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:
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>
|
||||
Reference in new issue
Block a user