| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229 |
- <!-- 质量检测 — 质检任务 -->
- <!-- eslint-disable vue/no-v-model-argument -->
- <template>
- <div class="page-container">
- <header class="page-head">
- <h1>质检任务</h1>
- <p class="page-desc">按检测项目跟进任务;实验室自检需开始后再填写结果,第三方在填写结果时一并上传检测材料。</p>
- </header>
- <section class="panel">
- <div class="panel-top">
- <h2>任务列表</h2>
- </div>
- <div class="filter-block">
- <el-form :model="searchForm" class="filter-form" @submit.prevent>
- <div class="filter-grid">
- <el-form-item label="关键词">
- <el-input
- v-model="searchForm.keyword"
- placeholder="任务号/工单号/样本编码/项目"
- clearable
- />
- </el-form-item>
- <el-form-item label="业务类型">
- <el-select v-model="searchForm.qcBizType" placeholder="全部" clearable style="width: 100%">
- <el-option label="入库质检" value="inbound" />
- <el-option label="出库质检" value="outbound" />
- <el-option label="临时质检" value="temporary" />
- </el-select>
- </el-form-item>
- <el-form-item label="任务类型">
- <el-select v-model="searchForm.taskType" placeholder="全部" clearable style="width: 100%">
- <el-option label="实验室自检" value="sysz" />
- <el-option label="第三方检测" value="dsfjc" />
- </el-select>
- </el-form-item>
- <el-form-item label="任务状态">
- <el-select v-model="searchForm.taskStatus" placeholder="全部" clearable style="width: 100%">
- <el-option label="待执行" value="dzx" />
- <el-option label="进行中" value="jxz" />
- <el-option label="已完成" value="ywc" />
- </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-body list-wrap">
- <el-table class="list-table" :data="tableData" v-loading="loading" stripe @row-dblclick="handleView">
- <el-table-column type="index" label="序号" min-width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
- <el-table-column prop="taskNo" label="任务号" min-width="150" align="center" />
- <el-table-column prop="qcBizTypeLabel" label="业务类型" min-width="100" align="center">
- <template #default="{ row }">
- <el-tag :type="qcBizTypeTagType(row.qcBizType || '')" size="small">
- {{ row.qcBizTypeLabel || qcBizTypeLabel(row.qcBizType || '') }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="workOrderNo" label="工单号" min-width="140" align="center" />
- <el-table-column prop="orderSource" label="来源" min-width="100" align="center">
- <template #default="{ row }">
- <el-tag :type="row.orderSource === 'temporary' ? 'warning' : 'success'" size="small">
- {{ row.orderSource === 'temporary' ? '临时' : '入库' }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="receptionSourceLabel" label="样本来源" min-width="100" align="center">
- <template #default="{ row }">
- <span v-if="shouldShowTempEmptyReceptionSource(row)">—</span>
- <el-tag
- v-else
- :type="row.receptionSource === 'research_donation' ? 'warning' : 'success'"
- size="small"
- >
- {{ row.receptionSourceLabel || receptionSourceLabel(row.receptionSource || '') }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="sampleTypeName" label="样本类型" min-width="110" align="center">
- <template #default="{ row }">{{ row.sampleTypeName || '—' }}</template>
- </el-table-column>
- <el-table-column prop="sampleCode" label="样本编码" min-width="120" align="center">
- <template #default="{ row }">{{ row.sampleCode || '—' }}</template>
- </el-table-column>
- <el-table-column prop="receptionTime" label="接收时间" min-width="170" align="center">
- <template #default="{ row }">{{ row.receptionTime || '—' }}</template>
- </el-table-column>
- <el-table-column prop="handlerName" label="接收经手人" min-width="110" align="center">
- <template #default="{ row }">{{ row.handlerName || '—' }}</template>
- </el-table-column>
- <el-table-column prop="taskType" label="检测类型" min-width="110" align="center">
- <template #default="{ row }">
- <el-tag :type="row.taskType === 'sysz' ? 'success' : 'info'" size="small">{{ taskTypeLabel(row.taskType) }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="itemName" label="检测项目" min-width="160" align="center" />
- <el-table-column prop="companyName" label="第三方公司" min-width="120" align="center">
- <template #default="{ row }">{{ row.companyName || '—' }}</template>
- </el-table-column>
- <el-table-column prop="conclusion" label="质检结论" min-width="100" align="center">
- <template #default="{ row }">
- <el-tag
- v-if="row.conclusion === 'tg'"
- type="success"
- size="small"
- >
- {{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
- </el-tag>
- <el-tag
- v-else-if="row.conclusion === 'btg'"
- type="danger"
- size="small"
- >
- {{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
- </el-tag>
- <el-tag v-else type="info" size="small">—</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="taskStatus" label="状态" min-width="100" align="center">
- <template #default="{ row }">
- <el-tag :type="row.taskStatus === 'ywc' ? 'success' : row.taskStatus === 'jxz' ? 'warning' : 'info'" size="small">
- {{ taskStatusLabel(row.taskStatus) }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="remark" label="备注" min-width="140" align="center" show-overflow-tooltip>
- <template #default="{ row }">{{ row.remark || '—' }}</template>
- </el-table-column>
- <el-table-column label="操作" min-width="320" fixed="right" align="center">
- <template #default="{ row }">
- <template v-if="row.taskType === 'sysz' && row.taskStatus === 'dzx'">
- <el-button class="btn-op-item primary" @click="handleStart(row)"><el-icon><VideoPlay /></el-icon>开始</el-button>
- </template>
- <template v-if="canFillResult(row)">
- <el-button class="btn-op-item primary" @click="openResult(row)"><el-icon><EditPen /></el-icon>填写结果</el-button>
- </template>
- <el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
- <el-button v-if="row.taskStatus === 'dzx'" class="btn-op-item" @click="handleDelete(row)">删除</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"
- layout="total, sizes, prev, pager, next"
- @size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
- @current-change="(v) => ((pagination.page = v), loadData())"
- />
- </div>
- </div>
- <el-dialog
- v-model="resultVisible"
- title="填写检测结果"
- :width="resultDialogWidth"
- class="dialog-form-layout qc-task-result-dialog"
- align-center
- destroy-on-close
- :close-on-click-modal="false"
- >
- <DialogFormBody
- :loading="resultLoading"
- >
- <el-form label-position="top" class="biz-dialog-form">
- <div class="form-section">
- <div class="section-title">检测参数</div>
- <div
- v-if="!resultLoading && resultParams.length === 0"
- style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px"
- >
- 该项目暂无配置参数,请先在实验室/第三方项目中维护检测参数
- </div>
- <div class="form-grid-3">
- <el-form-item
- v-for="(param, idx) in resultParams"
- :key="param.paramId || param.paramCode || idx"
- :label="param.paramName + (param.paramUnit ? `(${param.paramUnit})` : '')"
- >
- <el-input v-if="param.paramType === 'text'" v-model="param.paramValue" placeholder="请输入" />
- <el-input-number
- v-else
- :model-value="toResultNumber(param.paramValue)"
- :precision="2"
- style="width: 100%"
- placeholder="请输入"
- @update:model-value="(v) => setResultNumber(param, v)"
- />
- </el-form-item>
- </div>
- </div>
- <div v-if="resultTaskType !== 'dsfjc'" class="form-section material-usage-section">
- <div class="section-title material-usage-head">
- <div class="section-title-left"><h3>物料使用(选填)</h3></div>
- <el-button class="btn-main" type="primary" size="small" @click="addMaterialRow">
- <el-icon><Plus /></el-icon>添加物料
- </el-button>
- </div>
- <el-table
- v-if="materialUsages.length"
- :data="materialUsages"
- :row-key="(row) => row._rowKey"
- stripe
- class="list-table inner-table material-usage-table"
- max-height="280"
- >
- <el-table-column
- type="index"
- label="序号"
- min-width="56"
- align="center"
- header-align="center"
- :index="(index) => index + 1"
- />
- <el-table-column label="物料名称" min-width="160" align="center" header-align="center">
- <template #default="{ row, $index }">
- <el-select
- v-model="row.materialId"
- filterable
- placeholder="搜索并选择物料"
- class="inner-cell-input"
- @change="(v) => onMaterialSelect($index, v)"
- >
- <el-option
- v-for="opt in materialOptions"
- :key="opt.id"
- :label="opt.label"
- :value="opt.id"
- :disabled="isMaterialOptionDisabled(opt.id, $index)"
- />
- </el-select>
- </template>
- </el-table-column>
- <el-table-column label="规格型号" min-width="120" align="center" header-align="center">
- <template #default="{ row }">{{ row.specModel || '—' }}</template>
- </el-table-column>
- <el-table-column label="用量" min-width="120" align="center" header-align="center">
- <template #default="{ row }">
- <el-input-number
- v-model="row.qty"
- :min="0.0001"
- :precision="4"
- :step="1"
- controls-position="right"
- placeholder="用量"
- class="inner-cell-input"
- />
- </template>
- </el-table-column>
- <el-table-column label="单位" min-width="72" align="center" header-align="center">
- <template #default="{ row }">{{ row.unit || '—' }}</template>
- </el-table-column>
- <el-table-column label="备注" min-width="140" align="center" header-align="center">
- <template #default="{ row }">
- <el-input v-model="row.remark" placeholder="选填" clearable class="inner-cell-input" />
- </template>
- </el-table-column>
- <el-table-column label="操作" min-width="96" align="center" header-align="center" fixed="right">
- <template #default="{ $index }">
- <el-button link type="danger" size="small" @click="removeMaterialRow($index)">
- 删除
- </el-button>
- </template>
- </el-table-column>
- </el-table>
- <el-empty
- v-else
- description="暂无物料记录,可点击上方按钮添加"
- :image-size="56"
- />
- </div>
- <div class="form-section result-conclusion-section">
- <div class="section-title">检测结论</div>
- <el-form-item label="结论" required>
- <el-radio-group v-model="resultConclusion">
- <el-radio value="tg">通过</el-radio>
- <el-radio value="btg">不通过</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="备注">
- <el-input v-model="resultRemark" type="textarea" :rows="3" placeholder="选填" />
- </el-form-item>
- <div class="result-attachment-block">
- <ProtocolFinanceConfirmFields
- v-model:finance-remark="resultRemark"
- v-model:attachments="resultAttachments"
- :show-remark="false"
- :attachment-label="resultAttachmentLabel"
- :upload-folder="resultUploadFolder"
- />
- </div>
- </div>
- </el-form>
- </DialogFormBody>
- <template #footer>
- <el-button class="btn-ghost" @click="resultVisible = false"><el-icon><Close /></el-icon>取消</el-button>
- <el-button class="btn-main" type="primary" :loading="resultLoading" @click="submitResult"><el-icon><Check /></el-icon>{{ resultSubmitLabel }}</el-button>
- </template>
- </el-dialog>
- <SystemViewDialog
- v-model:visible="viewVisible"
- title="任务详情"
- width="960px"
- :grid-columns="3"
- :icon="Tickets"
- :meta="viewDetail?.taskNo || ''"
- :sections="viewSections"
- >
- <template #reception-extra>
- <div class="view-reception-attachments">
- <div class="view-kv view-kv-span-2">
- <dt>接收附件</dt>
- <dd>
- <ProtocolAttachmentReadonly
- :files="viewReceptionAttachmentFiles"
- @preview="handleReceptionAttachmentPreview"
- @download="handleReceptionAttachmentDownload"
- />
- </dd>
- </div>
- </div>
- </template>
- <template #material-usages>
- <div v-if="viewMaterialUsages.length" class="view-subtable-wrap">
- <el-table :data="viewMaterialUsages" stripe class="list-table view-subtable">
- <el-table-column
- type="index"
- label="序号"
- min-width="56"
- align="center"
- header-align="center"
- :index="(index) => index + 1"
- />
- <el-table-column
- prop="materialName"
- label="物料名称"
- min-width="160"
- align="center"
- header-align="center"
- />
- <el-table-column label="规格型号" min-width="120" align="center" header-align="center">
- <template #default="{ row }">{{ row.specModel || '—' }}</template>
- </el-table-column>
- <el-table-column label="用量" min-width="100" align="center" header-align="center">
- <template #default="{ row }">
- {{ row.qty != null && row.qty !== '' ? row.qty : '—' }}
- </template>
- </el-table-column>
- <el-table-column label="单位" min-width="72" align="center" header-align="center">
- <template #default="{ row }">{{ row.unit || '—' }}</template>
- </el-table-column>
- <el-table-column label="备注" min-width="140" align="center" header-align="center">
- <template #default="{ row }">{{ row.remark || '—' }}</template>
- </el-table-column>
- </el-table>
- </div>
- <p v-else class="view-empty-hint">暂无物料使用记录</p>
- </template>
- <template #task-attachments>
- <ProtocolAttachmentReadonly
- :files="viewDetail?.attachmentFiles || []"
- @preview="handleViewAttachmentPreview"
- @download="handleViewAttachmentDownload"
- />
- </template>
- </SystemViewDialog>
- </section>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, computed, onMounted } from 'vue'
- import { ElMessage, ElMessageBox } from 'element-plus'
- import { Close, Check, Search, RefreshLeft, VideoPlay, View, Tickets, EditPen, Plus } from '@element-plus/icons-vue'
- import { formatMoneyDisplay } from '@/utils/money-format'
- import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
- import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
- import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
- import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
- import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
- import {
- financeAttachmentsToUrlString,
- type FinanceAttachmentItem
- } from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
- import {
- fetchQcTaskList,
- fetchQcTaskDetail,
- fetchStartQcTask,
- fetchCreateQcTask,
- fetchDeleteQcTask,
- type QcTask,
- type QcTaskDetail
- } from '@/api/qc-task'
- import {
- fetchQcTaskResults,
- fetchQcTaskMaterialUsages,
- fetchCompleteTaskWithResult,
- type QcTaskResultItem,
- type QcTaskMaterialUsageItem
- } from '@/api/qc-task-result'
- import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
- import {
- taskTypeLabel,
- taskStatusLabel,
- qcBizTypeLabel,
- qcBizTypeTagType,
- receptionSourceLabel,
- acceptConclusionLabel,
- transportTypeLabel,
- workOrderStatusLabel,
- qcTaskConclusionLabel
- } from '@/utils/qc-labels'
- import {
- protocolStatusLabel,
- protocolStatusTagType,
- donationStatusLabel,
- donationStatusTagType,
- } from '@/utils/status-tag-registry'
- import type { TagType } from '@/utils/status-tag-registry'
- /** 根据来源类型返回协议状态标签 */
- function protocolStatusLabelBySource(status: string, source: string): string {
- return source === 'research_donation' ? donationStatusLabel(status) : protocolStatusLabel(status)
- }
- function protocolStatusTagTypeBySource(status: string, source: string): TagType {
- return source === 'research_donation' ? donationStatusTagType(status) : protocolStatusTagType(status)
- }
- /** 临时来源任务无关联样本,样本来源展示为 — */
- function shouldShowTempEmptyReceptionSource(row: { orderSource?: string }) {
- return row.orderSource === 'temporary'
- }
- import { downloadAttachmentFile } from '@/utils/download-attachment'
- defineOptions({ name: 'QualityInspectionQcTask' })
- const addVisible = ref(false)
- const addLoading = ref(false)
- const addFormRef = ref()
- const addForm = reactive({
- taskType: 'sysz',
- itemCode: '',
- itemName: '',
- companyId: '',
- companyName: '',
- feeAmount: undefined as number | undefined,
- sampleCode: '',
- qcBizType: 'inbound',
- remark: ''
- })
- const addRules = {
- taskType: [{ required: true, message: '请选择检测类型', trigger: 'change' }],
- itemCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }],
- itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
- }
- function handleAdd() {
- addForm.taskType = 'sysz'
- addForm.itemCode = ''
- addForm.itemName = ''
- addForm.companyId = ''
- addForm.companyName = ''
- addForm.feeAmount = undefined
- addForm.sampleCode = ''
- addForm.qcBizType = 'inbound'
- addForm.remark = ''
- addVisible.value = true
- }
- async function submitAdd() {
- await addFormRef.value?.validate()
- addLoading.value = true
- try {
- const payload: Record<string, any> = {
- taskType: addForm.taskType,
- itemCode: addForm.itemCode,
- itemName: addForm.itemName,
- sampleCode: addForm.sampleCode || undefined,
- qcBizType: addForm.qcBizType,
- remark: addForm.remark || undefined
- }
- if (addForm.taskType === 'dsfjc') {
- payload.companyId = addForm.companyId || undefined
- payload.companyName = addForm.companyName || undefined
- payload.feeAmount = addForm.feeAmount
- }
- await fetchCreateQcTask(payload)
- ElMessage.success('创建成功')
- addVisible.value = false
- loadData()
- } finally {
- addLoading.value = false
- }
- }
- const searchForm = reactive({ keyword: '', qcBizType: '', taskType: '', taskStatus: '' })
- const tableData = ref<QcTask[]>([])
- const loading = ref(false)
- const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
- const resultVisible = ref(false)
- const resultLoading = ref(false)
- const resultTaskId = ref('')
- const resultTaskType = ref('')
- const resultTaskStatus = ref('')
- const resultParams = ref<QcTaskResultItem[]>([])
- const resultRemark = ref('')
- const resultConclusion = ref('')
- const resultAttachments = ref<FinanceAttachmentItem[]>([])
- type MaterialUsageRow = {
- _rowKey: string
- materialId: string
- materialName: string
- specModel: string
- unit: string
- qty: number | undefined
- remark: string
- }
- let materialUsageRowSeq = 0
- function nextMaterialRowKey() {
- materialUsageRowSeq += 1
- return `mu_${Date.now()}_${materialUsageRowSeq}`
- }
- const materialOptions = ref<QcMaterialOption[]>([])
- const materialUsages = ref<MaterialUsageRow[]>([])
- const resultDialogWidth = computed(() =>
- resultTaskType.value === 'sysz' ? '970px' : '720px'
- )
- function canFillResult(row: QcTask) {
- if (row.taskType === 'sysz') {
- return row.taskStatus === 'jxz' || row.taskStatus === 'ywc'
- }
- if (row.taskType === 'dsfjc') {
- return row.taskStatus === 'dzx' || row.taskStatus === 'jxz' || row.taskStatus === 'ywc'
- }
- return false
- }
- const resultSubmitLabel = computed(() =>
- resultTaskStatus.value === 'ywc' ? '保存' : '提交并完成'
- )
- const resultAttachmentLabel = computed(() =>
- resultTaskType.value === 'dsfjc' ? '检测材料' : '检测附件'
- )
- const resultUploadFolder = computed(() =>
- resultTaskType.value === 'dsfjc' ? 'qc-third-party' : 'qc-lab'
- )
- function clearMaterialRow(row: MaterialUsageRow) {
- row.materialId = ''
- row.materialName = ''
- row.specModel = ''
- row.unit = ''
- }
- function isMaterialOptionDisabled(materialId: string, currentIndex: number) {
- if (!materialId) return false
- return materialUsages.value.some((r, i) => i !== currentIndex && r.materialId === materialId)
- }
- function isDuplicateMaterial(materialId: string, excludeIndex: number) {
- if (!materialId) return false
- return materialUsages.value.some((r, i) => i !== excludeIndex && r.materialId === materialId)
- }
- function parseMaterialQty(qty: number | string | undefined): number | null {
- if (qty === undefined || qty === null || qty === '') return null
- const n = typeof qty === 'number' ? qty : Number(String(qty).trim())
- if (!Number.isFinite(n) || n <= 0) return null
- return n
- }
- function onMaterialSelect(index: number, id: string) {
- const row = materialUsages.value[index]
- if (!row) return
- if (!id) {
- if (parseMaterialQty(row.qty) == null) {
- removeMaterialRow(index)
- } else {
- clearMaterialRow(row)
- }
- return
- }
- if (isDuplicateMaterial(id, index)) {
- ElMessage.warning('该物料已在列表中,请勿重复添加')
- clearMaterialRow(row)
- return
- }
- const opt = materialOptions.value.find((o) => o.id === id)
- row.materialId = id
- row.materialName = opt?.materialName || ''
- row.specModel = opt?.specModel || ''
- row.unit = opt?.unit || ''
- }
- function addMaterialRow() {
- materialUsages.value.push({
- _rowKey: nextMaterialRowKey(),
- materialId: '',
- materialName: '',
- specModel: '',
- unit: '',
- qty: undefined,
- remark: ''
- })
- }
- function removeMaterialRow(index: number) {
- materialUsages.value = materialUsages.value.filter((_, i) => i !== index)
- }
- /** 合并同物料多条变动记录(历史重复扣减时),并生成稳定行 key */
- function materialRowsFromApi(items: QcTaskMaterialUsageItem[]): MaterialUsageRow[] {
- const merged = new Map<string, MaterialUsageRow>()
- for (const u of items || []) {
- const mid = (u.materialId || '').trim()
- if (!mid) continue
- const qtyRaw = Math.abs(Number(u.changeQty) || 0)
- const prev = merged.get(mid)
- if (prev) {
- prev.qty = (Number(prev.qty) || 0) + qtyRaw
- } else {
- merged.set(mid, {
- _rowKey: nextMaterialRowKey(),
- materialId: mid,
- materialName: u.materialName || '',
- specModel: '',
- unit: '',
- qty: qtyRaw > 0 ? qtyRaw : undefined,
- remark: u.remark || ''
- })
- }
- }
- return Array.from(merged.values())
- }
- function enrichMaterialUsageRows(
- rows: MaterialUsageRow[],
- options: QcMaterialOption[] = materialOptions.value
- ) {
- for (const row of rows) {
- const opt = options.find((o) => o.id === row.materialId)
- if (opt) {
- row.materialName = opt.materialName || row.materialName
- row.specModel = opt.specModel || ''
- row.unit = opt.unit || ''
- }
- }
- }
- const viewVisible = ref(false)
- const viewDetail = ref<QcTaskDetail | null>(null)
- const viewMaterialUsages = ref<MaterialUsageRow[]>([])
- const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
- function apiFilesToFinanceItems(
- files?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
- ): FinanceAttachmentItem[] {
- if (!files?.length) return []
- return files.map((f, i) => ({
- id: f.id || `att_${i}`,
- fileName: f.fileName || '',
- fileUrl: f.fileUrl || '',
- fileType: f.fileType || ''
- }))
- }
- 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 handleViewAttachmentPreview(file: Record<string, unknown>) {
- const url = attachmentFullUrl(String(file.fileUrl || ''))
- if (url) window.open(url, '_blank')
- }
- function handleViewAttachmentDownload(file: Record<string, unknown>) {
- downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
- }
- 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, i) => {
- const url = u.trim()
- if (!url) return null
- const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
- return { id: `url_${i}`, fileUrl: url, fileName: name }
- })
- .filter(Boolean) as Array<Record<string, unknown>>
- })
- function handleReceptionAttachmentPreview(file: Record<string, unknown>) {
- const url = attachmentFullUrl(String(file.fileUrl || ''))
- if (url) window.open(url, '_blank')
- }
- function handleReceptionAttachmentDownload(file: Record<string, unknown>) {
- downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
- }
- function normalizeResultParams(items: QcTaskResultItem[]): QcTaskResultItem[] {
- return (items || []).map((p) => ({
- ...p,
- paramType: p.paramType || 'text',
- paramValue: p.paramValue ?? ''
- }))
- }
- function toResultNumber(v: string | undefined) {
- if (v === '' || v === undefined || v === null) return undefined
- const n = Number(v)
- return Number.isFinite(n) ? n : undefined
- }
- function setResultNumber(param: QcTaskResultItem, v: number | undefined) {
- param.paramValue = v === undefined || v === null ? '' : String(v)
- }
- function hasResultValue(v: unknown) {
- return v !== '' && v !== undefined && v !== null
- }
- 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 viewSections = computed<ViewSection[]>(() => {
- const d = viewDetail.value
- if (!d) return []
- const r = d.reception || {}
- const isTempQc = d.qcBizType === 'temporary'
- 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 cellProductSection: ViewSection[] = isTempQc
- ? [
- {
- title: '质检对象',
- fields: [
- { label: '产品编码', value: d.cellProductCode || '—' },
- { label: '产品名称', value: d.cellProductName || '—' },
- { label: '产品类型', value: d.cellProductTypeName || '—' },
- { label: '代次', value: d.cellProductPassage || '—' },
- { label: '规格', value: d.cellProductSpecName || '—' }
- ]
- }
- ]
- : []
- const sampleSections: ViewSection[] = isTempQc
- ? []
- : [
- {
- title: '样本信息',
- fields: [
- { label: '协议号', value: r.protocolNo || d.protocolNo || '—' },
- {
- label: '协议状态',
- value: protocolStatusLabelBySource(
- String(d.protocolStatus || r.protocolStatus || ''),
- d.receptionSource || r.sourceType || 'cell_storage'
- ),
- tagType: protocolStatusTagTypeBySource(
- String(d.protocolStatus || r.protocolStatus || ''),
- d.receptionSource || r.sourceType || 'cell_storage'
- )
- },
- { label: '样本编码', value: r.sampleCode || d.sampleCode || '—' },
- {
- label: '样本来源',
- value: r.sourceTypeLabel || d.receptionSourceLabel || receptionSourceLabel(d.receptionSource || ''),
- tagType: 'primary' as const
- },
- { 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 }
- ]
- }
- ]
- const resultSection =
- d.results && d.results.length > 0
- ? [
- {
- title: '检测结果',
- fields: d.results.map((item) => ({
- label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
- value: item.paramValue || '—'
- }))
- }
- ]
- : []
- const tailSections: ViewSection[] = []
- if (d.taskType !== 'dsfjc') {
- tailSections.push({ title: '物料使用', fields: [], slot: 'material-usages' })
- }
- if (d.attachmentFiles?.length) {
- tailSections.push({
- title: d.taskType === 'dsfjc' ? '检测材料' : '检测附件',
- fields: [],
- slot: 'task-attachments'
- })
- }
- return [
- {
- title: '任务信息',
- fields: [
- { label: '任务号', value: d.taskNo },
- { label: '业务类型', value: d.qcBizTypeLabel || qcBizTypeLabel(d.qcBizType || ''), tagType: qcBizTypeTagType(d.qcBizType || '') },
- { 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: d.feeAmount != null ? '¥' + formatMoneyDisplay(d.feeAmount) : '—' },
- { 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 }
- ]
- },
- ...cellProductSection,
- ...sampleSections,
- ...resultSection,
- ...tailSections
- ]
- })
- async function handleDelete(row: QcTask) {
- await ElMessageBox.confirm('确定要删除该质检任务吗?', '提示', { type: 'warning' })
- await fetchDeleteQcTask(row.id)
- ElMessage.success('删除成功')
- await loadData()
- }
- async function loadData() {
- loading.value = true
- try {
- const res = await fetchQcTaskList({
- page: pagination.page,
- pageSize: pagination.pageSize,
- keyword: searchForm.keyword || undefined,
- qcBizType: searchForm.qcBizType || undefined,
- taskType: searchForm.taskType || undefined,
- taskStatus: searchForm.taskStatus || undefined
- })
- tableData.value = res.list || []
- pagination.total = res.total ?? 0
- } finally {
- loading.value = false
- }
- }
- function handleSearch() {
- pagination.page = 1
- loadData()
- }
- function handleReset() {
- searchForm.keyword = ''
- searchForm.qcBizType = ''
- searchForm.taskType = ''
- searchForm.taskStatus = ''
- pagination.page = 1
- loadData()
- }
- async function handleStart(row: QcTask) {
- await fetchStartQcTask(row.id)
- ElMessage.success('已开始')
- loadData()
- }
- async function handleView(row: QcTask) {
- const detail = await fetchQcTaskDetail(row.id)
- try {
- const results = await fetchQcTaskResults(row.id)
- ;(detail as any).results = results || []
- } catch {
- ;(detail as any).results = []
- }
- viewMaterialUsages.value = []
- if (row.taskType !== 'dsfjc') {
- try {
- const [savedUsages, opts] = await Promise.all([
- fetchQcTaskMaterialUsages(row.id).catch(() => [] as QcTaskMaterialUsageItem[]),
- fetchQcMaterialOptions().catch(() => [] as QcMaterialOption[])
- ])
- const rows = materialRowsFromApi(savedUsages || [])
- enrichMaterialUsageRows(rows, opts || [])
- viewMaterialUsages.value = rows
- } catch {
- viewMaterialUsages.value = []
- }
- }
- viewDetail.value = detail
- viewVisible.value = true
- }
- async function openResult(row: QcTask) {
- if (!canFillResult(row)) {
- if (row.taskType === 'sysz') {
- ElMessage.warning('请先开始任务后再填写结果')
- } else {
- ElMessage.warning('当前状态不可填写结果')
- }
- return
- }
- resultTaskStatus.value = row.taskStatus
- resultTaskId.value = row.id
- resultTaskType.value = row.taskType
- resultRemark.value = row.remark || ''
- resultConclusion.value = ''
- resultAttachments.value = apiFilesToFinanceItems(row.attachmentFiles)
- materialUsages.value = []
- materialOptions.value = []
- resultLoading.value = true
- try {
- const detail = await fetchQcTaskDetail(row.id)
- resultTaskStatus.value = detail.taskStatus || row.taskStatus
- resultRemark.value = detail.remark || row.remark || ''
- resultConclusion.value = detail.conclusion || ''
- resultAttachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
- const results = await fetchQcTaskResults(row.id)
- resultParams.value = normalizeResultParams(results || [])
- if (row.taskType !== 'dsfjc') {
- const [opts, savedUsages] = await Promise.all([
- fetchQcMaterialOptions().catch(() => [] as QcMaterialOption[]),
- fetchQcTaskMaterialUsages(row.id).catch(() => [] as QcTaskMaterialUsageItem[])
- ])
- materialOptions.value = opts || []
- materialUsages.value = materialRowsFromApi(savedUsages || [])
- enrichMaterialUsageRows(materialUsages.value)
- }
- } catch {
- resultParams.value = []
- materialUsages.value = []
- } finally {
- resultLoading.value = false
- }
- resultVisible.value = true
- }
- async function submitResult() {
- if (resultAttachments.value.some((a) => a._uploading)) {
- ElMessage.warning('附件正在上传,请稍候')
- return
- }
- if (!resultConclusion.value) {
- ElMessage.warning('请选择检测结论')
- return
- }
- if (resultTaskType.value === 'dsfjc' && !financeAttachmentsToUrlString(resultAttachments.value)) {
- ElMessage.warning('请上传第三方检测材料')
- return
- }
- if (resultTaskType.value !== 'dsfjc') {
- materialUsages.value = materialUsages.value.filter((mu) => !!mu.materialId)
- const seenMaterialIds = new Set<string>()
- for (const mu of materialUsages.value) {
- if (!mu.materialId) continue
- if (parseMaterialQty(mu.qty) == null) {
- ElMessage.warning('请为已选物料填写有效用量')
- return
- }
- if (seenMaterialIds.has(mu.materialId)) {
- ElMessage.warning('物料列表中存在重复物料,请检查')
- return
- }
- seenMaterialIds.add(mu.materialId)
- }
- }
- resultLoading.value = true
- try {
- const validMaterialUsages = materialUsages.value
- .map((mu) => ({ ...mu, qtyNum: parseMaterialQty(mu.qty) }))
- .filter((mu) => mu.materialId && mu.qtyNum != null)
- const data: {
- conclusion: string
- results: QcTaskResultItem[]
- remark?: string
- thirdPartyAttachments?: string
- materialUsages?: Array<{ materialId: string; qty: number; remark?: string }>
- } = {
- conclusion: resultConclusion.value,
- remark: resultRemark.value,
- results: resultParams.value
- .filter((p) => hasResultValue(p.paramValue))
- .map((p) => ({
- ...p,
- paramValue: p.paramValue === undefined || p.paramValue === null ? '' : String(p.paramValue)
- }))
- }
- data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
- if (resultTaskType.value !== 'dsfjc') {
- data.materialUsages = validMaterialUsages.map((mu) => ({
- materialId: mu.materialId,
- qty: mu.qtyNum!,
- remark: mu.remark?.trim() || undefined
- }))
- }
- await fetchCompleteTaskWithResult(resultTaskId.value, data)
- ElMessage.success(resultTaskStatus.value === 'ywc' ? '已保存' : '已完成')
- resultVisible.value = false
- loadData()
- } finally {
- resultLoading.value = false
- }
- }
- // 输入检索条件时自动搜索(防抖 300ms)
- watchDebounced(
- searchForm,
- () => {
- pagination.page = 1
- loadData()
- },
- { debounce: 300, deep: true }
- )
- onMounted(() => loadData())
- </script>
- <style scoped lang="scss">
- .form-section {
- background: #fafafa;
- border-radius: 8px;
- padding: 20px;
- }
- .form-section .section-title {
- font-size: 15px;
- font-weight: 500;
- color: #303133;
- margin-bottom: 16px;
- padding-bottom: 12px;
- padding-left: 10px;
- border-bottom: 1px solid #ebeef5;
- border-left: 3px solid #10b981;
- }
- .material-usage-section {
- margin-top: 12px;
- .material-usage-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 14px;
- padding-bottom: 10px;
- border-bottom: 1px solid #ebeef5;
- border-left: none;
- padding-left: 0;
- }
- .section-title-left {
- display: flex;
- align-items: center;
- min-width: 0;
- &::before {
- content: '';
- width: 3px;
- height: 18px;
- background: #10b981;
- border-radius: 2px;
- margin-right: 10px;
- flex-shrink: 0;
- }
- h3 {
- margin: 0;
- font-size: 15px;
- font-weight: 600;
- color: #1e293b;
- }
- }
- .material-usage-table {
- width: 100%;
- }
- .inner-cell-input {
- width: 100%;
- }
- :deep(.inner-cell-input.el-input-number) {
- width: 100%;
- }
- }
- .result-conclusion-section {
- margin-top: 12px;
- display: flex;
- flex-direction: column;
- gap: 16px;
- .section-title {
- margin-bottom: 0;
- }
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- :deep(.el-form-item__label) {
- display: inline-flex;
- width: 100%;
- justify-content: flex-start;
- text-align: left;
- padding-left: 0 !important;
- }
- .result-attachment-block {
- margin-top: 0;
- padding: 0;
- background: transparent;
- :deep(.protocol-finance-confirm-fields .el-form-item) {
- margin-bottom: 0;
- }
- }
- }
- .biz-dialog-form {
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- }
- .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;
- }
- }
- }
- </style>
|