Browse Source

feat: 新增菌种和灭菌记录管理功能

本次提交新增了完整的菌种与灭菌记录管理模块:
1. 在首页导航添加菌种记录、灭菌记录入口
2. 新增菌种入库、领用的全流程管理页面与API
3. 新增灭菌记录的增删改查管理页面与API
4. 配置对应的路由跳转规则
张旭伟 1 month ago
parent
commit
d93f1d8667

+ 23 - 0
src/api/sterilization/index.ts

@@ -0,0 +1,23 @@
+import request from '/@/utils/micro_request.js'
+
+const basePath = import.meta.env.VITE_INSTR_ADMIN
+
+export function useSterilizationApi() {
+    return {
+        getList(query?: object) {
+            return request.postRequest(basePath, 'TusSterilization', 'GetList', query)
+        },
+        getById(query?: object) {
+            return request.postRequest(basePath, 'TusSterilization', 'GetById', query)
+        },
+        create(query?: object) {
+            return request.postRequest(basePath, 'TusSterilization', 'Create', query)
+        },
+        update(query?: object) {
+            return request.postRequest(basePath, 'TusSterilization', 'Update', query)
+        },
+        delete(query?: object) {
+            return request.postRequest(basePath, 'TusSterilization', 'Delete', query)
+        },
+    }
+}

+ 36 - 0
src/api/strain/index.ts

@@ -0,0 +1,36 @@
+import request from '/@/utils/micro_request.js'
+
+const basePath = import.meta.env.VITE_INSTR_ADMIN
+
+export function useStrainApi() {
+    return {
+        getInboundList(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'GetInboundList', query)
+        },
+        getInboundById(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'GetInboundById', query)
+        },
+        createInbound(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'CreateInbound', query)
+        },
+        updateInbound(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'UpdateInbound', query)
+        },
+        deleteInbound(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'DeleteInbound', query)
+        },
+
+        getUsageList(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'GetUsageList', query)
+        },
+        getUsageById(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'GetUsageById', query)
+        },
+        createUsage(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'CreateUsage', query)
+        },
+        deleteUsage(query?: object) {
+            return request.postRequest(basePath, 'TusStrain', 'DeleteUsage', query)
+        },
+    }
+}

+ 40 - 0
src/router.ts

@@ -154,6 +154,46 @@ const routes = [
       title: '隐患整改'
     }
   },
+  {
+    name: 'strain',
+    path: '/strain',
+    component: () => import('/@/view/strain/index.vue'),
+    meta: {
+      title: '菌种记录'
+    }
+  },
+  {
+    name: 'strainInboundAdd',
+    path: '/strain/inbound/add',
+    component: () => import('/@/view/strain/inbound/add.vue'),
+    meta: {
+      title: '录入菌种入库'
+    }
+  },
+  {
+    name: 'strainUsageAdd',
+    path: '/strain/usage/add',
+    component: () => import('/@/view/strain/usage/add.vue'),
+    meta: {
+      title: '录入菌种领用'
+    }
+  },
+  {
+    name: 'sterilization',
+    path: '/sterilization',
+    component: () => import('/@/view/sterilization/index.vue'),
+    meta: {
+      title: '灭菌记录'
+    }
+  },
+  {
+    name: 'sterilizationAdd',
+    path: '/sterilization/add',
+    component: () => import('/@/view/sterilization/add.vue'),
+    meta: {
+      title: '录入灭菌记录'
+    }
+  },
   {
     name: 'repairReportConfirm',
     path: '/inst/repairReport/confirm',

+ 8 - 0
src/view/home/index.vue

@@ -63,6 +63,14 @@
           <img src="../../assets/img/更多应用.png" alt="" />
           <p>巡检任务</p>
         </li>
+        <li @click="onRouterPush('/strain')">
+          <img src="../../assets/img/更多应用.png" alt="" />
+          <p>菌种记录</p>
+        </li>
+        <li @click="onRouterPush('/sterilization')">
+          <img src="../../assets/img/更多应用.png" alt="" />
+          <p>灭菌记录</p>
+        </li>
       </ul>
     </div>
     <div class="swipe-con flex justify-between mt10">

+ 255 - 0
src/view/sterilization/add.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="app-container">
+    <van-form ref="formRef" @submit="onSubmit" required="auto">
+      <h4 class="mb8 mt8">灭菌信息</h4>
+      <van-cell-group>
+        <van-field
+          v-model="state.form.recordDate"
+          label="日期"
+          placeholder="请选择日期"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请选择日期' }]"
+          @click="isDetail || (showDatePicker = true)"
+        />
+        <van-field
+          v-model="state.form.operator"
+          label="实验/移交人"
+          placeholder="当前用户"
+          readonly
+        />
+      </van-cell-group>
+
+      <h4 class="mb8 mt8">灭菌物品</h4>
+      <van-cell-group>
+        <van-field label="选择灭菌物品">
+          <template #input>
+            <van-checkbox-group v-model="state.checkedItems" direction="horizontal">
+              <van-checkbox style="margin-bottom:15px;" name="10" shape="square" :disabled="isDetail">实验固体废弃物</van-checkbox>
+              <van-checkbox style="margin-bottom:15px;" name="20" shape="square" :disabled="isDetail">防护物品固体废弃物</van-checkbox>
+              <van-checkbox style="margin-bottom:15px;" name="30" shape="square" :disabled="isDetail">需重复使用的器皿</van-checkbox>
+              <van-checkbox name="40" shape="square" :disabled="isDetail">液体废弃物</van-checkbox>
+            </van-checkbox-group>
+          </template>
+        </van-field>
+        <van-field
+          v-if="state.checkedItems.includes('40')"
+          v-model="state.form.liquidWasteDesc"
+          label="液体废弃物名称"
+          placeholder="请填写液体废弃物名称"
+          maxlength="255"
+          :readonly="isDetail"
+        />
+      </van-cell-group>
+
+      <h4 class="mb8 mt8">灭菌详情</h4>
+      <van-cell-group>
+        <van-field
+          v-model="state.form.quantity"
+          label="数量/袋"
+          placeholder="如:5"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入数量/袋' }]"
+        />
+        <van-field name="uploader" label="灭菌物理监测">
+          <template #input>
+            <template v-if="isDetail">
+              <a v-if="state.form.monitorAttachment" :href="state.form.monitorAttachment" target="_blank" style="color:#3c78e3">{{ state.form.monitorAttachmentName || '查看附件' }}</a>
+              <span v-else>-</span>
+            </template>
+            <van-uploader v-else v-model="state.fileList" :after-read="afterRead" preview-size="60" :preview-full-image="true" :max-count="1" />
+          </template>
+        </van-field>
+        <van-field
+          v-model="state.form.duration"
+          label="灭菌时长/min"
+          placeholder="如:30"
+          maxlength="225"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入灭菌时长' }]"
+        />
+      </van-cell-group>
+
+      <h4 class="mb8 mt8">仪器状态</h4>
+      <van-cell-group>
+        <van-field label="状态" :rules="isDetail ? [] : [{ required: true, message: '请选择仪器状态' }]">
+          <template #input>
+            <van-radio-group v-model="state.form.instrumentStatus" direction="horizontal">
+              <van-radio name="10" :disabled="isDetail">正常</van-radio>
+              <van-radio name="20" :disabled="isDetail">故障</van-radio>
+            </van-radio-group>
+          </template>
+        </van-field>
+        <van-field
+          v-if="state.form.instrumentStatus === '20'"
+          v-model="state.form.faultDesc"
+          label="故障描述"
+          placeholder="请描述故障情况"
+          maxlength="512"
+          type="textarea"
+          rows="2"
+          autosize
+          :readonly="isDetail"
+        />
+      </van-cell-group>
+
+      <van-action-bar placeholder>
+        <van-action-bar-icon icon="wap-home-o" text="首页" @click="router.push('/home')" />
+        <van-action-bar-icon icon="revoke" text="返回" @click="router.push('/sterilization')" />
+        <van-action-bar-button v-if="!isDetail" type="primary" :text="isEditMode ? '保存修改' : '立即提交'" :loading="submitting" native-type="submit" />
+      </van-action-bar>
+    </van-form>
+
+    <van-popup v-model:show="showDatePicker" position="bottom">
+      <van-date-picker v-model="currentDate" title="选择日期" @confirm="onConfirmDate" @cancel="showDatePicker = false" />
+    </van-popup>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import to from 'await-to-js'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useSterilizationApi } from '/@/api/sterilization'
+import { useUserInfo } from '/@/stores/userInfo'
+import { formatDate } from '/@/utils/formatTime'
+import { handleUpload } from '/@/utils/upload'
+import { showNotify } from 'vant'
+
+const router = useRouter()
+const route = useRoute()
+const sterilizationApi = useSterilizationApi()
+const userStore = useUserInfo()
+
+const formRef = ref()
+const showDatePicker = ref(false)
+const currentDate = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
+const submitting = ref(false)
+
+const pageMode = computed(() => (route.query.mode as string) || 'add')
+const isDetail = computed(() => pageMode.value === 'detail')
+const isEditMode = computed(() => pageMode.value === 'edit')
+
+const state = reactive({
+  form: {
+    id: 0,
+    recordDate: formatDate(new Date(), 'YYYY-mm-dd'),
+    operatorId: 0,
+    operator: '',
+    sterilizationItems: '',
+    liquidWasteDesc: '',
+    quantity: '',
+    monitorAttachment: '',
+    monitorAttachmentName: '',
+    duration: '',
+    instrumentStatus: '10',
+    faultDesc: '',
+  },
+  checkedItems: [] as string[],
+  fileList: [] as any[],
+})
+
+const onConfirmDate = () => {
+  showDatePicker.value = false
+  state.form.recordDate = `${currentDate.value[0]}-${String(currentDate.value[1]).padStart(2, '0')}-${String(currentDate.value[2]).padStart(2, '0')}`
+}
+
+const initForm = () => {
+  const u = userStore.userInfos
+  state.form.operatorId = u.id
+  state.form.operator = u.nickName || ''
+  state.form.recordDate = formatDate(new Date(), 'YYYY-mm-dd')
+}
+
+const loadData = async (id: number) => {
+  const [err, res]: any = await to(sterilizationApi.getById({ id }))
+  if (err) return
+  const d = res?.data
+  if (d) {
+    state.form = {
+      id: d.id || 0,
+      recordDate: d.recordDate || '',
+      operatorId: d.operatorId || 0,
+      operator: d.operator || '',
+      sterilizationItems: d.sterilizationItems || '',
+      liquidWasteDesc: d.liquidWasteDesc || '',
+      quantity: d.quantity || '',
+      monitorAttachment: d.monitorAttachment || '',
+      monitorAttachmentName: d.monitorAttachmentName || '',
+      duration: d.duration || '',
+      instrumentStatus: d.instrumentStatus || '10',
+      faultDesc: d.faultDesc || '',
+    }
+    state.checkedItems = d.sterilizationItems ? d.sterilizationItems.split(',').filter(Boolean) : []
+    if (d.monitorAttachment) {
+      state.fileList = [{ name: d.monitorAttachmentName || d.monitorAttachment.split('/').pop() || '附件', url: d.monitorAttachment }]
+    }
+  }
+}
+
+const afterRead = async (file: any) => {
+  file.status = 'uploading'
+  const [err, res]: ToResponse = await to(handleUpload(file.file))
+  if (err) {
+    file.status = 'failed'
+    return
+  }
+  file.status = 'success'
+  file.url = res as string
+  file.name = file.file.name
+  state.form.monitorAttachment = res as string
+  state.form.monitorAttachmentName = file.file.name
+}
+
+const onSubmit = async () => {
+  const [errValid] = await to(formRef.value.validate())
+  if (errValid) return
+
+  if (state.checkedItems.length === 0) {
+    showNotify({ type: 'warning', message: '请至少选择一项灭菌物品' })
+    return
+  }
+
+  submitting.value = true
+  state.form.sterilizationItems = state.checkedItems.join(',')
+  const params = JSON.parse(JSON.stringify(state.form))
+  const post = isEditMode.value ? sterilizationApi.update : sterilizationApi.create
+  const [err]: any = await to(post(params))
+  submitting.value = false
+  if (err) return
+
+  showNotify({ type: 'success', message: isEditMode.value ? '修改成功' : '新增成功' })
+  setTimeout(() => {
+    router.push('/sterilization')
+  }, 1000)
+}
+
+onMounted(async () => {
+  const id = route.query.id
+  if (id) {
+    await loadData(Number(id))
+  } else {
+    initForm()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  h4 {
+    margin: 10px 0;
+    height: 20px;
+    line-height: 20px;
+    padding-left: 16px;
+    &::before {
+      display: inline-block;
+      content: '';
+      background-color: #3c78e3;
+      width: 4px;
+      height: 20px;
+      margin-right: 4px;
+      vertical-align: top;
+    }
+  }
+}
+</style>

+ 201 - 0
src/view/sterilization/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="app-container">
+    <div class="search-wrap">
+      <van-search v-model="state.queryForm.operator" placeholder="搜索实验/移交人" @search="onSearch" />
+    </div>
+    <div class="list-container">
+      <van-list v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad">
+        <van-cell v-for="item in state.list" :key="item.id" @click="onDetail(item)">
+          <template #default>
+            <div class="card-item">
+              <header class="flex justify-between">
+                <strong class="title">{{ formatItems(item.sterilizationItems) }}</strong>
+                <van-tag :type="item.instrumentStatus === '10' ? 'success' : 'danger'" size="medium">
+                  {{ item.instrumentStatus === '10' ? '正常' : '故障' }}
+                </van-tag>
+              </header>
+              <p class="info-row"><span>日期</span><span class="val">{{ item.recordDate ? formatDate(new Date(item.recordDate), 'YYYY-mm-dd') : '-' }}</span></p>
+              <p class="info-row"><span>操作人</span><span class="val">{{ item.operator }}</span></p>
+              <p class="info-row"><span>数量/袋</span><span class="val">{{ item.quantity || '-' }}</span></p>
+              <p class="info-row"><span>时长/min</span><span class="val">{{ item.duration || '-' }}</span></p>
+              <p class="info-row" v-if="item.liquidWasteDesc && item.sterilizationItems && item.sterilizationItems.includes('40')"><span>液体废弃物描述</span><span class="val">{{ item.liquidWasteDesc }}</span></p>
+              <p class="info-row" v-if="item.faultDesc && item.instrumentStatus === '20'"><span>故障描述</span><span class="val">{{ item.faultDesc }}</span></p>
+              <p class="info-row" v-if="item.monitorAttachment">
+                <span>灭菌监测</span>
+                <a class="val" :href="item.monitorAttachment" target="_blank">{{ item.monitorAttachmentName || '查看附件' }}</a>
+              </p>
+              <footer class="flex justify-between mt4">
+                <span class="created-name">{{ item.createdName }}</span>
+                <span class="time">{{ item.createdTime ? formatDate(new Date(item.createdTime), 'YYYY-mm-dd') : '' }}</span>
+              </footer>
+              <div class="flex mt10 btns">
+                <van-button v-auth="'instr_sterilization_edit'" size="small" type="primary" @click.stop="onEdit(item)">修改</van-button>
+                <van-button v-auth="'instr_sterilization_del'" size="small" type="danger" @click.stop="onDel(item)">删除</van-button>
+              </div>
+            </div>
+          </template>
+        </van-cell>
+      </van-list>
+    </div>
+    <van-floating-bubble v-auth="'instr_sterilization_add'" icon="plus" @click="onAdd" axis="y" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import to from 'await-to-js'
+import { formatDate } from '/@/utils/formatTime'
+import { onMounted, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import { useSterilizationApi } from '/@/api/sterilization'
+import { showConfirmDialog, showNotify } from 'vant'
+
+const router = useRouter()
+const sterilizationApi = useSterilizationApi()
+
+const itemsMap: Record<string, string> = {
+  '10': '实验固体废弃物',
+  '20': '防护物品固体废弃物',
+  '30': '需重复使用的器皿',
+  '40': '液体废弃物',
+}
+
+const formatItems = (val: string) => {
+  if (!val) return '-'
+  return val.split(',').map((v) => itemsMap[v] || v).join('、')
+}
+
+const state = reactive({
+  queryForm: {
+    operator: '',
+    pageNum: 1,
+    pageSize: 10
+  },
+  loading: false,
+  finished: false,
+  list: [] as any[]
+})
+
+const onLoad = async () => {
+  const [err, res]: any = await to(sterilizationApi.getList(state.queryForm))
+  if (err) return
+  const list = res?.data?.list || res?.data || []
+  for (const item of list) {
+    state.list.push(item)
+  }
+  state.loading = false
+  state.queryForm.pageNum++
+  if (list.length < state.queryForm.pageSize) {
+    state.finished = true
+  }
+}
+
+const onSearch = () => {
+  state.queryForm.pageNum = 1
+  state.list = []
+  state.finished = false
+  onLoad()
+}
+
+const onDetail = (item: any) => {
+  router.push(`/sterilization/add?mode=detail&id=${item.id}`)
+}
+
+const onAdd = () => {
+  router.push('/sterilization/add')
+}
+
+const onEdit = (item: any) => {
+  router.push(`/sterilization/add?mode=edit&id=${item.id}`)
+}
+
+const onDel = async (item: any) => {
+  showConfirmDialog({
+    title: '提示',
+    message: '确认删除该灭菌记录?'
+  }).then(async () => {
+    const [err]: any = await to(sterilizationApi.delete({ ids: [item.id] }))
+    if (err) return
+    showNotify({ type: 'success', message: '删除成功' })
+    state.queryForm.pageNum = 1
+    state.list = []
+    state.finished = false
+    onLoad()
+  }).catch(() => {})
+}
+
+onMounted(() => {
+  onLoad()
+})
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  background-color: #f7f8fa;
+}
+.search-wrap {
+  background: #fff;
+}
+.list-container {
+  overflow-y: auto;
+  padding: 10px;
+  flex: 1;
+  .van-list {
+    .van-cell {
+      background-color: #fff;
+      border-radius: 8px;
+      + .van-cell {
+        margin-top: 10px;
+      }
+    }
+  }
+}
+.card-item {
+  header {
+    .title {
+      flex: 1;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      font-size: 16px;
+      margin-right: 8px;
+    }
+  }
+  .info-row {
+    display: flex;
+    justify-content: space-between;
+    color: #333;
+    margin-top: 4px;
+    span:first-child {
+      color: #787878;
+      flex: 0 0 70px;
+    }
+    .val {
+      flex: 1;
+      text-align: right;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    a.val {
+      color: #3c78e3;
+      text-decoration: underline;
+    }
+  }
+  footer {
+    color: #333;
+    .created-name {
+      flex: 1;
+    }
+    .time {
+      color: #f69a4d;
+    }
+  }
+  .btns {
+    justify-content: flex-end;
+    gap: 10px;
+  }
+}
+</style>

+ 178 - 0
src/view/strain/inbound/add.vue

@@ -0,0 +1,178 @@
+<template>
+  <div class="app-container">
+    <van-form ref="formRef" @submit="onSubmit" required="auto">
+      <h4 class="mb8 mt8">入库信息</h4>
+      <van-cell-group>
+        <van-field
+          v-model="state.form.strainName"
+          label="菌毒种名称"
+          placeholder="请输入菌毒种名称"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入菌毒种名称' }]"
+        />
+        <van-field
+          v-model="state.form.preservationTemp"
+          label="保存温度"
+          placeholder="如:-80°C"
+          maxlength="90"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入保存温度' }]"
+        />
+        <van-field
+          v-model="state.form.storageLocation"
+          label="存放地点"
+          placeholder="请输入存放地点"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入存放地点' }]"
+        />
+        <van-field
+          v-model="state.form.inboundDate"
+          label="入库日期"
+          placeholder="请选择入库日期"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请选择入库日期' }]"
+          @click="isDetail || (showDatePicker = true)"
+        />
+        <van-field
+          v-model="state.form.inboundPerson"
+          label="入库人"
+          placeholder="当前用户"
+          readonly
+        />
+        <van-field
+          v-model="state.form.quantity"
+          label="入库量"
+          placeholder="如:10包"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入入库量' }]"
+        />
+      </van-cell-group>
+
+      <van-action-bar placeholder>
+        <van-action-bar-icon icon="wap-home-o" text="首页" @click="router.push('/home')" />
+        <van-action-bar-icon icon="revoke" text="返回" @click="router.push('/strain')" />
+        <van-action-bar-button v-if="!isDetail" type="primary" :text="isEditMode ? '保存修改' : '立即提交'" :loading="submitting" native-type="submit" />
+      </van-action-bar>
+    </van-form>
+
+    <van-popup v-model:show="showDatePicker" position="bottom">
+      <van-date-picker v-model="currentDate" title="选择日期" @confirm="onConfirmDate" @cancel="showDatePicker = false" />
+    </van-popup>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import to from 'await-to-js'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useStrainApi } from '/@/api/strain'
+import { useUserInfo } from '/@/stores/userInfo'
+import { formatDate } from '/@/utils/formatTime'
+import { showNotify } from 'vant'
+
+const router = useRouter()
+const route = useRoute()
+const strainApi = useStrainApi()
+const userStore = useUserInfo()
+
+const formRef = ref()
+const showDatePicker = ref(false)
+const currentDate = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
+const submitting = ref(false)
+
+const pageMode = computed(() => (route.query.mode as string) || 'add')
+const isDetail = computed(() => pageMode.value === 'detail')
+const isEditMode = computed(() => pageMode.value === 'edit')
+
+const state = reactive({
+  form: {
+    id: 0,
+    strainName: '',
+    preservationTemp: '',
+    storageLocation: '',
+    inboundDate: formatDate(new Date(), 'YYYY-mm-dd'),
+    inboundPersonId: 0,
+    inboundPerson: '',
+    quantity: '',
+  }
+})
+
+const onConfirmDate = () => {
+  showDatePicker.value = false
+  state.form.inboundDate = `${currentDate.value[0]}-${String(currentDate.value[1]).padStart(2, '0')}-${String(currentDate.value[2]).padStart(2, '0')}`
+}
+
+const initForm = () => {
+  const u = userStore.userInfos
+  state.form.inboundPersonId = u.id
+  state.form.inboundPerson = u.nickName || ''
+  state.form.inboundDate = formatDate(new Date(), 'YYYY-mm-dd')
+}
+
+const loadData = async (id: number) => {
+  const [err, res]: any = await to(strainApi.getInboundById({ id }))
+  if (err) return
+  const d = res?.data
+  if (d) {
+    state.form = {
+      id: d.id || 0,
+      strainName: d.strainName || '',
+      preservationTemp: d.preservationTemp || '',
+      storageLocation: d.storageLocation || '',
+      inboundDate: d.inboundDate || '',
+      inboundPersonId: d.inboundPersonId || 0,
+      inboundPerson: d.inboundPerson || '',
+      quantity: d.quantity || '',
+    }
+  }
+}
+
+const onSubmit = async () => {
+  const [errValid] = await to(formRef.value.validate())
+  if (errValid) return
+
+  submitting.value = true
+  const params = JSON.parse(JSON.stringify(state.form))
+  const post = isEditMode.value ? strainApi.updateInbound : strainApi.createInbound
+  const [err]: any = await to(post(params))
+  submitting.value = false
+  if (err) return
+
+  showNotify({ type: 'success', message: isEditMode.value ? '修改成功' : '新增成功' })
+  setTimeout(() => {
+    router.push('/strain')
+  }, 1000)
+}
+
+onMounted(async () => {
+  const id = route.query.id
+  if (id) {
+    await loadData(Number(id))
+  } else {
+    initForm()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  h4 {
+    margin: 10px 0;
+    height: 20px;
+    line-height: 20px;
+    padding-left: 16px;
+    &::before {
+      display: inline-block;
+      content: '';
+      background-color: #3c78e3;
+      width: 4px;
+      height: 20px;
+      margin-right: 4px;
+      vertical-align: top;
+    }
+  }
+}
+</style>

+ 220 - 0
src/view/strain/index.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="app-container">
+    <div class="search-wrap">
+      <van-search v-model="searchKey" placeholder="搜索菌毒种名称" @search="onSearch" />
+    </div>
+    <van-tabs v-model:active="activeTab" @change="onTabChange">
+      <van-tab title="入库记录" name="inbound"></van-tab>
+      <van-tab title="领用记录" name="usage"></van-tab>
+    </van-tabs>
+    <div class="list-container">
+      <van-list v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad">
+        <van-cell v-for="item in state.list" :key="item.id" @click="onDetail(item)">
+          <template #default>
+            <!-- 入库记录卡片 -->
+            <div v-if="activeTab === 'inbound'" class="card-item">
+              <header class="flex justify-between">
+                <strong class="title">{{ item.strainName }}</strong>
+                <van-tag type="primary" size="medium">{{ item.quantity }}</van-tag>
+              </header>
+              <p class="info-row"><span>保存温度</span><span class="val">{{ item.preservationTemp }}</span></p>
+              <p class="info-row"><span>存放地点</span><span class="val">{{ item.storageLocation }}</span></p>
+              <p class="info-row"><span>入库日期</span><span class="val">{{ item.inboundDate ? formatDate(new Date(item.inboundDate), 'YYYY-mm-dd') : '-' }}</span></p>
+              <p class="info-row"><span>入库人</span><span class="val">{{ item.inboundPerson }}</span></p>
+              <div class="flex mt10 btns">
+                <van-button v-auth="'instr_strain_inbound_edit'" size="small" type="primary" @click.stop="onEditInbound(item)">修改</van-button>
+                <van-button v-auth="'instr_strain_inbound_del'" size="small" type="danger" @click.stop="onDelInbound(item)">删除</van-button>
+              </div>
+            </div>
+            <!-- 领用记录卡片 -->
+            <div v-else class="card-item">
+              <header class="flex justify-between">
+                <strong class="title">{{ item.strainName }}</strong>
+                <van-tag type="success" size="medium">{{ item.usageQuantity }}</van-tag>
+              </header>
+              <p class="info-row"><span>领用日期</span><span class="val">{{ item.usageDate ? formatDate(new Date(item.usageDate), 'YYYY-mm-dd') : '-' }}</span></p>
+              <p class="info-row"><span>领用人</span><span class="val">{{ item.usagePerson }}</span></p>
+              <p class="info-row" v-if="item.purpose"><span>用途</span><span class="val">{{ item.purpose }}</span></p>
+              <p class="info-row"><span>剩余量</span><span class="val">{{ item.remainingQuantity || '-' }}</span></p>
+              <div class="flex mt10 btns">
+                <van-button v-auth="'instr_strain_usage_del'" size="small" type="danger" @click.stop="onDelUsage(item)">删除</van-button>
+              </div>
+            </div>
+          </template>
+        </van-cell>
+      </van-list>
+    </div>
+    <van-floating-bubble v-if="hasAddPerm" icon="plus" @click="onAdd" axis="y" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import to from 'await-to-js'
+import { formatDate } from '/@/utils/formatTime'
+import { onMounted, reactive, ref, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { useStrainApi } from '/@/api/strain'
+import { useUserInfo } from '/@/stores/userInfo'
+import { showConfirmDialog, showNotify } from 'vant'
+
+const router = useRouter()
+const strainApi = useStrainApi()
+const userStore = useUserInfo()
+
+const activeTab = ref('inbound')
+const searchKey = ref('')
+
+const hasAddPerm = computed(() => {
+  const key = activeTab.value === 'inbound' ? 'instr_strain_inbound_add' : 'instr_strain_usage_add'
+  return userStore.userInfos.authBtnList?.includes(key)
+})
+
+const state = reactive({
+  loading: false,
+  finished: false,
+  list: [] as any[],
+  queryForm: {
+    strainName: '',
+    pageNum: 1,
+    pageSize: 10
+  }
+})
+
+const resetList = () => {
+  state.queryForm.pageNum = 1
+  state.list = []
+  state.finished = false
+}
+
+const onLoad = async () => {
+  const query = { ...state.queryForm }
+  const api = activeTab.value === 'inbound' ? strainApi.getInboundList : strainApi.getUsageList
+  const [err, res]: any = await to(api(query))
+  if (err) return
+  const list = res?.data?.list || res?.data || []
+  for (const item of list) {
+    state.list.push(item)
+  }
+  state.loading = false
+  state.queryForm.pageNum++
+  if (list.length < state.queryForm.pageSize) {
+    state.finished = true
+  }
+}
+
+const onSearch = () => {
+  state.queryForm.strainName = searchKey.value
+  resetList()
+  onLoad()
+}
+
+const onTabChange = () => {
+  resetList()
+  onLoad()
+}
+
+const onAdd = () => {
+  if (activeTab.value === 'inbound') {
+    router.push('/strain/inbound/add')
+  } else {
+    router.push('/strain/usage/add')
+  }
+}
+
+const onDetail = (item: any) => {
+  if (activeTab.value === 'inbound') {
+    router.push(`/strain/inbound/add?mode=detail&id=${item.id}`)
+  } else {
+    router.push(`/strain/usage/add?mode=detail&id=${item.id}`)
+  }
+}
+
+const onEditInbound = (item: any) => {
+  router.push(`/strain/inbound/add?mode=edit&id=${item.id}`)
+}
+
+const onDelInbound = async (item: any) => {
+  showConfirmDialog({
+    title: '提示',
+    message: `确认删除入库记录(${item.strainName})?`
+  }).then(async () => {
+    const [err]: any = await to(strainApi.deleteInbound({ ids: [item.id] }))
+    if (err) return
+    showNotify({ type: 'success', message: '删除成功' })
+    resetList()
+    onLoad()
+  }).catch(() => {})
+}
+
+const onDelUsage = async (item: any) => {
+  showConfirmDialog({
+    title: '提示',
+    message: `确认删除领用记录(${item.strainName})?`
+  }).then(async () => {
+    const [err]: any = await to(strainApi.deleteUsage({ ids: [item.id] }))
+    if (err) return
+    showNotify({ type: 'success', message: '删除成功' })
+    resetList()
+    onLoad()
+  }).catch(() => {})
+}
+
+onMounted(() => {
+  onLoad()
+})
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  background-color: #f7f8fa;
+}
+.search-wrap {
+  background: #fff;
+}
+.list-container {
+  overflow-y: auto;
+  padding: 10px;
+  flex: 1;
+  .van-list {
+    .van-cell {
+      background-color: #fff;
+      border-radius: 8px;
+      + .van-cell {
+        margin-top: 10px;
+      }
+    }
+  }
+}
+.card-item {
+  header {
+    .title {
+      flex: 1;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      font-size: 16px;
+    }
+  }
+  .info-row {
+    display: flex;
+    justify-content: space-between;
+    color: #333;
+    margin-top: 4px;
+    span:first-child {
+      color: #787878;
+      flex: 0 0 70px;
+    }
+    .val {
+      flex: 1;
+      text-align: right;
+    }
+  }
+  .btns {
+    justify-content: flex-end;
+    gap: 10px;
+  }
+}
+</style>

+ 177 - 0
src/view/strain/usage/add.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="app-container">
+    <van-form ref="formRef" @submit="onSubmit" required="auto">
+      <h4 class="mb8 mt8">领用信息</h4>
+      <van-cell-group>
+        <van-field
+          v-model="state.form.strainName"
+          label="菌毒种名称"
+          placeholder="请输入菌毒种名称"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入菌毒种名称' }]"
+        />
+        <van-field
+          v-model="state.form.usageDate"
+          label="领用日期"
+          placeholder="请选择领用日期"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请选择领用日期' }]"
+          @click="isDetail || (showDatePicker = true)"
+        />
+        <van-field
+          v-model="state.form.usagePerson"
+          label="领用人"
+          placeholder="当前用户"
+          readonly
+        />
+        <van-field
+          v-model="state.form.usageQuantity"
+          label="领用数量"
+          placeholder="如:6包"
+          maxlength="255"
+          :readonly="isDetail"
+          :rules="isDetail ? [] : [{ required: true, message: '请输入领用数量' }]"
+        />
+        <van-field
+          v-model="state.form.purpose"
+          label="用途"
+          placeholder="请输入用途"
+          type="textarea"
+          rows="2"
+          autosize
+          maxlength="2048"
+          :readonly="isDetail"
+        />
+        <van-field
+          v-model="state.form.remainingQuantity"
+          label="剩余量"
+          placeholder="如:46包"
+          maxlength="255"
+          :readonly="isDetail"
+        />
+      </van-cell-group>
+
+      <van-action-bar placeholder>
+        <van-action-bar-icon icon="wap-home-o" text="首页" @click="router.push('/home')" />
+        <van-action-bar-icon icon="revoke" text="返回" @click="router.push('/strain')" />
+        <van-action-bar-button v-if="!isDetail" type="primary" text="立即提交" :loading="submitting" native-type="submit" />
+      </van-action-bar>
+    </van-form>
+
+    <van-popup v-model:show="showDatePicker" position="bottom">
+      <van-date-picker v-model="currentDate" title="选择日期" @confirm="onConfirmDate" @cancel="showDatePicker = false" />
+    </van-popup>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import to from 'await-to-js'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useStrainApi } from '/@/api/strain'
+import { useUserInfo } from '/@/stores/userInfo'
+import { formatDate } from '/@/utils/formatTime'
+import { showNotify } from 'vant'
+
+const router = useRouter()
+const route = useRoute()
+const strainApi = useStrainApi()
+const userStore = useUserInfo()
+
+const formRef = ref()
+const showDatePicker = ref(false)
+const currentDate = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
+const submitting = ref(false)
+
+const pageMode = computed(() => (route.query.mode as string) || 'add')
+const isDetail = computed(() => pageMode.value === 'detail')
+
+const state = reactive({
+  form: {
+    id: 0,
+    strainName: '',
+    usageDate: formatDate(new Date(), 'YYYY-mm-dd'),
+    usagePersonId: 0,
+    usagePerson: '',
+    usageQuantity: '',
+    purpose: '',
+    remainingQuantity: '',
+  }
+})
+
+const onConfirmDate = () => {
+  showDatePicker.value = false
+  state.form.usageDate = `${currentDate.value[0]}-${String(currentDate.value[1]).padStart(2, '0')}-${String(currentDate.value[2]).padStart(2, '0')}`
+}
+
+const initForm = () => {
+  const u = userStore.userInfos
+  state.form.usagePersonId = u.id
+  state.form.usagePerson = u.nickName || ''
+  state.form.usageDate = formatDate(new Date(), 'YYYY-mm-dd')
+}
+
+const loadData = async (id: number) => {
+  const [err, res]: any = await to(strainApi.getUsageById({ id }))
+  if (err) return
+  const d = res?.data
+  if (d) {
+    state.form = {
+      id: d.id || 0,
+      strainName: d.strainName || '',
+      usageDate: d.usageDate || '',
+      usagePersonId: d.usagePersonId || 0,
+      usagePerson: d.usagePerson || '',
+      usageQuantity: d.usageQuantity || '',
+      purpose: d.purpose || '',
+      remainingQuantity: d.remainingQuantity || '',
+    }
+  }
+}
+
+const onSubmit = async () => {
+  const [errValid] = await to(formRef.value.validate())
+  if (errValid) return
+
+  submitting.value = true
+  const params = JSON.parse(JSON.stringify(state.form))
+  const [err]: any = await to(strainApi.createUsage(params))
+  submitting.value = false
+  if (err) return
+
+  showNotify({ type: 'success', message: '新增成功' })
+  setTimeout(() => {
+    router.push('/strain')
+  }, 1000)
+}
+
+onMounted(async () => {
+  const id = route.query.id
+  if (id) {
+    await loadData(Number(id))
+  } else {
+    initForm()
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  h4 {
+    margin: 10px 0;
+    height: 20px;
+    line-height: 20px;
+    padding-left: 16px;
+    &::before {
+      display: inline-block;
+      content: '';
+      background-color: #3c78e3;
+      width: 4px;
+      height: 20px;
+      margin-right: 4px;
+      vertical-align: top;
+    }
+  }
+}
+</style>