Просмотр исходного кода

feat(web-admin): add storage-cert QC view on cell-storage sample-info.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
500a85465f
1 измененных файлов с 576 добавлено и 0 удалено
  1. 576 0
      web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue

+ 576 - 0
web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue

@@ -0,0 +1,576 @@
+<template>
+  <div class="unified-sample-info-page">
+    <div v-if="visibleTabs.length > 1" class="panel-pill-tabs panel-pill-tabs--lg">
+      <div class="panel-pill-tabs__track" role="tablist" aria-label="来源">
+        <button
+          v-for="item in visibleTabs"
+          :key="item.key"
+          type="button"
+          role="tab"
+          class="panel-pill-tabs__btn"
+          :class="{ 'is-active': activeSource === item.key }"
+          :aria-selected="activeSource === item.key"
+          @click="switchSource(item.key)"
+        >
+          {{ item.title }}
+        </button>
+      </div>
+    </div>
+
+    <div class="filter-block">
+      <el-form
+        :model="searchForm"
+        class="filter-form sample-info-filter-form"
+        label-width="auto"
+        @submit.prevent
+      >
+        <div
+          class="filter-grid sample-info-filter-grid"
+          :class="{ 'is-cell-storage': isCellStorage }"
+        >
+          <el-form-item label="关键词">
+            <el-input v-model="searchForm.keyword" placeholder="姓名/样本编码/入库编码" clearable />
+          </el-form-item>
+          <el-form-item label="采集">
+            <el-select v-model="searchForm.collectionFlag" placeholder="全部" clearable style="width: 100%">
+              <el-option label="未采集" value="uncollected" />
+              <el-option label="已采集" value="collected" />
+              <el-option label="已作废" value="voided" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="接收">
+            <el-select v-model="searchForm.receptionFlag" placeholder="全部" clearable style="width: 100%">
+              <el-option label="未接收" value="none" />
+              <el-option label="已接收" value="js" />
+              <el-option label="拒收" value="bj" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="质检">
+            <el-select v-model="searchForm.qcFlag" placeholder="全部" clearable style="width: 100%">
+              <el-option label="未完成" value="incomplete" />
+              <el-option label="已完成" value="complete" />
+              <el-option label="已作废" value="voided" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="入库">
+            <el-select v-model="searchForm.storageFlag" placeholder="全部" clearable style="width: 100%">
+              <el-option label="未入库" value="none" />
+              <el-option label="已入库" value="yrk" />
+              <el-option label="拒绝入库" value="rejected" />
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="isCellStorage" label="储存证">
+            <el-select
+              v-model="searchForm.storageCertHandled"
+              placeholder="全部"
+              clearable
+              style="width: 100%"
+            >
+              <el-option label="已办理" value="1" />
+              <el-option label="未办理" value="0" />
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="isCellStorage" label="保险">
+            <el-select
+              v-model="searchForm.insuranceHandled"
+              placeholder="全部"
+              clearable
+              style="width: 100%"
+            >
+              <el-option label="已办理" value="1" />
+              <el-option label="未办理" value="0" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <div class="filter-actions-row">
+          <div class="filter-actions-end">
+            <el-button class="btn-ghost" @click="handleReset">
+              <el-icon><RefreshLeft /></el-icon>重置
+            </el-button>
+            <el-button class="btn-main" type="primary" @click="handleSearch">
+              <el-icon><Search /></el-icon>搜索
+            </el-button>
+          </div>
+        </div>
+      </el-form>
+    </div>
+
+    <div class="list-toolbar">
+      <div class="list-toolbar-row">
+        <div class="list-toolbar-selected">
+          已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
+        </div>
+        <el-button
+          type="primary"
+          class="btn-main"
+          :disabled="!selectedRows.length"
+          @click="openQuickRegister('storage')"
+        >
+          <el-icon><Box /></el-icon>
+          登记入库
+        </el-button>
+        <el-button
+          v-if="isCellStorage"
+          class="btn-ghost"
+          :disabled="!selectedRows.length"
+          @click="openQuickRegister('storage_cert')"
+        >
+          <el-icon><Ticket /></el-icon>
+          登记储存证
+        </el-button>
+        <el-button
+          v-if="isCellStorage"
+          class="btn-ghost"
+          :disabled="!selectedRows.length"
+          @click="openQuickRegister('insurance')"
+        >
+          <el-icon><Collection /></el-icon>
+          登记保险
+        </el-button>
+      </div>
+    </div>
+
+    <div class="list-body list-wrap">
+      <el-table
+        ref="tableRef"
+        class="list-table"
+        :data="tableData"
+        v-loading="loading"
+        stripe
+        row-key="rowKey"
+        @selection-change="handleSelectionChange"
+        @row-dblclick="handleView"
+      >
+        <el-table-column type="selection" min-width="55" align="center" header-align="center" />
+        <el-table-column
+          type="index"
+          label="序号"
+          min-width="68"
+          align="center"
+          header-align="center"
+          :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
+        />
+        <el-table-column label="协议状态" min-width="100" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="expectedDeliveryDate" label="预产期" min-width="120" align="center" header-align="center" />
+        <el-table-column prop="motherName" label="母亲姓名" min-width="100" align="center" header-align="center" />
+        <el-table-column
+          prop="newbornName"
+          label="新生儿姓名"
+          min-width="110"
+          align="center"
+          header-align="center"
+          show-overflow-tooltip
+        />
+        <el-table-column prop="sampleCode" label="样本编码" min-width="160" align="center" header-align="center">
+          <template #default="{ row }">
+            <SampleCodeWithSpecialDisplay
+              :sample-code="row.sampleCode"
+              :is-special="!!row.isSpecial"
+              :special-remark="row.specialRemark"
+              monospace
+            />
+          </template>
+        </el-table-column>
+        <el-table-column prop="labStorageCode" label="实验室入库编码" min-width="150" align="center" header-align="center">
+          <template #default="{ row }">{{ row.labStorageCode || '—' }}</template>
+        </el-table-column>
+        <el-table-column prop="sampleTypeName" label="样本类型" min-width="110" align="center" header-align="center" />
+        <el-table-column label="采集" min-width="90" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-tag :type="sampleCollectionFlagTagType(String(row.collectionFlag || ''))" size="small">
+              {{ row.collectionFlagLabel || sampleCollectionFlagLabel(String(row.collectionFlag || '')) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="接收" min-width="90" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-tag :type="sampleReceptionFlagTagType(String(row.receptionFlag || ''))" size="small">
+              {{ row.receptionFlagLabel || sampleReceptionFlagLabel(String(row.receptionFlag || '')) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="质检" min-width="90" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-tag :type="sampleQcFlagTagType(String(row.qcFlag || ''))" size="small">
+              {{ row.qcFlagLabel || sampleQcFlagLabel(String(row.qcFlag || '')) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="入库" min-width="100" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-tag :type="sampleStorageFlagTagType(String(row.storageFlag || ''))" size="small">
+              {{ row.storageFlagLabel || sampleStorageFlagLabel(String(row.storageFlag || '')) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          v-if="isCellStorage"
+          label="储存证"
+          min-width="90"
+          align="center"
+          header-align="center"
+        >
+          <template #default="{ row }">
+            <el-tag :type="handleDoneFlagTagType(row.storageCertHandled ? '1' : '0')" size="small">
+              {{ handleDoneFlagLabel(row.storageCertHandled ? '1' : '0') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column
+          v-if="isCellStorage"
+          label="保险"
+          min-width="90"
+          align="center"
+          header-align="center"
+        >
+          <template #default="{ row }">
+            <el-tag :type="handleDoneFlagTagType(row.insuranceHandled ? '1' : '0')" size="small">
+              {{ handleDoneFlagLabel(row.insuranceHandled ? '1' : '0') }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
+          <template #default="{ row }">
+            <el-button class="btn-op-item primary" @click="handleView(row)">
+              <el-icon><View /></el-icon>查看
+            </el-button>
+            <el-button
+              v-if="isCellStorage && supportsStorageCertQc(row.sampleTypeName)"
+              class="btn-op-item"
+              @click="openStorageCertQcView(row)"
+            >
+              <el-icon><Document /></el-icon>查看储存证的质检信息
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="pagination list-pager-bar">
+        <el-pagination
+          :current-page="pagination.page"
+          :page-size="pagination.pageSize"
+          :total="pagination.total"
+          :page-sizes="[10, 20, 50]"
+          layout="total, sizes, prev, pager, next"
+          @size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
+          @current-change="(val) => ((pagination.page = val), loadData())"
+        />
+      </div>
+    </div>
+
+    <ProtocolViewDialog
+      v-if="isCellStorage"
+      v-model="protocolViewVisible"
+      :protocol-id="viewProtocolId"
+      :list-row="viewProtocolRow"
+      :highlight-sample-code="highlightSampleCode"
+      show-audit-history
+      show-handle-panels
+    />
+    <DonationViewDialog
+      v-else
+      v-model="donationViewVisible"
+      :donation-id="viewDonationId"
+      :list-row="viewDonationRow"
+      :highlight-sample-code="highlightSampleCode"
+      show-audit-history
+    />
+    <SampleInfoQuickRegisterDialog
+      v-model="quickRegisterVisible"
+      :kind="quickRegisterKind"
+      :reception-source="activeSource"
+      :rows="quickRegisterRows"
+      @saved="onQuickRegisterSaved"
+    />
+    <StorageCertQcDialog
+      v-model:visible="storageCertQcVisible"
+      mode="view"
+      :sample-code="storageCertQcSampleCode"
+      :sample-type-name="storageCertQcSampleTypeName"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { Box, Collection, Document, RefreshLeft, Search, Ticket, View } from '@element-plus/icons-vue'
+  import { ElMessage } from 'element-plus'
+  import StorageCertQcDialog from '@/components/business/storage-cert-qc-dialog/index.vue'
+  import { supportsStorageCertQc } from '@/utils/storage-cert-qc-fields'
+  import { fetchProtocolSampleCollectionList } from '@/api/protocol-sample-collection'
+  import { fetchResearchDonationSampleCollectionList } from '@/api/research-donation-sample-collection'
+  import type { CollectionProtocolRow } from '@/api/collection-protocol'
+  import type { ResearchDonationRow } from '@/api/research-donation'
+  import ProtocolViewDialog from '@/views/cell-storage/protocol/ProtocolViewDialog.vue'
+  import DonationViewDialog from '@/views/research-donation/list/DonationViewDialog.vue'
+  import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
+  import SampleInfoQuickRegisterDialog, {
+    type QuickRegisterKind
+  } from './SampleInfoQuickRegisterDialog.vue'
+  import { useMenuPerm } from '@/hooks/core/useMenuPerm'
+  import {
+    donationStatusDisplayLabel,
+    donationStatusTagType,
+    handleDoneFlagLabel,
+    handleDoneFlagTagType,
+    protocolStatusDisplayLabel,
+    protocolStatusTagType,
+    sampleCollectionFlagLabel,
+    sampleCollectionFlagTagType,
+    sampleQcFlagLabel,
+    sampleQcFlagTagType,
+    sampleReceptionFlagLabel,
+    sampleReceptionFlagTagType,
+    sampleStorageFlagLabel,
+    sampleStorageFlagTagType
+  } from '@/utils/status-tag-registry'
+
+  defineOptions({ name: 'UnifiedSampleInfoPage' })
+
+  const props = defineProps<{
+    defaultSource: 'cell_storage' | 'research_donation'
+  }>()
+
+  const { hasMenuPerm } = useMenuPerm()
+  const route = useRoute()
+  const router = useRouter()
+
+  type SourceKey = 'cell_storage' | 'research_donation'
+
+  const allTabs: Array<{ key: SourceKey; title: string; perm: string }> = [
+    { key: 'cell_storage', title: '细胞存储', perm: 'cell-storage:sample-info:list' },
+    {
+      key: 'research_donation',
+      title: '科研捐赠',
+      perm: 'research-donation:sample-info:list'
+    }
+  ]
+
+  const visibleTabs = computed(() => allTabs.filter((t) => hasMenuPerm(t.perm)))
+  const activeSource = ref<SourceKey>(props.defaultSource)
+  const isCellStorage = computed(() => activeSource.value === 'cell_storage')
+
+  const searchForm = reactive({
+    keyword: '',
+    collectionFlag: '',
+    receptionFlag: '',
+    qcFlag: '',
+    storageFlag: '',
+    storageCertHandled: '',
+    insuranceHandled: ''
+  })
+  const tableData = ref<Record<string, unknown>[]>([])
+  const loading = ref(false)
+  const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
+  const selectedRows = ref<Record<string, unknown>[]>([])
+  const tableRef = ref<{ clearSelection?: () => void } | null>(null)
+  const quickRegisterVisible = ref(false)
+  const quickRegisterKind = ref<QuickRegisterKind>('storage')
+  const quickRegisterRows = ref<Record<string, unknown>[]>([])
+
+  const protocolViewVisible = ref(false)
+  const donationViewVisible = ref(false)
+  const viewProtocolId = ref<string | null>(null)
+  const viewDonationId = ref<string | null>(null)
+  const viewProtocolRow = ref<CollectionProtocolRow | null>(null)
+  const viewDonationRow = ref<ResearchDonationRow | null>(null)
+  const highlightSampleCode = ref('')
+  const storageCertQcVisible = ref(false)
+  const storageCertQcSampleCode = ref('')
+  const storageCertQcSampleTypeName = ref('')
+
+  function openStorageCertQcView(row: Record<string, unknown>) {
+    storageCertQcSampleCode.value = String(row.sampleCode || '')
+    storageCertQcSampleTypeName.value = String(row.sampleTypeName || '')
+    if (!storageCertQcSampleCode.value) {
+      ElMessage.warning('缺少样本编码,无法查看')
+      return
+    }
+    storageCertQcVisible.value = true
+  }
+
+  function statusLabel(row: Record<string, unknown>) {
+    if (isCellStorage.value) {
+      return protocolStatusDisplayLabel(String(row.protocolStatus || ''), String(row.voidType || ''))
+    }
+    return donationStatusDisplayLabel(String(row.donationStatus || ''), String(row.voidType || ''))
+  }
+
+  function statusTagType(row: Record<string, unknown>) {
+    if (isCellStorage.value) {
+      return protocolStatusTagType(String(row.protocolStatus || ''))
+    }
+    return donationStatusTagType(String(row.donationStatus || ''))
+  }
+
+  function switchSource(key: SourceKey) {
+    activeSource.value = key
+    protocolViewVisible.value = false
+    donationViewVisible.value = false
+    selectedRows.value = []
+    router.replace({ query: { ...route.query, tab: key } })
+    pagination.page = 1
+    loadData()
+  }
+
+  async function loadData() {
+    loading.value = true
+    try {
+      const params: Record<string, unknown> = {
+        page: pagination.page,
+        pageSize: pagination.pageSize,
+        keyword: searchForm.keyword || undefined,
+        collectionFlag: searchForm.collectionFlag || undefined,
+        receptionFlag: searchForm.receptionFlag || undefined,
+        qcFlag: searchForm.qcFlag || undefined,
+        storageFlag: searchForm.storageFlag || undefined
+      }
+      if (isCellStorage.value) {
+        params.storageCertHandled = searchForm.storageCertHandled || undefined
+        params.insuranceHandled = searchForm.insuranceHandled || undefined
+        const res = (await fetchProtocolSampleCollectionList(params as any)) as Record<string, unknown>
+        const list = ((res.list as Record<string, unknown>[]) || []).map((row) => ({
+          ...row,
+          rowKey: String(row.protocolSampleId || row.sampleCode || Math.random())
+        }))
+        tableData.value = list
+        pagination.total = Number(res.total) || 0
+      } else {
+        const res = (await fetchResearchDonationSampleCollectionList(
+          params as any
+        )) as Record<string, unknown>
+        const list = ((res.list as Record<string, unknown>[]) || []).map((row) => ({
+          ...row,
+          rowKey: String(row.donationSampleId || row.sampleCode || Math.random())
+        }))
+        tableData.value = list
+        pagination.total = Number(res.total) || 0
+      }
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleSearch() {
+    pagination.page = 1
+    loadData()
+  }
+
+  function handleReset() {
+    Object.assign(searchForm, {
+      keyword: '',
+      collectionFlag: '',
+      receptionFlag: '',
+      qcFlag: '',
+      storageFlag: '',
+      storageCertHandled: '',
+      insuranceHandled: ''
+    })
+    handleSearch()
+  }
+
+  function handleSelectionChange(rows: Record<string, unknown>[]) {
+    selectedRows.value = rows
+  }
+
+  function openQuickRegister(kind: QuickRegisterKind) {
+    if (!selectedRows.value.length) {
+      ElMessage.warning('请先选择样本')
+      return
+    }
+    if ((kind === 'storage_cert' || kind === 'insurance') && !isCellStorage.value) {
+      ElMessage.warning('储存证与保险仅支持细胞存储样本')
+      return
+    }
+    quickRegisterKind.value = kind
+    quickRegisterRows.value = [...selectedRows.value]
+    quickRegisterVisible.value = true
+  }
+
+  function onQuickRegisterSaved() {
+    tableRef.value?.clearSelection?.()
+    selectedRows.value = []
+    loadData()
+  }
+
+  function handleView(row: Record<string, unknown>) {
+    highlightSampleCode.value = String(row.sampleCode || '')
+    if (isCellStorage.value) {
+      const pid = String(row.collectionProtocolId || '')
+      if (!pid) {
+        ElMessage.warning('缺少关联协议单,无法查看')
+        return
+      }
+      viewProtocolId.value = pid
+      viewProtocolRow.value = {
+        id: pid,
+        motherName: row.motherName,
+        newbornName: row.newbornName
+      } as CollectionProtocolRow
+      protocolViewVisible.value = true
+      return
+    }
+    const did = String(row.researchDonationId || '')
+    if (!did) {
+      ElMessage.warning('缺少关联捐赠协议单,无法查看')
+      return
+    }
+    viewDonationId.value = did
+    viewDonationRow.value = {
+      id: did,
+      motherName: row.motherName,
+      newbornName: row.newbornName
+    } as ResearchDonationRow
+    donationViewVisible.value = true
+  }
+
+  watchDebounced(
+    searchForm,
+    () => {
+      pagination.page = 1
+      loadData()
+    },
+    { debounce: 300, deep: true }
+  )
+
+  onMounted(() => {
+    const tab = String(route.query.tab || '')
+    const allowed = visibleTabs.value.map((t) => t.key)
+    if (allowed.includes(tab as SourceKey)) {
+      activeSource.value = tab as SourceKey
+    } else if (allowed.includes(props.defaultSource)) {
+      activeSource.value = props.defaultSource
+    } else if (allowed.length) {
+      activeSource.value = allowed[0]
+    }
+    loadData()
+  })
+</script>
+
+<style scoped lang="scss">
+  .sample-info-filter-form {
+    min-width: 0;
+  }
+
+  .unified-sample-info-page .filter-grid.sample-info-filter-grid {
+    margin-bottom: 0;
+    align-items: center;
+    grid-template-columns: minmax(0, 1.5fr) repeat(4, minmax(0, 1fr));
+  }
+
+  .unified-sample-info-page .filter-grid.sample-info-filter-grid.is-cell-storage {
+    grid-template-columns: minmax(0, 1.5fr) repeat(6, minmax(0, 1fr));
+  }
+
+  .sample-info-filter-form :deep(.el-form-item) {
+    margin-bottom: 0;
+    min-width: 0;
+  }
+
+  .sample-info-filter-form :deep(.el-select),
+  .sample-info-filter-form :deep(.el-input) {
+    min-width: 0;
+  }
+</style>