增加手工入库的功能

This commit is contained in:
jiangs 2026-09-21 09:12:35 +08:00
parent 6910ed417a
commit a492ea4091
5 changed files with 815 additions and 2 deletions

View File

@ -0,0 +1,47 @@
---
name: blood-system
description: 输血系统前端项目编码规范与业务约束。在编写、修改、审查输血系统(blood_vue)前端代码时自动应用,涵盖组件写法、TS类型、接口请求、表单校验、安全约束等规则。
---
# 项目基础信息
- 项目名称:输血系统
- 前端项目:blood_vue
- 后端项目:blood
- 技术栈:Vue3 + TypeScript + Element Plus + Vite + Pinia + Axios + scss
- 后端:springboot + mybatis + redis
- 数据库:sqlserver2008
- 编码语言:中文注释,提示、弹窗文案使用中文
# 强制编码规则
1. 组件统一使用 `<script setup>` 语法糖,禁止使用 Options API;单文件组件结构顺序:script setup lang="ts" → template → style
2. 变量命名:小驼峰;常量全大写下划线;组件名大驼峰;禁止拼音命名,业务字段使用业务英文缩写
3. TS类型:所有接口入参、返回体、表单对象必须定义 interface/type,禁止 any;尽量使用 readonly 约束只读数据
4. 样式:统一使用 scoped,优先使用 Element Plus 自带样式,自定义 class 采用 BEM 命名,禁止全局污染
5. 导入:按顺序导入:vue内置包 → 第三方依赖 → 项目组件 → 工具函数 → 静态资源;统一使用 import,不使用 require
6. 每次新增组件,需要增加组件注释说明
# 接口请求规范
1. 所有请求必须使用项目已封装 axios 实例,禁止直接原生 axios;统一拦截器处理 token、401、500
2. 后端返回固定结构 `{code:number, msg:string, data:any}`,code=200 或 0 代表成功;业务异常统一捕获提示
3. 接口请求必须写 TS 类型定义,请求参数、返回 data 都要定义 interface;增加注释说明接口用途、业务场景
# 表单 & 校验
1. Element Plus ElForm,表单校验规则抽离单独 rules 对象;必填项添加 required:true,提示文案友好
2. 检验项目、标本类型、状态下拉选项,统一维护枚举常量,不要硬编码写死在 template
3. 保存、提交按钮:提交期间增加 loading 状态,防止重复提交;表单提交前先触发全部校验
# 业务与安全约束
1. 禁止:前端硬编码密钥、token、账号密码;禁止直接拼接 SQL 相关参数;禁止 eval 危险函数
2. 弹窗提示:操作类动作(删除、取消、作废)必须增加确认弹窗;成功/失败给出 toast 提示
# AI 输出约束
1. 输出代码优先完整片段,不要省略关键逻辑;新增组件同时补充 interface 类型
2. 给出代码后,附带简短说明:改动点、注意事项、需要后端配合的字段
3. 不要生成多余无关代码,保持简洁;如存在多种方案,优先选择和现有项目一致的写法
4. 如修改已有代码,先展示原有代码片段,再给出修改后的代码,标注改动位置

View File

@ -0,0 +1,33 @@
import request from "@/utils/request";
export function getBloodBreed(query?: Object) {
return request({
url: "/bus/bloodbank/handInStorage/getBloodBreed",
method: "get",
params: query,
});
}
export function saveData(data?: Object) {
return request({
url: "/bus/bloodbank/handInStorage/saveData",
method: "post",
data,
});
}
export function queryList(query?: Object) {
return request({
url: "/bus/bloodbank/handInStorage/queryList",
method: "get",
params: query,
});
}
export function queryDetail(query?: Object) {
return request({
url: "/bus/bloodbank/handInStorage/queryDetail",
method: "get",
params: query,
});
}

View File

@ -767,6 +767,30 @@ aside {
color: #FFF !important;
}
// SelectTable 下拉表格:行高30px,单元格内容垂直居中
.select-table-dropdown {
// 覆盖 vxe 默认行高(48px)与文字行高(22px),vxe 会按该变量计算单元格内联高度
--vxe-ui-table-row-height-default: 30px;
--vxe-ui-table-row-line-height: 30px;
.vxe-table {
.vxe-header--column,
.vxe-body--column {
.vxe-cell {
padding-top: 0 !important;
padding-bottom: 0 !important;
line-height: 30px !important;
display: flex;
align-items: center;
.vxe-cell--wrapper {
line-height: 30px !important;
}
}
}
}
}
// 滚动条
.mytable-style ::-webkit-scrollbar {
width: .6rem;

View File

@ -0,0 +1,709 @@
<template>
<div class="hand-in-storage">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" plain :icon="Search" @click="handleSearch">检索</el-button>
<el-button type="success" plain :icon="Plus" @click="handleAdd">新增</el-button>
<el-button type="warning" plain :icon="Fold" @click="handleSave" :loading="saveLoading">保存</el-button>
<el-button type="info" plain :icon="Printer">打印</el-button>
<el-button type="primary" plain :icon="Upload">导入</el-button>
<el-button type="danger" plain :icon="Close" @click="handleClose">关闭</el-button>
</div>
<!-- <div class="toolbar-right">
<el-checkbox v-model="printDetail">打印明细</el-checkbox>
</div> -->
</div>
<div class="form-section">
<div class="section-title">入库信息</div>
<el-form :model="mainForm" label-width="100px" label-position="left">
<el-row :gutter="15">
<el-col :span="6">
<el-form-item label="入库单号">
<el-input v-model="mainForm.bill_no" disabled placeholder="自动生成" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="入库时间">
<el-date-picker v-model="mainForm.occur_date" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="入库时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="经手人">
<SelectTable v-model:data="mainForm.handle_person" :tableData="userList" placeholder="经手人" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="血液来源">
<div class="source-box">
<el-select v-model="mainForm.source_type" placeholder="血站" style="width: 80px"
@change="handleSourceType">
<el-option label="血站" value="1" />
<el-option label="其它" value="2" />
</el-select>
<el-input v-model="mainForm.org" placeholder="请输入来源" style="flex: 1" />
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="15">
<el-col :span="6">
<el-form-item label="血站出库时间">
<el-date-picker v-model="mainForm.out_date" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="出库时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="18">
<el-form-item label="备注">
<el-input v-model="mainForm.remark" placeholder="备注" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="15">
<el-col :span="24">
<el-checkbox v-model="cancelBlood">取消血液</el-checkbox>
</el-col>
</el-row>
</el-form>
</div>
<div class="main-content">
<div class="left-panel">
<div class="left-panel-scroll">
<el-form :model="bloodForm" label-width="90px" label-position="left">
<el-form-item label="血液流水号">
<el-input v-model="bloodForm.blood_no" placeholder="请输入血液流水号" @keyup.enter="handleScan" />
</el-form-item>
<el-form-item label="产品号">
<el-input v-model="bloodForm.product_no" placeholder="产品号" />
</el-form-item>
<el-form-item label="血液品种">
<el-select v-model="bloodForm.blood_breed" placeholder="请选择血液品种" filterable
style="width: 100%" @change="handleBreedChange">
<el-option v-for="item in bloodBreedList" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="血型">
<el-select v-model="bloodForm.blood_type" placeholder="请选择血型" style="width: 100%">
<el-option v-for="item in bloodTypeOptions" :key="item.value" :label="item.label"
:value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="Rh(D)">
<el-select v-model="bloodForm.rh_blood_type" placeholder="Rh(D)" style="width: 100%">
<el-option label="+" value="+" />
<el-option label="-" value="-" />
</el-select>
</el-form-item>
<el-form-item label="容量">
<div class="capacity-box">
<el-input v-model="bloodForm.capacity" placeholder="容量" style="flex: 1" />
<el-input v-model="bloodForm.unit" placeholder="单位" style="width: 60px" />
</div>
</el-form-item>
<el-form-item label="有效日期">
<el-date-picker v-model="bloodForm.valid_date" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="有效日期" style="width: 100%" />
</el-form-item>
<el-form-item label="采血日期">
<el-date-picker v-model="bloodForm.pick_date" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="采血日期" style="width: 100%" />
</el-form-item>
<el-form-item label="入库价格">
<el-input-number v-model="bloodForm.price" :precision="2" :step="0.01" :min="0"
style="width: 100%" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Plus" @click="handleAddBlood">添加</el-button>
</el-form-item>
</el-form>
</div>
<div class="stat-box">
<div class="section-title">汇总信息</div>
<BloodTypeStatTable :data="bloodList" />
</div>
</div>
<div class="right-panel">
<div class="section-title">入库列表</div>
<el-table :data="bloodList" border highlight-current-row @current-change="handleRowSelect">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="blood_no" label="血液流水号" width="140" align="center" />
<el-table-column prop="blood_breed" label="血液品种" width="100" align="center">
<template #default="{ row }">
{{ formatBloodBreed(row.blood_breed) }}
</template>
</el-table-column>
<el-table-column prop="blood_type" label="血型" width="60" align="center" />
<el-table-column prop="rh_blood_type" label="Rh(D)" width="60" align="center" />
<el-table-column prop="capacity" label="容量" width="70" align="center" />
<el-table-column prop="unit" label="单位" width="60" align="center" />
<el-table-column prop="special_deal" label="特别处理" width="80" align="center" />
<el-table-column prop="valid_date" label="有效日期" width="150" align="center" />
<el-table-column prop="pick_date" label="采血日期" width="150" align="center" />
<el-table-column prop="remark" label="备注" align="center" />
<el-table-column prop="product_no" label="产品码" width="120" align="center" />
<!-- 删除血液 -->
<el-table-column label="操作" align="center" :show-overflow-tooltip="false">
<template #default="{ row, $index }">
<el-button type="primary" text @click="delBlood(row, $index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog v-model="searchDialogVisible" title="入库检索" width="700px" :close-on-click-modal="false">
<el-form :model="searchForm" label-width="100px">
<el-row :gutter="15">
<el-col :span="12">
<el-form-item label="入库单号">
<el-input v-model="searchForm.billNo" placeholder="入库单号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="科室">
<el-input v-model="searchForm.dept" placeholder="科室" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="15">
<el-col :span="24">
<el-form-item label="入库时间">
<div class="date-range">
<el-date-picker v-model="searchForm.stime" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width: 45%" />
<span class="separator">-</span>
<el-date-picker v-model="searchForm.etime" type="datetime"
value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width: 45%" />
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="dialog-table-box">
<el-button type="primary" :icon="Search" @click="doSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
<el-table :data="queryResultList" border highlight-current-row height="280"
@current-change="handleQueryRowSelect">
<el-table-column prop="bill_no" label="入库单号" width="140" align="center" />
<el-table-column prop="occur_date" label="入库时间" width="160" align="center" />
<el-table-column prop="handle_person" label="经手人" width="100" align="center" />
<el-table-column prop="total_money" label="总金额" width="100" align="center" />
<el-table-column prop="remark" label="备注" align="center" />
</el-table>
</div>
<template #footer>
<el-button @click="searchDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSelect">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { Search, Plus, Fold, Printer, Upload, Close, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import useUserStore from '@/store/modules/user'
import { useCommonDict } from '@/hooks'
import { getBloodBreed, saveData, queryList, queryDetail } from '@/api/blood/handInStorage'
import BloodTypeStatTable from '@/components/BloodTypeStatTable/index.vue'
const userInfo = useUserStore()
const { getServerTime, bloodBreed: bloodBreedList, getBloodList, userList, getUserData } = useCommonDict()
const printDetail = ref(false)
const cancelBlood = ref(false)
const resetBloodAfterScan = ref(false)
const saveLoading = ref(false)
const searchDialogVisible = ref(false)
const selectedBloodRow = ref<any>(null)
const selectedQueryRow = ref<any>(null)
const bloodTypeOptions = [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'O', value: 'O' },
{ label: 'AB', value: 'AB' }
]
const mainForm = ref<any>({
bill_no: '',
occur_date: '',
handle_person: userInfo.name,
source_type: '1',
org: '',
out_date: '',
remark: ''
})
const bloodForm = ref<any>({
blood_no: '',
product_no: '',
blood_breed: '',
blood_type: '',
rh_blood_type: '+',
capacity: '',
unit: '',
valid_date: '',
pick_date: '',
produce_date: '',
price: 0,
special_deal: '',
remark: ''
})
const bloodList = ref<any[]>([])
const searchForm = ref<any>({
billNo: '',
dept: '',
stime: '',
etime: ''
})
const queryResultList = ref<any[]>([])
const formatBloodBreed = (code: string) => {
const item = bloodBreedList.value.find((b: any) => b.value === code)
return item ? item.label : code
}
const handleSourceType = () => {
if (mainForm.value.source_type === '1') {
mainForm.value.org = ''
}
}
const handleBreedChange = (val: string) => {
if (val) {
getBloodBreed({ breedCode: val }).then((res: any) => {
if (res.data) {
bloodForm.value.unit = res.data.unit || bloodForm.value.unit
bloodForm.value.product_no = res.data.station_breed_code || bloodForm.value.product_no
}
})
}
}
// 入库前必填项校验:血液品种、血型、Rh(D)血型、产品号、容量、有效日期
const validateBloodForm = () => {
if (!bloodForm.value.blood_breed) {
ElMessage.warning('请选择血液品种')
return false
}
if (!bloodForm.value.blood_type) {
ElMessage.warning('请选择血型')
return false
}
if (!bloodForm.value.rh_blood_type) {
ElMessage.warning('请选择Rh(D)血型')
return false
}
if (!bloodForm.value.product_no) {
ElMessage.warning('请输入产品号')
return false
}
if (!bloodForm.value.capacity) {
ElMessage.warning('请输入容量')
return false
}
if (!bloodForm.value.valid_date) {
ElMessage.warning('请选择有效日期')
return false
}
return true
}
const handleScan = () => {
const bloodNo = bloodForm.value.blood_no?.trim()
if (!bloodNo) return
const index = bloodList.value.findIndex(item => item.blood_no === bloodNo)
if (cancelBlood.value) {
// 取消血液:从入库列表中删除对应血液流水号数据
if (index > -1) {
const removed = bloodList.value.splice(index, 1)
if (selectedBloodRow.value === removed[0]) {
selectedBloodRow.value = null
}
ElMessage.success('已取消血液:' + bloodNo)
} else {
ElMessage.warning('入库列表中不存在该血液流水号')
}
bloodForm.value.blood_no = ''
return
}
if (index > -1) {
ElMessage.warning('该血液流水号已存在')
bloodForm.value.blood_no = ''
return
}
if (!validateBloodForm()) return
bloodList.value.push({ ...bloodForm.value })
if (resetBloodAfterScan.value) {
resetBloodForm()
} else {
bloodForm.value.blood_no = ''
}
}
const handleAddBlood = () => {
if (!bloodForm.value.blood_no) {
return ElMessage.warning('请输入血液流水号')
}
// 勾选“取消血液”时,添加按钮也执行取消删除逻辑
if (cancelBlood.value) {
handleScan()
return
}
if (!validateBloodForm()) return
const exists = bloodList.value.some(item => item.blood_no === bloodForm.value.blood_no)
if (exists) {
ElMessage.warning('该血液流水号已存在')
return
}
bloodList.value.push({ ...bloodForm.value })
resetBloodForm()
}
const resetBloodForm = () => {
bloodForm.value = {
blood_no: '',
product_no: '',
blood_breed: '',
blood_type: '',
rh_blood_type: '+',
capacity: '',
unit: '',
valid_date: '',
pick_date: '',
produce_date: '',
price: 0,
special_deal: '',
remark: ''
}
}
const handleRowSelect = (row: any) => {
selectedBloodRow.value = row
}
const delBlood = (row: any, index: number) => {
bloodList.value.splice(index, 1)
if (selectedBloodRow.value === row) {
selectedBloodRow.value = null
}
}
const handleSearch = () => {
searchDialogVisible.value = true
searchForm.value = {
billNo: '',
dept: '',
stime: dayjs().subtract(30, 'day').format('YYYY-MM-DD') + ' 00:00:00',
etime: dayjs().format('YYYY-MM-DD') + ' 23:59:59'
}
doSearch()
}
const doSearch = () => {
queryList(searchForm.value).then((res: any) => {
queryResultList.value = res.data || []
})
}
const resetSearch = () => {
searchForm.value = {
billNo: '',
dept: '',
stime: '',
etime: ''
}
}
const handleQueryRowSelect = (row: any) => {
selectedQueryRow.value = row
}
const confirmSelect = () => {
if (!selectedQueryRow.value) {
ElMessage.warning('请选择一条记录')
return
}
queryDetail({ billNo: selectedQueryRow.value.bill_no }).then((res: any) => {
if (res.data) {
const main = res.data.xkInstorageMain || {}
// 数据库存的是姓名(nick_name),回显时转换回用户代号以匹配下拉选项
main.handle_person = userList.value.find(v => v.label === main.handle_person)?.value || main.handle_person
mainForm.value = main
bloodList.value = res.data.xkBloodInfoList || []
}
searchDialogVisible.value = false
selectedQueryRow.value = null
})
}
const handleAdd = () => {
getServerTime().then((time: string) => {
mainForm.value = {
bill_no: '',
occur_date: time,
handle_person: userInfo.name,
source_type: '1',
org: '',
out_date: time,
remark: ''
}
})
bloodList.value = []
resetBloodForm()
selectedBloodRow.value = null
}
const handleSave = () => {
if (!mainForm.value.occur_date) {
return ElMessage.warning('请选择入库时间')
}
if (!mainForm.value.handle_person) {
return ElMessage.warning('请选择经手人')
}
if (bloodList.value.length === 0) {
return ElMessage.warning('请添加血液信息')
}
const xkInstorageDetailList = bloodList.value.map((item, index) => ({
sid: index + 1,
bill_no: '',
blood_no: item.blood_no,
product_no: item.product_no,
price: item.price || 0,
remark: item.remark || ''
}))
const xkBloodInfoList = bloodList.value.map(item => ({
blood_no: item.blood_no,
product_no: item.product_no,
blood_breed: item.blood_breed,
blood_type: item.blood_type,
rh_blood_type: item.rh_blood_type,
capacity: item.capacity,
unit: item.unit,
valid_date: item.valid_date,
pick_date: item.pick_date,
produce_date: item.produce_date,
special_deal: item.special_deal,
Remark: item.remark || ''
}))
const totalMoney = bloodList.value.reduce((sum: number, item: any) => sum + (Number(item.price) || 0), 0)
const data = {
xkInstorageMain: {
bill_no: '',
org: mainForm.value.org,
out_date: mainForm.value.out_date,
occur_date: mainForm.value.occur_date,
// 下拉选的是用户代号,入库时写入姓名(nick_name)
handle_person: userList.value.find(v => v.value === mainForm.value.handle_person)?.label || mainForm.value.handle_person,
remark: mainForm.value.remark,
total_money: totalMoney
},
xkInstorageDetailList,
xkBloodInfoList
}
saveLoading.value = true
saveData(data).then((res: any) => {
if (res.code === 0) {
ElMessage.success('保存成功,入库单号:' + res.data)
mainForm.value.bill_no = res.data
}
}).finally(() => {
saveLoading.value = false
})
}
const handleClose = () => {
ElMessageBox.confirm('确定关闭当前录入吗?未保存的数据将丢失', '提示', {
type: 'warning'
}).then(() => {
handleAdd()
}).catch(() => { })
}
onMounted(() => {
getBloodList()
getUserData()
getServerTime().then((time: string) => {
mainForm.value.occur_date = time
mainForm.value.out_date = time
})
})
</script>
<style lang="scss" scoped>
.hand-in-storage {
display: flex;
flex-direction: column;
// height: 100%;
padding: 10px;
box-sizing: border-box;
// overflow-y: auto;
}
.toolbar {
flex: none;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
margin-bottom: 10px;
.toolbar-left {
display: flex;
gap: 8px;
}
}
.form-section {
flex: none;
padding: 10px 15px;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 4px;
margin-bottom: 10px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
padding: 0 0 6px 10px;
margin-bottom: 8px;
border-bottom: 1px solid #ebeef5;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
bottom: 7px;
width: 4px;
height: 14px;
background-color: #409eff;
border-radius: 0 4px 4px 0;
}
}
.main-content {
flex: 1;
min-height: 0;
display: flex;
gap: 10px;
}
.left-panel {
width: 380px;
flex: 0 0 380px;
display: flex;
flex-direction: column;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px;
min-height: 0;
}
.left-panel-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.right-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px;
.el-table {
min-height: 59vh;
}
}
// .stat-box {
// flex: none;
// flex-shrink: 0;
// padding-top: 10px;
// border-top: 1px solid #ebeef5;
// background: #fafbfc;
// .total-row {
// display: flex;
// justify-content: space-between;
// align-items: center;
// padding: 8px 12px;
// background: #ecf5ff;
// border-radius: 4px;
// margin-top: 8px;
// .total-num {
// font-size: 18px;
// font-weight: 700;
// color: #409eff;
// }
// }
// }
.source-box {
display: flex;
gap: 6px;
width: 100%;
}
.capacity-box {
display: flex;
gap: 6px;
width: 100%;
}
.ml20 {
margin-left: 20px;
}
.date-range {
display: flex;
align-items: center;
width: 100%;
.separator {
margin: 0 8px;
color: #909399;
}
}
.dialog-table-box {
display: flex;
flex-direction: column;
gap: 8px;
}
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.left-panel .el-form-item__label) {
white-space: nowrap;
}
</style>

View File

@ -48,8 +48,8 @@ export default defineConfig(({ mode, command }) => {
proxy: {
// https://cn.vitejs.dev/config/#server-proxy
"/dev-api": {
// target: "http://localhost:8907",
target: "http://47.97.125.165:8907",
target: "http://localhost:8907",
//target: "http://47.97.125.165:8907",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, ""),
},