feat(web-admin): mount unified sample-info page for storage-cert QC entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-09 12:40:41 +08:00
1 parent 5d0e3d904a
commit df5206454f
2 files changed
+342 -92

No files matched your search

@@ -0,0 +1,340 @@
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="640px"
align-center
append-to-body
class="dialog-form-layout"
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<el-form label-position="top" class="biz-dialog-form" @submit.prevent>
<p class="quick-register-hint">{{ selectionHint }}</p>
<div class="form-grid-2">
<el-form-item label="办理时间" required>
<el-date-picker
v-model="form.handleTime"
type="datetime"
placeholder="请选择办理时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="办理人员" required>
<el-select
v-model="form.handlerUserId"
filterable
remote
clearable
placeholder="输入姓名检索"
:remote-method="searchUsers"
:loading="userLoading"
style="width: 100%"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="u.nickName || u.userName"
:value="u.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="attachmentLabel" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="onFileChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div v-for="(file, index) in files" :key="file.id || index" class="attachment-item">
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<el-icon class="action-btn--danger" @click="files.splice(index, 1)">
<Delete />
</el-icon>
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Office 等,单文件不超过 20MB</span>
</div>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import { fetchSampleInfoQuickRegister } from '@/api/sample-info-handle'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'SampleInfoQuickRegisterDialog' })
export type QuickRegisterKind = 'storage' | 'storage_cert' | 'insurance'
const props = defineProps<{
modelValue: boolean
kind: QuickRegisterKind
receptionSource: 'cell_storage' | 'research_donation'
rows: Record<string, unknown>[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'saved'): void
}>()
const userStore = useUserStore()
const saving = ref(false)
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName?: string; userName?: string }>>([])
const files = ref<Array<{ id: string; fileName: string; fileUrl: string }>>([])
const form = reactive({
handleTime: '',
handlerUserId: '',
remark: ''
})
const titleMap: Record<QuickRegisterKind, string> = {
storage: '快捷登记入库',
storage_cert: '快捷登记储存证',
insurance: '快捷登记保险'
}
const dialogTitle = computed(() => titleMap[props.kind])
const attachmentLabel = computed(() => {
if (props.kind === 'storage_cert') return '储存证附件'
if (props.kind === 'insurance') return '保险附件'
return '入库附件'
})
const protocolIdOf = (row: Record<string, unknown>) => {
if (props.receptionSource === 'cell_storage') {
return String(row.collectionProtocolId || '')
}
return String(row.researchDonationId || '')
}
const uniqueProtocolCount = computed(() => {
const ids = new Set(props.rows.map(protocolIdOf).filter(Boolean))
return ids.size
})
const selectionHint = computed(() => {
const n = props.rows.length
if (props.kind === 'storage') {
return `将为已选 ${n} 条样本登记入库(同一表单内容写入每一条)。`
}
return `将为已选 ${n} 条样本、共 ${uniqueProtocolCount.value} 份协议登记(证/保险按协议去重,同一表单写入)。`
})
function pad(n: number) {
return String(n).padStart(2, '0')
}
function nowStr() {
const d = new Date()
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function resetForm() {
form.handleTime = nowStr()
form.handlerUserId = String(userStore.info?.userId || '')
form.remark = ''
files.value = []
const name = String(userStore.info?.userName || '')
if (form.handlerUserId) {
userOptions.value = [{ id: form.handlerUserId, nickName: name, userName: name }]
} else {
userOptions.value = []
}
}
watch(
() => props.modelValue,
(open) => {
if (open) resetForm()
}
)
async function searchUsers(kw: string) {
userLoading.value = true
try {
const res = await fetchUserList({ page: 1, pageSize: 50, keyword: kw || '', status: '1' })
userOptions.value = ((res as any)?.list || []).map((u: any) => ({
id: String(u.id),
nickName: u.nickName,
userName: u.userName
}))
} finally {
userLoading.value = false
}
}
async function onFileChange(uploadFile: any) {
const file = uploadFile.raw || uploadFile
if (!file) return
const folder =
props.kind === 'storage_cert'
? 'storage-cert'
: props.kind === 'insurance'
? 'fullmoon-todo'
: 'sample-storage'
const fd = new FormData()
fd.append('folder', folder)
fd.append('file', file)
const res = (await fetchUploadAttachment(fd)) as { id?: string; fileName?: string; fileUrl?: string }
files.value.push({
id: String(res.id || Date.now()),
fileName: file.name || res.fileName || '附件',
fileUrl: res.fileUrl || ''
})
}
async function handleSubmit() {
if (!form.handleTime) {
ElMessage.warning('请填写办理时间')
return
}
if (!form.handlerUserId) {
ElMessage.warning('请指定办理人员')
return
}
const items = props.rows
.map((row) => ({
sampleCode: String(row.sampleCode || '').trim(),
protocolId: protocolIdOf(row)
}))
.filter((it) => it.sampleCode && it.protocolId)
if (!items.length) {
ElMessage.warning('所选样本缺少编码或协议,无法登记')
return
}
saving.value = true
try {
const urls = files.value.map((f) => f.fileUrl).filter(Boolean).join(',')
const res = (await fetchSampleInfoQuickRegister({
kind: props.kind,
receptionSource: props.receptionSource,
items,
handleTime: form.handleTime,
handlerUserId: form.handlerUserId,
attachments: urls,
remark: form.remark
})) as { success?: number; fail?: number }
const ok = Number(res.success) || 0
const fail = Number(res.fail) || 0
if (fail && ok) {
ElMessage.warning(`已登记 ${ok} 项,失败 ${fail} 项`)
} else if (fail) {
ElMessage.error('登记失败,请确认所选样本权限后重试')
} else {
ElMessage.success('登记成功')
}
emit('update:modelValue', false)
emit('saved')
} finally {
saving.value = false
}
}
</script>
<style scoped lang="scss">
.quick-register-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.action-btn--danger {
cursor: pointer;
color: var(--el-color-danger);
}
.attachment-upload {
width: 100%;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.upload-card-text {
font-size: 12px;
color: #047857;
font-weight: 500;
}
.upload-hint {
display: block;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
margin-top: 8px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
width: 140px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 8px;
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,103 +1,13 @@
<!-- 样本信息管理(样本采集 / 样本接收 / 样本质检 / 样本入库) -->
<template>
<div class="page-container">
<section class="panel">
<nav class="pill-tab-nav" role="tablist" aria-label="视图类型">
<div class="pill-tab-nav__rail">
<div class="pill-tab-nav__progress" :style="pillProgressStyle" />
<button
v-for="(item, index) in entityTabs"
:key="item.key"
type="button"
class="pill-tab-nav__step"
:class="{
'is-active': activeTab === item.key,
'is-done': index < activeTabIndex
}"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="pill-tab-nav__index">{{ index + 1 }}</span>
<span class="pill-tab-nav__text">
<span class="pill-tab-nav__title">{{ item.title }}</span>
<span class="pill-tab-nav__desc">{{ item.desc }}</span>
</span>
</button>
</div>
</nav>
<SampleCollectionInfoTab v-if="activeTab === 'sample-collection'" />
<SampleReceptionInfoTab v-else-if="activeTab === 'sample-reception'" />
<SampleQcInfoTab v-else-if="activeTab === 'sample-qc'" />
<StorageEntryInfoTab v-else-if="activeTab === 'storage-entry'" />
<UnifiedSampleInfoPage default-source="cell_storage" />
</section>
</div>
</template>
<script setup lang="ts">
import SampleCollectionInfoTab from '../protocol-audit/SampleCollectionInfoTab.vue'
import SampleReceptionInfoTab from '../protocol-audit/SampleReceptionInfoTab.vue'
import SampleQcInfoTab from '../protocol-audit/SampleQcInfoTab.vue'
import StorageEntryInfoTab from '../protocol-audit/StorageEntryInfoTab.vue'
import UnifiedSampleInfoPage from './UnifiedSampleInfoPage.vue'
defineOptions({ name: 'CellStorageSampleInfo' })
const VALID_TABS = [
'sample-collection',
'sample-reception',
'sample-qc',
'storage-entry'
] as const
type EntityTabKey = (typeof VALID_TABS)[number]
const entityTabs: Array<{
key: EntityTabKey
title: string
desc: string
}> = [
{
key: 'sample-collection',
title: '样本采集信息',
desc: '协议样本采集进度'
},
{
key: 'sample-reception',
title: '样本接收信息',
desc: '接收结论与短信通知'
},
{
key: 'sample-qc',
title: '样本质检信息',
desc: '质检结论与短信通知'
},
{
key: 'storage-entry',
title: '样本入库信息',
desc: '样本入库记录查询'
}
]
const route = useRoute()
const activeTab = ref<EntityTabKey>('sample-collection')
const activeTabIndex = computed(() =>
Math.max(
0,
entityTabs.findIndex((item) => item.key === activeTab.value)
)
)
const pillProgressStyle = computed(() => ({
width: `calc(${100 / entityTabs.length}% - 3px)`,
transform: `translateX(${activeTabIndex.value * 100}%)`
}))
onMounted(() => {
const tab = String(route.query.tab || '')
if (VALID_TABS.includes(tab as EntityTabKey)) {
activeTab.value = tab as EntityTabKey
}
})
</script>