This commit is contained in:
zlwu committed 2026-05-19 23:12:56 +08:00
1 parent 93cd506a90
commit 3b875ec370
93 files changed
+11514 -2304

No files matched your search

@@ -1,7 +1,7 @@
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
<template>
<div class="protocol-finance-confirm-fields">
<el-form-item :label="remarkLabel" class="form-col-span-2">
<el-form-item v-if="showRemark" :label="remarkLabel" class="form-col-span-2">
<el-input
:model-value="financeRemark"
type="textarea"
@@ -97,11 +97,13 @@
uploadFolder?: string
remarkLabel?: string
attachmentLabel?: string
showRemark?: boolean
}>(),
{
uploadFolder: 'protocol-finance-confirm',
remarkLabel: '财务备注',
attachmentLabel: '财务确认附件'
attachmentLabel: '财务确认附件',
showRemark: true
}
)
@@ -0,0 +1,303 @@
<!-- 质检任务 — 只读查看(参数、结论、附件) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="innerVisible"
title="质检任务详情"
width="960px"
:grid-columns="3"
:icon="Tickets"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
append-to-body
compact
modal-class="qc-task-view-dialog-mask"
>
<template #reception-extra>
<div class="view-reception-attachments">
<div class="view-kv view-kv-span-2">
<dt>接收附件</dt>
<dd>
<ProtocolAttachmentReadonly
:files="viewReceptionAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
</div>
</template>
<template #append>
<div
v-if="viewDetail?.attachmentFiles?.length"
class="view-section qc-task-view-attachments"
>
<h3>{{ viewDetail?.taskType === 'dsfjc' ? '检测材料' : '检测附件' }}</h3>
<ProtocolAttachmentReadonly
:files="viewDetail.attachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Tickets } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { fetchQcTaskDetail, type QcTaskDetail } from '@/api/qc-task'
import { fetchQcTaskResults } from '@/api/qc-task-result'
import {
taskTypeLabel,
taskStatusLabel,
qcBizTypeLabel,
receptionSourceLabel,
acceptConclusionLabel,
transportTypeLabel,
workOrderStatusLabel,
qcTaskConclusionLabel
} from '@/utils/qc-labels'
import { downloadAttachmentFile } from '@/utils/download-attachment'
const props = defineProps<{
visible: boolean
taskId?: string
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const loading = ref(false)
const viewDetail = ref<QcTaskDetail | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
watch(
() => [props.visible, props.taskId] as const,
async ([vis, id]) => {
if (!vis || !id) {
viewDetail.value = null
return
}
loading.value = true
try {
const detail = await fetchQcTaskDetail(id)
try {
const results = await fetchQcTaskResults(id)
;(detail as QcTaskDetail).results = results || []
} catch {
;(detail as QcTaskDetail).results = []
}
viewDetail.value = detail
} catch {
viewDetail.value = null
} finally {
loading.value = false
}
},
{ immediate: true }
)
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
return String(v)
}
function formatCheckResults(items?: Array<{ itemName?: string; checked?: boolean }>) {
if (!items?.length) return '—'
return items.map((x) => `${x.itemName || ''}${x.checked ? '(✓)' : '(×)'}`).join('、')
}
const viewReceptionAttachmentFiles = computed(() => {
const r = viewDetail.value?.reception
if (!r) return []
if (Array.isArray(r.receiveAttachmentFiles) && r.receiveAttachmentFiles.length) {
return r.receiveAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(r.receiveAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({ id: `rec_${i}`, fileUrl, fileName: fileUrl.split('/').pop() || '' }))
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
const r = d.reception || {}
const transportFields: ViewSection['fields'] = [
{ label: '运输方式', value: transportTypeLabel(String(r.transportType || '')) }
]
if (r.transportType === 'kdys') {
transportFields.push(
{ label: '快递公司', value: r.expressCompanyName || '—' },
{ label: '快递单号', value: r.waybillNo || '—' }
)
} else if (r.transportType === 'ryxd') {
transportFields.push(
{ label: '携带人', value: r.carrierName || '—' },
{ label: '携带人电话', value: r.carrierPhone || '—' }
)
}
if (r.etaTime) {
transportFields.push({ label: '预计到达', value: r.etaTime })
}
const resultSection =
d.results && d.results.length > 0
? [
{
title: '检测参数',
fields: d.results.map((item) => ({
label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
value: item.paramValue || '—'
}))
}
]
: []
return [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo },
{ label: '业务类型', value: d.qcBizTypeLabel || qcBizTypeLabel(d.qcBizType || ''), tagType: 'success' },
{ label: '工单号', value: d.workOrderNo || '—' },
{
label: '工单状态',
value: d.workOrderStatus ? workOrderStatusLabel(d.workOrderStatus) : '—',
tagType: d.workOrderStatus === 'ywc' ? 'success' : d.workOrderStatus === 'ypz' ? 'warning' : 'info'
},
{ label: '检测类型', value: taskTypeLabel(d.taskType), tagType: d.taskType === 'sysz' ? 'success' : 'info' },
{ label: '检测项目', value: d.itemName },
{ label: '项目编码', value: d.itemCode || '—' },
{ label: '第三方公司', value: d.companyName || '—' },
{
label: '任务状态',
value: taskStatusLabel(d.taskStatus),
tagType: d.taskStatus === 'ywc' ? 'success' : d.taskStatus === 'jxz' ? 'warning' : 'info'
},
{
label: '检测结论',
value: d.conclusionLabel || qcTaskConclusionLabel(d.conclusion || '') || '—',
tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info'
},
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '创建时间', value: d.createTime || '—' },
{ label: '备注', value: d.remark || '—', span: true }
]
},
{
title: '样本信息',
fields: [
{ label: '协议号', value: r.protocolNo || d.protocolNo || '—' },
{ label: '样本编码', value: r.sampleCode || d.sampleCode || '—' },
{
label: '样本来源',
value: r.sourceTypeLabel || d.receptionSourceLabel || receptionSourceLabel(d.receptionSource || ''),
tagType: 'primary'
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
]
},
...(transportFields.length > 1 || r.transportType
? [{ title: '运输信息', fields: transportFields }]
: []),
{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
{
label: '接收温度(℃)',
value: r.receiveTemperature ? `${r.receiveTemperature}℃` : '—'
},
{ label: '体积(ml)', value: formatMetric(r.volumeMl) },
{ label: '净重(g)', value: formatMetric(r.netWeightG) },
{
label: '结论',
value:
r.acceptConclusionLabel ||
acceptConclusionLabel(String(r.acceptConclusion || ''))
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
},
...resultSection
]
})
function attachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
</script>
<style lang="scss">
/* 叠在协议/捐赠查看弹窗之上 */
.qc-task-view-dialog-mask {
z-index: 4000 !important;
}
</style>
<style scoped lang="scss">
.view-reception-attachments {
margin-top: 4px;
.view-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
font-size: 13px;
align-items: start;
dt {
margin: 0;
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #1e293b;
}
}
}
.qc-task-view-attachments {
padding: 12px 18px;
border-bottom: none;
h3 {
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
}
</style>
@@ -0,0 +1,299 @@
<!-- 样本查看 — 质检信息卡片(协议单/捐赠单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--qc">
<div class="sample-card__section-label">质检信息</div>
<div v-if="!qcInfo" class="sample-qc-empty">{{ emptyHint }}</div>
<template v-else>
<div class="qc-work-order-row">
<div class="qc-kv">
<span class="qc-kv__label">工单号</span>
<span class="qc-kv__value qc-kv__value--code">{{ qcInfo.workOrderNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">业务类型</span>
<span class="qc-kv__value">{{
qcInfo.qcBizTypeLabel || qcBizTypeLabel(qcInfo.qcBizType || '')
}}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">工单状态</span>
<span class="qc-kv__value">
<el-tag :type="workOrderStatusTagType(qcInfo.workOrderStatus || '')" size="small">
{{ qcInfo.workOrderStatusLabel || workOrderStatusLabel(qcInfo.workOrderStatus || '') }}
</el-tag>
</span>
</div>
</div>
<div v-if="qcInfo.workOrderStatus === 'dpz' && !qcInfo.tasks?.length" class="sample-qc-hint">
工单待配置检测项目
</div>
<div v-if="qcInfo.tasks?.length" class="qc-detail-cards qc-detail-cards--tasks">
<div
v-for="task in qcInfo.tasks"
:key="task.id"
class="qc-detail-card qc-detail-card--task"
>
<div class="qc-detail-card__header">
<span class="qc-detail-card__name">{{ task.itemName || '—' }}</span>
<el-tag type="info" size="small">{{
task.taskTypeLabel || taskTypeLabel(task.taskType || '')
}}</el-tag>
</div>
<div class="qc-detail-card__body">
<div class="qc-kv">
<span class="qc-kv__label">任务号</span>
<span class="qc-kv__value">{{ task.taskNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">任务状态</span>
<span class="qc-kv__value">
<el-tag :type="taskStatusTagType(task.taskStatus)" size="small">
{{ task.taskStatusLabel || taskStatusLabel(task.taskStatus || '') }}
</el-tag>
</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">检测结论</span>
<span class="qc-kv__value">
<el-tag
v-if="task.conclusion"
:type="qcTaskConclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || qcTaskConclusionLabel(task.conclusion) }}
</el-tag>
<span v-else class="qc-kv__muted">—</span>
</span>
</div>
</div>
<div class="qc-detail-card__foot">
<button type="button" class="qc-detail-card__link" @click.stop="openTaskView(task)">
点击查看详情
</button>
</div>
</div>
</div>
<div
v-else-if="qcInfo.workOrderStatus !== 'dpz'"
class="sample-qc-hint"
>
暂无检测任务
</div>
</template>
<Teleport to="body">
<QcTaskViewDialog v-model:visible="taskViewVisible" :task-id="taskViewId" />
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
qcBizTypeLabel,
workOrderStatusLabel,
taskTypeLabel,
taskStatusLabel,
qcTaskConclusionLabel,
qcTaskConclusionTagType
} from '@/utils/qc-labels'
import { workOrderStatusTagType, taskStatusTagType } from '@/utils/status-tag-registry'
import QcTaskViewDialog from '@/components/business/qc-task-view-dialog/index.vue'
export type SampleQcTask = {
id?: string
taskNo?: string
taskType?: string
taskTypeLabel?: string
itemName?: string
itemCode?: string
companyName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
completedTime?: string
remark?: string
}
export type SampleQcInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
qcBizType?: string
qcBizTypeLabel?: string
configuredTime?: string
hasLab?: boolean
hasThirdParty?: boolean
labItems?: Array<{ id?: string; itemCode?: string; itemName?: string }>
thirdPartyItems?: Array<{
id?: string
itemCode?: string
itemName?: string
companyName?: string
}>
tasks?: SampleQcTask[]
}
export type SampleReceptionBrief = {
acceptConclusion?: string
}
const props = defineProps<{
qcInfo?: SampleQcInfo | null
sampleReception?: SampleReceptionBrief | null
}>()
const taskViewVisible = ref(false)
const taskViewId = ref('')
const emptyHint = computed(() => {
const rec = props.sampleReception
if (!rec) {
return '暂未接收样本,接收结论为「接收」后将自动生成质检工单'
}
if (rec.acceptConclusion === 'bj') {
return '样本拒收,不生成质检工单'
}
if (rec.acceptConclusion === 'js') {
return '已接收,质检工单生成中或待配置'
}
return '暂无质检信息'
})
function openTaskView(task: SampleQcTask) {
if (!task.id) return
taskViewId.value = task.id
taskViewVisible.value = true
}
</script>
<style scoped lang="scss">
.sample-card__section--qc {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-qc-empty,
.sample-qc-hint {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.qc-work-order-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
margin-bottom: 12px;
padding: 0 2px;
}
.qc-work-order-row .qc-kv {
flex: 1;
min-width: 140px;
}
.qc-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
&--tasks {
margin-bottom: 0;
}
}
.qc-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
&--task {
border-left: 3px solid #0ea5e9;
}
}
.qc-detail-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
}
.qc-detail-card__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: left;
flex: 1;
word-break: break-word;
}
.qc-detail-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.qc-detail-card__foot {
padding: 2px 12px 8px;
text-align: right;
border-top: 1px dashed #f1f5f9;
}
.qc-detail-card__link {
padding: 0;
border: none;
background: none;
font-size: 11px;
line-height: 1.4;
color: #10b981;
cursor: pointer;
&:hover {
color: #059669;
text-decoration: underline;
}
}
.qc-kv__muted {
color: #94a3b8;
font-weight: 400;
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
}
</style>
@@ -3,11 +3,14 @@
<template>
<el-dialog
v-model="innerVisible"
width="720px"
:width="width"
class="system-view-dialog"
:class="[dialogClass, { 'system-view-dialog--compact': compact }]"
align-center
destroy-on-close
:show-close="false"
:append-to-body="appendToBody"
:modal-class="modalClass"
style="backdrop-filter: none"
>
<div class="dialog-scroll">
@@ -25,7 +28,7 @@
<!-- sections -->
<div v-for="section in sections" :key="section.title" class="view-section">
<h3>{{ section.title }}</h3>
<dl class="view-kv-grid">
<dl class="view-kv-grid" :class="`view-kv-grid--cols-${gridColumns}`">
<div
v-for="field in section.fields"
:key="field.label"
@@ -33,9 +36,13 @@
:class="{ 'view-kv-span-2': field.span }"
>
<dt>{{ field.label }}</dt>
<dd>{{ field.value ?? '—' }}</dd>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{ field.value ?? '—' }}</el-tag>
<template v-else>{{ field.value ?? '—' }}</template>
</dd>
</div>
</dl>
<slot v-if="section.slot" :name="section.slot" />
</div>
<slot name="append" />
</div>
@@ -52,24 +59,52 @@
import { computed, type Component } from 'vue'
import { Close } from '@element-plus/icons-vue'
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface ViewField {
label: string
value: string | number | boolean | null | undefined
span?: boolean
/** 传入后以 el-tag 渲染,值为 el-tag 的 type 属性 */
tagType?: TagType
}
export interface ViewSection {
title: string
fields: ViewField[]
/** 区块内追加内容(如附件列表),对应具名插槽名 */
slot?: string
}
const props = defineProps<{
visible: boolean
title: string
icon?: Component
meta?: string
sections: ViewSection[]
}>()
const props = withDefaults(
defineProps<{
visible: boolean
title: string
icon?: Component
meta?: string
sections: ViewSection[]
/** 弹窗宽度,默认 720px */
width?: string
/** 字段栅格列数,默认 2 */
gridColumns?: 2 | 3
/** 挂到 body,用于嵌套在其它弹窗内时叠层展示 */
appendToBody?: boolean
/** 遮罩类名(可配合全局样式提高 z-index) */
modalClass?: string
/** 附加 dialog class */
dialogClass?: string
/** 紧凑布局:缩小头部/区块间距与内容区最大高度 */
compact?: boolean
}>(),
{
width: '720px',
gridColumns: 2,
appendToBody: false,
modalClass: '',
dialogClass: '',
compact: false
}
)
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
@@ -153,7 +188,11 @@
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 28px;
gap: 12px 20px;
&--cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.view-kv {
@@ -180,8 +219,15 @@
grid-column: 1 / -1;
}
@media (max-width: 900px) {
.view-kv-grid--cols-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.view-kv-grid {
.view-kv-grid,
.view-kv-grid--cols-3 {
grid-template-columns: 1fr;
}
@@ -190,6 +236,60 @@
gap: 4px;
}
}
.system-view-dialog--compact {
.dialog-scroll {
max-height: min(68vh, 560px);
}
.view-hero {
gap: 12px;
padding: 12px 18px;
}
.view-hero-icon {
width: 48px;
height: 48px;
border-radius: 10px;
.el-icon {
font-size: 22px !important;
}
}
.view-hero-main h2 {
margin-bottom: 4px;
font-size: 16px;
}
.view-hero-meta {
font-size: 12px;
}
.view-section {
padding: 12px 18px;
h3 {
margin-bottom: 10px;
font-size: 13px;
}
}
.view-kv-grid {
gap: 8px 16px;
}
.view-kv {
font-size: 12px;
grid-template-columns: 84px 1fr;
}
}
</style>
<style lang="scss">
.system-view-dialog--compact.el-dialog .el-dialog__footer {
padding: 8px 18px;
}
</style>
<style lang="scss">