This commit is contained in:
zlwu committed 2026-06-15 11:43:16 +08:00
1 parent 427bcb1551
commit 31b04575e2
247 files changed
+39284 -20367

No files matched your search

@@ -22,16 +22,21 @@
<el-form-item label="下发对象">
<el-select
v-model="searchForm.issuedUserId"
placeholder="全部"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -328,15 +333,21 @@
<el-form-item label="下发用户" prop="issuedUserId">
<el-select
v-model="batchIssueForm.issuedUserId"
placeholder="请选择用户"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -412,15 +423,21 @@
<el-form-item v-if="formData.issued === 1" label="下发用户" prop="issuedUserId">
<el-select
v-model="formData.issuedUserId"
placeholder="请选择用户"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -479,7 +496,7 @@
fetchCreateSampleCode,
fetchUpdateSampleCode,
fetchDeleteSampleCode,
fetchUserOptions,
fetchUserList,
fetchBatchGenerateSampleCodes,
fetchBatchIssueSampleCodes,
fetchBatchRevokeSampleCodes
@@ -572,7 +589,35 @@
const batchIssueRules = {
issuedUserId: [{ required: true, message: '请选择下发用户', trigger: 'change' }]
}
const userOptions = ref<Array<{ label: string; value: string }>>([])
const userOptions = ref<Api.User.UserItem[]>([])
const userOptionsLoading = ref(false)
function formatUserOptionLabel(u: Api.User.UserItem) {
const parts = [u.nickName || '']
if (u.userNo) parts.push(u.userNo)
return parts.filter(Boolean).join(' · ')
}
async function remoteSearchUsers(query: string) {
userOptionsLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: query?.trim() || undefined,
status: '1'
})
userOptions.value = res.list || []
} catch {
userOptions.value = []
} finally {
userOptionsLoading.value = false
}
}
function onUserSelectVisible(visible: boolean) {
if (visible) void remoteSearchUsers('')
}
const formData = reactive({
sampleCode: '',
@@ -648,22 +693,13 @@
editingId.value = null
}
async function loadUserOptions() {
if (userOptions.value.length) return
try {
userOptions.value = await fetchUserOptions()
} catch {
/* ignore */
}
}
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增样本编码'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleEdit(row: Api.BizSampleCode.SampleCodeItem) {
@@ -680,7 +716,7 @@
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleSubmit() {
@@ -689,7 +725,7 @@
try {
const issueUserOpt =
formData.issued === 1
? userOptions.value.find((u) => u.value === formData.issuedUserId)
? userOptions.value.find((u) => u.id === formData.issuedUserId)
: undefined
const payload = {
sampleCode: formData.sampleCode.trim(),
@@ -698,7 +734,7 @@
issuedUserId: formData.issued === 1 ? formData.issuedUserId || undefined : null,
issuedUserName:
formData.issued === 1
? (formData.issuedUserName || issueUserOpt?.label || '').trim() || undefined
? (formData.issuedUserName || issueUserOpt?.nickName || '').trim() || undefined
: null,
issuedTime: formData.issuedTime || undefined,
orderNum: formData.orderNum,
@@ -799,7 +835,7 @@
if (!selectedRows.value.length) return
batchIssueForm.issuedUserId = ''
batchIssueVisible.value = true
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleBatchIssue() {
@@ -807,11 +843,11 @@
batchIssueLoading.value = true
try {
const ids = selectedRows.value.map((r) => r.id)
const userOpt = userOptions.value.find((u) => u.value === batchIssueForm.issuedUserId)
const userOpt = userOptions.value.find((u) => u.id === batchIssueForm.issuedUserId)
await fetchBatchIssueSampleCodes({
ids,
issuedUserId: batchIssueForm.issuedUserId,
issuedUserName: userOpt?.label || undefined
issuedUserName: userOpt?.nickName || undefined
})
batchIssueVisible.value = false
selectedRows.value = []
@@ -830,8 +866,7 @@
{ debounce: 300, deep: true }
)
onMounted(async () => {
await loadUserOptions()
onMounted(() => {
loadData()
})
</script>
@@ -11,16 +11,21 @@
<el-form-item label="下发对象">
<el-select
v-model="searchForm.issuedUserId"
placeholder="全部"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -292,15 +297,21 @@
<el-form-item label="下发用户" prop="issuedUserId">
<el-select
v-model="batchIssueForm.issuedUserId"
placeholder="请选择用户"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -365,15 +376,21 @@
<el-form-item v-if="formData.issued === 1" label="下发用户" prop="issuedUserId">
<el-select
v-model="formData.issuedUserId"
placeholder="请选择用户"
placeholder="输入姓名检索"
filterable
remote
reserve-keyword
clearable
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -432,7 +449,7 @@
fetchCreateCollectionProtocolNo,
fetchUpdateCollectionProtocolNo,
fetchDeleteCollectionProtocolNo,
fetchUserOptions,
fetchUserList,
fetchBatchGenerateProtocolNos,
fetchBatchIssueProtocolNos,
fetchBatchRevokeProtocolNos
@@ -520,15 +537,43 @@
const batchIssueRules = {
issuedUserId: [{ required: true, message: '请选择下发用户', trigger: 'change' }]
}
const userOptions = ref<Array<{ label: string; value: string }>>([])
const userOptions = ref<Api.User.UserItem[]>([])
const userOptionsLoading = ref(false)
function formatUserOptionLabel(u: Api.User.UserItem) {
const parts = [u.nickName || '']
if (u.userNo) parts.push(u.userNo)
return parts.filter(Boolean).join(' · ')
}
async function remoteSearchUsers(query: string) {
userOptionsLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: query?.trim() || undefined,
status: '1'
})
userOptions.value = res.list || []
} catch {
userOptions.value = []
} finally {
userOptionsLoading.value = false
}
}
function onUserSelectVisible(visible: boolean) {
if (visible) void remoteSearchUsers('')
}
function resolveIssuedUserName(row: Api.BizCollectionProtocolNo.ProtocolNoItem): string {
const name = (row.issuedUserName || '').trim()
if (name) return name
const uid = (row.issuedUserId || '').trim()
if (!uid) return '-'
const opt = userOptions.value.find((u) => u.value === uid)
return (opt?.label || '').trim() || '-'
const opt = userOptions.value.find((u) => u.id === uid)
return (opt?.nickName || '').trim() || '-'
}
const formData = reactive({
@@ -606,7 +651,7 @@
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleEdit(row: Api.BizCollectionProtocolNo.ProtocolNoItem) {
@@ -622,7 +667,7 @@
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleSubmit() {
@@ -631,7 +676,7 @@
try {
const issueUserOpt =
formData.issued === 1
? userOptions.value.find((u) => u.value === formData.issuedUserId)
? userOptions.value.find((u) => u.id === formData.issuedUserId)
: undefined
const payload = {
protocolNo: formData.protocolNo.trim(),
@@ -639,7 +684,7 @@
issuedUserId: formData.issued === 1 ? formData.issuedUserId || undefined : null,
issuedUserName:
formData.issued === 1
? (formData.issuedUserName || issueUserOpt?.label || '').trim() || undefined
? (formData.issuedUserName || issueUserOpt?.nickName || '').trim() || undefined
: null,
issuedTime: formData.issuedTime || undefined,
orderNum: formData.orderNum,
@@ -740,7 +785,7 @@
if (!selectedRows.value.length) return
batchIssueForm.issuedUserId = ''
batchIssueVisible.value = true
await loadUserOptions()
void remoteSearchUsers('')
}
async function handleBatchIssue() {
@@ -748,11 +793,11 @@
batchIssueLoading.value = true
try {
const ids = selectedRows.value.map((r) => r.id)
const userOpt = userOptions.value.find((u) => u.value === batchIssueForm.issuedUserId)
const userOpt = userOptions.value.find((u) => u.id === batchIssueForm.issuedUserId)
await fetchBatchIssueProtocolNos({
ids,
issuedUserId: batchIssueForm.issuedUserId,
issuedUserName: userOpt?.label || undefined
issuedUserName: userOpt?.nickName || undefined
})
batchIssueVisible.value = false
selectedRows.value = []
@@ -762,14 +807,6 @@
}
}
async function loadUserOptions() {
if (userOptions.value.length) return
try {
userOptions.value = await fetchUserOptions()
} catch {
/* ignore */
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
@@ -780,8 +817,7 @@
{ debounce: 300, deep: true }
)
onMounted(async () => {
await loadUserOptions()
onMounted(() => {
loadData()
})
</script>
@@ -15,9 +15,14 @@
<el-form-item label="物料筛选">
<el-select
v-model="searchForm.materialId"
placeholder="全部物料"
placeholder="输入物料名称检索"
clearable
filterable
remote
reserve-keyword
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
@visible-change="onMaterialSelectVisible"
>
<el-option
v-for="m in materialOptions"
@@ -281,7 +286,7 @@
import { Search, RefreshLeft, View, Document, CircleCheck } from '@element-plus/icons-vue'
import { fetchMaterialUsageList, fetchMarkMaterialUsageFullyUsed } from '@/api/material-usage'
import type { MaterialUsage } from '@/api/material-usage'
import { fetchMaterialOptions } from '@/api/material'
import { fetchMaterialList } from '@/api/material'
import type { MaterialOption } from '@/api/material'
import { formatMoneyDisplay } from '@/utils/money-format'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
@@ -305,6 +310,7 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const materialOptions = ref<MaterialOption[]>([])
const materialOptionsLoading = ref(false)
function isManualOutRow(row: MaterialUsage) {
if (row.sourceType === 'manual_out') return true
@@ -410,15 +416,39 @@
}
}
async function loadMaterialOptions() {
function mapMaterialListToOptions(list: Array<Record<string, any>>): MaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
category: String(m.category || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchMaterialOptions()
materialOptions.value = res || []
const res = await fetchMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
materialOptions.value = mapMaterialListToOptions(res.list || [])
} catch {
materialOptions.value = []
} finally {
materialOptionsLoading.value = false
}
}
function onMaterialSelectVisible(visible: boolean) {
if (visible) void remoteSearchMaterials('')
}
async function loadData() {
loading.value = true
try {
@@ -542,13 +572,13 @@
})
onMounted(async () => {
await loadMaterialOptions()
if (route.query.materialId) {
searchForm.materialId = route.query.materialId as string
}
if (route.query.stockInNo) {
searchForm.keyword = route.query.stockInNo as string
}
await remoteSearchMaterials('')
loadData()
})
</script>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -246,9 +246,15 @@
<el-form-item label="保险公司" prop="insuranceCompanyId">
<el-select
v-model="formData.insuranceCompanyId"
placeholder="请选择保险公司"
placeholder="输入公司名称检索"
filterable
remote
reserve-keyword
clearable
:loading="insuranceCompanyLoading"
:remote-method="remoteSearchInsuranceCompanies"
style="width: 100%"
@visible-change="onInsuranceCompanySelectVisible"
@change="handleInsuranceCompanyChange"
>
<el-option
@@ -544,16 +550,41 @@
// 保险公司下拉
// ---------------------------------------------------------------------------
const insuranceCompanyOptions = ref<any[]>([])
const insuranceCompanyLoading = ref(false)
async function loadInsuranceCompanies() {
async function remoteSearchInsuranceCompanies(query: string) {
insuranceCompanyLoading.value = true
try {
const res = await fetchInsuranceCompanyList({ page: 1, pageSize: 100 })
const res = await fetchInsuranceCompanyList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
insuranceCompanyOptions.value = (res as any)?.list || []
ensureInsuranceCompanyInOptions(formData.insuranceCompanyId)
} catch {
insuranceCompanyOptions.value = []
ensureInsuranceCompanyInOptions(formData.insuranceCompanyId)
} finally {
insuranceCompanyLoading.value = false
}
}
function ensureInsuranceCompanyInOptions(companyId: string) {
const id = String(companyId || '').trim()
if (!id || insuranceCompanyOptions.value.some((c) => c.id === id)) return
insuranceCompanyOptions.value = [{ id, companyName: id }, ...insuranceCompanyOptions.value]
}
function onInsuranceCompanySelectVisible(visible: boolean) {
if (visible) void remoteSearchInsuranceCompanies('')
}
async function loadInsuranceCompanies() {
await remoteSearchInsuranceCompanies('')
}
// ---------------------------------------------------------------------------
// 险种价格下拉(按保险公司联动)
// ---------------------------------------------------------------------------
File diff suppressed because it is too large. Load diff
@@ -115,220 +115,181 @@
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@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="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolStatus"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="expectedDeliveryDate"
label="预产期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectSummary"
label="是否已采集"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="registerDate"
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="motherPhone"
label="母亲电话"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherIdCard"
label="母亲身份证号"
min-width="190"
align="center"
header-align="center"
/>
<el-table-column
prop="fatherName"
label="父亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornSex"
label="新生儿性别"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleTypeNames"
label="样本类型"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleCodes"
label="样本编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="labStorageCodes"
label="实验室入库编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.labStorageCodes || '—' }}</template>
</el-table-column>
<el-table-column
prop="regionName"
label="采集地区"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="collectorUserName"
label="采集专员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="salesUserName"
label="销售人员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-loading="loading" class="protocol-card-section">
<div v-if="tableData.length" class="protocol-card-list">
<article
v-for="(row, index) in tableData"
:key="String(row.id)"
class="protocol-card"
:class="{ 'is-selected': isRowSelected(String(row.id)) }"
@dblclick="handleView(row)"
>
<div class="protocol-card__select" @click.stop>
<el-checkbox
:model-value="isRowSelected(String(row.id))"
@change="(val) => toggleRowSelection(row, !!val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<div class="protocol-card__aside">
<div class="protocol-card__name-row">
<span class="protocol-card__index">{{
(pagination.page - 1) * pagination.pageSize + index + 1
}}</span>
<span class="protocol-card__name">{{ row.motherName || '—' }}</span>
</div>
<div class="protocol-card__contact">
<div class="protocol-card__sub">{{ row.motherPhone || '—' }}</div>
<div class="protocol-card__sub protocol-card__sub--id">
{{ row.motherIdCard ? maskIdCard(String(row.motherIdCard)) : '—' }}
</div>
</div>
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
class="protocol-card__status"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
<div v-if="row.createTime" class="protocol-card__create-time">
创建 {{ row.createTime }}
</div>
</div>
<div class="protocol-card__sections">
<section class="protocol-card__block protocol-card__block--basic">
<div class="protocol-card__block-title">基础信息</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>协议号</label><span>{{ row.protocolNo || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>登记日期</label><span>{{ row.registerDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集地区</label><span>{{ row.regionName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集医院</label
><span :title="String(row.hospitalName || '')">{{
row.hospitalName || '—'
}}</span>
</div>
<div class="protocol-card__kv">
<label>采集专员</label><span>{{ row.collectorUserName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>样本采集</label><span>{{ row.sampleCollectSummary || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集时间</label><span>{{ row.sampleCollectTime || '—' }}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--newborn">
<div class="protocol-card__block-title">新生儿</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>姓名</label><span>{{ row.newbornName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>性别</label
><span>{{ newbornSexLabel(String(row.newbornSex || '')) || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>预产期</label><span>{{ row.expectedDeliveryDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>分娩时间</label><span>{{ row.newbornDeliveryTime || '—' }}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--samples">
<div class="protocol-card__block-title">
样本信息
<span v-if="getSampleItems(row).length" class="protocol-card__block-count">
{{ getSampleItems(row).length }} 项
</span>
</div>
<div v-if="getSampleItems(row).length" class="protocol-card__sample-list">
<div
v-for="(item, sIdx) in getSampleItems(row)"
:key="sIdx"
class="protocol-card__sample-item"
>
<span class="protocol-card__sample-seq">{{ sIdx + 1 }}</span>
<div class="protocol-card__sample-main">
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">类型</span>
<span class="protocol-card__sample-value">{{
item.sampleTypeName || '—'
}}</span>
<el-tag
v-if="item.sampleStatus"
:type="sampleStatusTagType(item.sampleStatus)"
size="small"
class="protocol-card__sample-status"
>
{{ sampleStatusLabel(item.sampleStatus) }}
</el-tag>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">编码</span>
<span class="protocol-card__sample-value protocol-card__sample-value--code">
{{ item.sampleCode || '—' }}
</span>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">运输</span>
<span class="protocol-card__sample-value">{{
formatTransportSummary(item.transport)
}}</span>
</div>
</div>
</div>
</div>
<div v-else class="protocol-card__empty">暂无样本</div>
</section>
<section v-if="getPaymentSummary(row)" class="protocol-card__block protocol-card__block--payment">
<div class="protocol-card__block-title">缴费信息</div>
<QcWorkOrderProtocolPaymentPanel
:summary="getPaymentSummary(row)"
compact
embedded
hide-overall-pay-status
hide-installment-detail-amounts
/>
</section>
<section v-else class="protocol-card__block protocol-card__block--payment">
<div class="protocol-card__block-title">缴费信息</div>
<div class="protocol-card__empty">暂无缴费信息</div>
</section>
</div>
<div class="protocol-card__foot">
<div class="protocol-card__actions">
<el-button class="btn-op-item" @click.stop="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-button class="btn-op-item" @click.stop="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
</article>
</div>
<el-empty
v-else-if="!loading"
class="protocol-card-empty"
description="暂无协议单"
:image-size="80"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
@@ -386,8 +347,7 @@
Delete,
Search,
RefreshLeft,
CircleCheck,
More
CircleCheck
} from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
@@ -398,6 +358,8 @@
type StatusChangeAction,
type StatusChangeTarget
} from '../protocol/ProtocolStatusChangeDialog.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
@@ -412,11 +374,100 @@
protocolStatusLabel,
protocolStatusTagType,
newbornSexLabel,
deliveryModeLabel
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { maskIdCard } from '@/utils/mask-sensitive'
defineOptions({ name: 'CellStorageProtocolAuditTab' })
type ProtocolSampleTransport = {
transportType?: string
destSiteName?: string
expressCompanyName?: string
waybillNo?: string
carrierName?: string
carrierPhone?: string
etaTime?: string
}
type ProtocolSampleItem = {
sampleTypeName: string
sampleCode: string
sampleStatus: string
labStorageCode: string
transport?: ProtocolSampleTransport | null
}
function getPaymentSummary(row: CollectionProtocolRow): QcProtocolPaymentSummary | null {
const summary = row.paymentSummary
if (!summary || typeof summary !== 'object') return null
return summary as QcProtocolPaymentSummary
}
function formatTransportSummary(transport?: ProtocolSampleTransport | null): string {
if (!transport?.transportType) return '—'
const parts: string[] = [transportTypeLabel(String(transport.transportType))]
const dest = String(transport.destSiteName || '').trim()
if (dest) parts.push(dest)
if (transport.transportType === 'kdys') {
const expressName = String(transport.expressCompanyName || '').trim()
if (expressName) parts.push(expressName)
const waybill = String(transport.waybillNo || '').trim()
if (waybill) parts.push(waybill)
} else if (transport.transportType === 'ryxd') {
const carrier = String(transport.carrierName || '').trim()
if (carrier) parts.push(carrier)
const phone = String(transport.carrierPhone || '').trim()
if (phone) parts.push(phone)
}
const eta = String(transport.etaTime || '').trim()
if (eta) parts.push(`预计 ${eta}`)
return parts.join(' · ')
}
function getSampleItems(row: CollectionProtocolRow): ProtocolSampleItem[] {
const rawItems = row.sampleItems
if (Array.isArray(rawItems) && rawItems.length) {
return rawItems.map((item) => {
const rec = item as Record<string, unknown>
const transportRaw = rec.transport
const transport =
transportRaw && typeof transportRaw === 'object'
? (transportRaw as ProtocolSampleTransport)
: null
return {
sampleTypeName: String(rec.sampleTypeName || '').trim(),
sampleCode: String(rec.sampleCode || '').trim(),
sampleStatus: String(rec.sampleStatus || '').trim(),
labStorageCode: String(rec.labStorageCode || '').trim(),
transport
}
})
}
const types = String(row.sampleTypeNames || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const codes = String(row.sampleCodes || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const labCodes = String(row.labStorageCodes || '')
.split(',')
.map((s) => s.trim())
const len = Math.max(types.length, codes.length)
if (!len) return []
return Array.from({ length: len }, (_, i) => ({
sampleTypeName: types[i] || '',
sampleCode: codes[i] || '',
sampleStatus: '',
labStorageCode: labCodes[i] || '',
transport: null
}))
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -443,8 +494,15 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: CollectionProtocolRow[]) => {
selectedRows.value = rows
const isRowSelected = (id: string) => selectedRows.value.some((r) => String(r.id) === id)
const toggleRowSelection = (row: CollectionProtocolRow, checked: boolean) => {
const id = String(row.id)
if (checked) {
if (!isRowSelected(id)) selectedRows.value = [...selectedRows.value, row]
return
}
selectedRows.value = selectedRows.value.filter((r) => String(r.id) !== id)
}
async function loadData() {
@@ -462,6 +520,8 @@
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
const validIds = new Set(tableData.value.map((r) => String(r.id)))
selectedRows.value = selectedRows.value.filter((r) => validIds.has(String(r.id)))
} finally {
loading.value = false
}
@@ -565,14 +625,6 @@
loadData()
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
/* ====== 删除 ====== */
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(formatProtocolDocDeleteConfirm('cell_storage', row), '删除确认', {
@@ -622,3 +674,359 @@
loadData()
})
</script>
<style scoped lang="scss">
.protocol-card-section {
min-height: 120px;
}
.protocol-card-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.protocol-card {
position: relative;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 12px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
padding: 12px 14px 12px 40px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c5e0d4;
box-shadow: 0 2px 8px rgb(15 23 42 / 5%);
}
&.is-selected {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 1px rgb(16 185 129 / 25%);
}
}
.protocol-card__select {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
}
.protocol-card__aside {
flex: 0 0 148px;
min-width: 132px;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 12px;
border-right: 1px solid #eef2f6;
}
.protocol-card__status {
align-self: flex-start;
}
.protocol-card__create-time {
margin-top: auto;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
line-height: 1.35;
word-break: break-all;
}
.protocol-card__name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.protocol-card__index {
flex-shrink: 0;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
}
.protocol-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.protocol-card__contact {
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sub {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--id {
white-space: normal;
word-break: break-all;
}
}
.protocol-card__sections {
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 10px;
min-width: 0;
overflow-x: auto;
}
.protocol-card__block {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f6;
&--basic {
flex: 1.1;
min-width: 180px;
}
&--newborn {
flex: 0.85;
min-width: 140px;
}
&--samples {
flex: 1.35;
min-width: 220px;
}
&--payment {
flex: 1.2;
min-width: 200px;
padding-bottom: 4px;
:deep(.qc-protocol-payment--compact) {
padding: 0;
background: transparent;
}
:deep(.qc-protocol-payment__compact-details) {
border-top-color: #e2e8f0;
}
:deep(.qc-protocol-payment__detail-chip) {
background: #fff;
border-color: #e2e8f0;
}
}
}
.protocol-card__block-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
color: var(--primary-dark, #059669);
line-height: 1.3;
}
.protocol-card__block-count {
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
.protocol-card__kv-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.protocol-card__kv {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
label {
flex-shrink: 0;
color: var(--mint-text-muted, #64748b);
}
span {
min-width: 0;
color: var(--text, #1e293b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.protocol-card__empty {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.protocol-card__sample-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.protocol-card__sample-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
}
.protocol-card__sample-seq {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 10px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.protocol-card__sample-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sample-line {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
.protocol-card__sample-label {
flex-shrink: 0;
width: 28px;
color: var(--mint-text-muted, #64748b);
}
.protocol-card__sample-value {
min-width: 0;
flex: 1;
color: var(--text, #1e293b);
word-break: break-all;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
}
.protocol-card__sample-status {
flex-shrink: 0;
margin-left: auto;
}
.protocol-card__foot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding-left: 12px;
border-left: 1px solid #eef2f6;
}
.protocol-card__actions {
display: flex;
flex-direction: column;
flex-shrink: 0;
align-items: stretch;
gap: 6px;
min-width: 72px;
:deep(.el-button) {
width: 100%;
margin: 0;
justify-content: center;
}
}
.protocol-card-empty {
padding: 32px 16px;
}
@media (max-width: 1200px) {
.protocol-card {
flex-wrap: wrap;
}
.protocol-card__sections {
flex-basis: 100%;
order: 3;
}
.protocol-card__foot {
flex-direction: column;
align-items: stretch;
border-left: none;
border-top: 1px solid #eef2f6;
padding-left: 0;
padding-top: 10px;
margin-left: 0;
order: 4;
width: 100%;
}
}
@media (max-width: 768px) {
.protocol-card__sections {
flex-direction: column;
}
.protocol-card__block {
min-width: 0 !important;
}
}
</style>
@@ -2,30 +2,30 @@
<template>
<div class="page-container">
<section class="panel">
<div
class="panel-entity-switch panel-entity-switch--multi"
role="tablist"
aria-label="视图类型"
>
<button
v-for="item in entityTabs"
:key="item.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === item.key }"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="item.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ item.title }}</span>
<span class="entity-switch-card__desc">{{ item.desc }}</span>
</span>
</button>
</div>
<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>
<ProtocolAuditTab v-if="activeTab === 'protocol'" />
<PaymentInfoTab v-else-if="activeTab === 'payment-info'" />
@@ -36,8 +36,6 @@
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Document, Wallet, Medal, Calendar } from '@element-plus/icons-vue'
import ProtocolAuditTab from './ProtocolAuditTab.vue'
import PaymentInfoTab from './PaymentInfoTab.vue'
import StorageCertTab from './StorageCertTab.vue'
@@ -55,31 +53,26 @@
key: EntityTabKey
title: string
desc: string
icon: Component
}> = [
{
key: 'protocol',
title: '协议单管理',
desc: '协议审核与状态维护',
icon: Document
desc: '协议审核与状态维护'
},
{
key: 'payment-info',
title: '缴费信息',
desc: '缴费计划与到账查询',
icon: Wallet
desc: '缴费计划与到账查询'
},
{
key: 'storage-cert',
title: '储存证管理',
desc: '储存证开具与管理',
icon: Medal
desc: '储存证开具与管理'
},
{
key: 'fullmoon-todo',
title: '满月待办管理',
desc: '满月保险待办办理',
icon: Calendar
desc: '满月保险待办办理'
}
]
@@ -87,6 +80,18 @@
const router = useRouter()
const activeTab = ref<EntityTabKey>('protocol')
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 (LEGACY_SAMPLE_TABS.includes(tab as (typeof LEGACY_SAMPLE_TABS)[number])) {
@@ -103,110 +108,139 @@
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.panel-entity-switch--multi {
flex-wrap: wrap;
gap: 10px;
.entity-switch-card {
flex: 0 1 168px;
min-width: 152px;
max-width: 180px;
padding: 10px 12px;
gap: 8px;
}
.entity-switch-card__icon {
width: 32px;
height: 32px;
font-size: 17px;
}
.entity-switch-card__title {
font-size: 13px;
}
.entity-switch-card__desc {
font-size: 11px;
line-height: 1.35;
}
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 220px;
max-width: 280px;
flex: 1;
padding: 14px 18px;
border: 1px solid var(--mint-border, #c5e0d4);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a8d4c4;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #047857);
background: var(--primary-bg, #e8f4ee);
box-shadow: 0 0 0 1px rgb(4 120 87 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #065f46);
}
}
.entity-switch-card__main {
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
align-items: center;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.entity-switch-card.is-active & {
color: var(--primary-dark, #065f46);
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -0,0 +1,291 @@
// 采集协议单 · 缴费信息五列看板
.payment-kanban-board {
display: grid;
grid-template-columns: repeat(5, minmax(260px, 1fr));
gap: 12px;
padding: 16px;
overflow-x: auto;
align-items: stretch;
}
.payment-kanban-col {
min-width: 260px;
background: var(--table-head, #f8fafc);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 320px);
max-height: calc(100vh - 200px);
&--dfj {
border-top: 3px solid #fbbf24;
}
&--yjf {
border-top: 3px solid var(--primary, #10b981);
}
&--ytf {
border-top: 3px solid #ef4444;
}
}
.payment-kanban-col-hd {
padding: 10px 12px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
background: var(--surface, #fff);
border-radius: 10px 10px 0 0;
.payment-kanban-col--dfj & {
background: linear-gradient(180deg, #fffbeb 0%, #fff 100%);
}
.payment-kanban-col--yjf & {
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
}
.payment-kanban-col--ytf & {
background: linear-gradient(180deg, #fef2f2 0%, #fff 100%);
}
}
.payment-kanban-col-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text, #1e293b);
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.payment-kanban-count {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface, #fff);
color: var(--mint-text-muted, #64748b);
border: 1px solid var(--mint-border, #d9e8e2);
}
.payment-kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-content: start;
}
.payment-kanban-col-footer {
padding: 8px 10px 10px;
border-top: 1px solid var(--mint-border, #d9e8e2);
flex-shrink: 0;
background: var(--surface, #fff);
border-radius: 0 0 10px 10px;
:deep(.el-pagination) {
flex-wrap: wrap;
justify-content: center;
row-gap: 4px;
}
:deep(.el-pagination .el-select) {
width: 88px;
}
}
.payment-card {
position: relative;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
background: var(--surface, #fff);
display: flex;
flex-direction: column;
min-width: 0;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: rgb(16 185 129 / 45%);
box-shadow: 0 4px 12px rgb(16 185 129 / 8%);
}
&--due-soon {
background: #fee2e2;
&:hover {
background: #fecaca;
}
}
}
.payment-card__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
}
.payment-card__head-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.payment-card__identity {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.payment-card__name {
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
line-height: 1.4;
word-break: break-word;
}
.payment-card__sub {
font-size: 11px;
line-height: 1.35;
color: var(--mint-text-muted, #64748b);
word-break: break-all;
&--id {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
}
}
.payment-card__status {
flex-shrink: 0;
align-self: center;
}
.payment-card__body {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.payment-card__kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.4;
min-width: 0;
label {
flex-shrink: 0;
width: 72px;
color: var(--mint-text-muted, #64748b);
font-size: 11px;
}
span {
flex: 1;
min-width: 0;
color: var(--text, #1e293b);
word-break: break-word;
}
&--amount span {
font-weight: 600;
color: var(--primary-dark, #059669);
}
}
.payment-card__expand {
padding: 0 12px 8px;
}
.payment-card__expand-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
cursor: pointer;
padding: 4px 0;
border: none;
background: transparent;
&:hover {
color: var(--primary-dark, #059669);
}
}
.payment-card__expand-body {
margin-top: 6px;
padding-top: 8px;
border-top: 1px dashed var(--mint-border, #e2e8f0);
display: flex;
flex-direction: column;
gap: 6px;
}
.payment-card__foot {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 10px;
border-top: 1px solid var(--mint-border, #e2e8f0);
:deep(.btn-op-item.el-button) {
flex: 1;
min-width: 0;
margin: 0;
height: 28px;
font-size: 11px;
padding: 0 6px;
}
}
.payment-kanban-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
border: 1px dashed var(--mint-border, #d9e8e2);
border-radius: 10px;
background: rgb(255 255 255 / 60%);
}
@media (width <= 1024px) {
.payment-kanban-board {
grid-template-columns: 1fr;
overflow-x: visible;
}
.payment-kanban-col {
min-width: 0;
max-height: none;
min-height: 280px;
}
}
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -3,9 +3,11 @@
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1380px"
width="1600px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -82,12 +84,11 @@
/>
</el-select>
</el-form-item>
</div>
<div class="form-grid form-grid--col2 basic-remark-attach-row">
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
<div class="form-grid form-grid--attachment">
<el-form-item label="协议附件">
<div class="attachment-upload">
<div class="attachment-list">
@@ -169,7 +170,7 @@
<div class="section-title">
<div class="section-title-left"><h3>采集信息</h3></div>
</div>
<div class="form-grid">
<div class="form-grid form-grid--col6">
<el-form-item label="采集地区">
<el-select
v-model="formData.regionId"
@@ -271,12 +272,22 @@
<SampleTransportFormSection
v-model:samples="formData.samples"
:sample-type-options="sampleTypeOptions"
:sample-type-options-loading="sampleTypeLoading"
:sample-code-options="sampleCodeOptions"
:sample-code-options-loading="sampleCodeLoading"
:unit-options="unitOptions"
:unit-options-loading="unitLoading"
:site-options="siteOptions"
:site-options-loading="siteLoading"
:express-company-options="expressCompanyOptions"
:express-company-options-loading="expressCompanyLoading"
:show-price="true"
@price-change="onSamplePriceChange"
@search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes"
@search-unit="remoteSearchUnits"
@search-site="remoteSearchSites"
@search-express-company="remoteSearchExpressCompanies"
/>
</div>
</div>
@@ -486,7 +497,7 @@
<!-- 缴费主表 -->
<div class="payment-main-card">
<div class="section-subtitle">缴费主表</div>
<div class="form-grid form-grid--col5">
<div class="form-grid form-grid--col6">
<el-form-item label="缴费模式">
<el-radio-group v-model="formData.payMode" @change="onPayModeChange">
<el-radio label="dj">趸交</el-radio>
@@ -547,68 +558,52 @@
@change="handleFqParamsChange"
/>
</el-form-item>
<div class="installment-advance-ratios-row form-col-span-2">
<el-form-item label="应缴费提前(天)" class="installment-advance-field">
<el-input
v-model="formData.installmentAdvanceDays"
type="number"
:min="0"
:max="365"
step="1"
style="width: 120px"
@change="handleFqParamsChange"
/>
</el-form-item>
<el-form-item label="缴费比例(%)" class="installment-ratios-field">
<div class="installment-ratios-panel">
<div class="installment-ratios-bar" aria-hidden="true">
<div
v-for="(ratio, rIdx) in formData.installmentRatios"
:key="'bar-' + rIdx"
class="installment-ratios-bar-seg"
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
:style="{ flex: Math.max(Number(ratio) || 0, 0.001) }"
:title="`第${rIdx + 1}期 ${ratio}%`"
/>
</div>
<div class="installment-ratios-cells">
<div
v-for="(_ratio, rIdx) in formData.installmentRatios"
:key="rIdx"
class="installment-ratio-cell"
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
>
<span class="installment-ratio-cell-no">{{ rIdx + 1 }}期</span>
<el-input
v-model="formData.installmentRatios[rIdx]"
type="number"
:min="0"
:max="100"
step="any"
:disabled="rIdx === formData.installmentRatios.length - 1"
@change="onInstallmentRatioChange(rIdx)"
>
<template #suffix>%</template>
</el-input>
</div>
</div>
<div class="installment-ratios-foot">
<span class="form-label-tip">末期为自动补齐</span>
<span
class="installment-ratios-sum"
:class="{ 'is-error': !!installmentRatiosError }"
>
合计 {{ installmentRatiosSum }}%
</span>
</div>
<div v-if="installmentRatiosError" class="form-tip form-tip--error">
{{ installmentRatiosError }}
</div>
</div>
</el-form-item>
</div>
<el-form-item label="应缴费提前(天)">
<el-input
v-model="formData.installmentAdvanceDays"
type="number"
:min="0"
:max="365"
step="1"
style="width: 100%"
@change="handleFqParamsChange"
/>
</el-form-item>
</template>
</div>
<div v-if="formData.payMode === 'fq'" class="installment-amounts-row">
<el-form-item label="分期金额(元)" class="installment-amounts-field">
<div class="installment-amounts-cells">
<div
v-for="(_amount, rIdx) in formData.installmentRatios"
:key="rIdx"
class="installment-amount-cell"
>
<span class="installment-amount-cell-no">{{ rIdx + 1 }}期</span>
<AmountInput
v-model="formData.installmentRatios[rIdx]"
:min="0"
placeholder="金额"
@change="onInstallmentAmountChange(rIdx)"
/>
</div>
</div>
</el-form-item>
<div class="installment-amounts-summary-row">
<span
class="installment-amounts-sum"
:class="{ 'is-error': !!installmentAmountsError }"
>
合计 {{ fmtMoney(installmentAmountsSum) }}
<template v-if="formData.contractAmount != null">
/ 合同 {{ fmtMoney(formData.contractAmount) }}
</template>
</span>
<div v-if="installmentAmountsError" class="form-tip form-tip--error">
{{ installmentAmountsError }}
</div>
</div>
</div>
</div>
<!-- 缴费明细 -->
@@ -844,7 +839,14 @@
</el-dialog>
<!-- ========== 文件预览弹窗 ========== -->
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -900,16 +902,14 @@
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
import { useUserStore } from '@/store/modules/user'
import {
calcInstallmentDueAmountsByRatios,
calcUnpaidAmountsByRatios,
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
DEFAULT_INSTALLMENT_RATIOS_6,
defaultInstallmentRatios,
finalizeInstallmentRatios,
isInstallmentRatiosValid,
resizeInstallmentRatios,
syncFqPaymentDetailRowCount
} from '@/utils/protocol-payment-installment'
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
isInstallmentAmountsValid,
normalizeInstallmentAmounts,
resizeInstallmentAmounts,
scaleInstallmentAmountsToContract,
sumInstallmentAmounts,
syncFqPaymentDetailRowCount
} from '@/utils/protocol-payment-installment'
import {
applyPayModeSwitch,
canAddPaymentDetail,
@@ -955,7 +955,7 @@
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
installmentRatios: [] as number[]
})
const customerId = ref<string | null>(null)
@@ -997,6 +997,10 @@
const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const sampleCodeLoading = ref(false)
const sampleTypeLoading = ref(false)
const unitLoading = ref(false)
const siteLoading = ref(false)
const expressCompanyLoading = ref(false)
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
const siteOptions = ref<Api.Site.SiteItem[]>([])
let sampleRowSeq = 0
@@ -1194,7 +1198,7 @@
lumpSumDeadline: '',
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS as number | null,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6] as number[],
installmentRatios: [] as number[] as number[],
// Payment Details
paymentDetails: [] as Array<Record<string, any>>
})
@@ -1248,7 +1252,7 @@
lumpSumDeadline: '',
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6],
installmentRatios: [] as number[],
paymentDetails: []
})
customerId.value = null
@@ -1262,6 +1266,11 @@
salesSnapshot.value = null
regionOptions.value = []
hospitalOptions.value = []
sampleTypeOptions.value = []
unitOptions.value = []
sampleCodeOptions.value = []
siteOptions.value = []
expressCompanyOptions.value = []
activeFormTab.value = 'basic'
lastConfirmedPayMode.value = 'dj'
lastConfirmedFqParams.value = {
@@ -1269,7 +1278,7 @@
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
installmentRatios: [] as number[]
}
}
@@ -1377,13 +1386,11 @@
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
}
const count = toIntOrUndefined(formData.installmentCount) || 6
if (reset || !formData.installmentRatios?.length) {
formData.installmentRatios = defaultInstallmentRatios(count)
} else {
formData.installmentRatios = finalizeInstallmentRatios(
resizeInstallmentRatios(formData.installmentRatios, count)
)
}
formData.installmentRatios = normalizeInstallmentAmounts(
formData.installmentRatios || [],
formData.contractAmount,
count
)
}
function toIntOrUndefined(val: unknown): number | undefined {
@@ -1428,7 +1435,7 @@
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
installmentStartDate: formData.installmentStartDate || '',
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
installmentRatios: finalizeInstallmentRatios([...(formData.installmentRatios || [])])
installmentRatios: [...(formData.installmentRatios || [])].map((v) => Number(v || 0))
}
}
@@ -1584,7 +1591,6 @@
resetForm()
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
loadSampleOptions()
void loadEthnicOptions()
void remoteSearchCustomers('')
@@ -1686,12 +1692,18 @@
formData.installmentStartDate = String(pay.installmentStartDate || '')
formData.installmentAdvanceDays =
pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
const loadedRatios = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
if (loadedRatios.length) {
formData.installmentRatios = loadedRatios.map((v: unknown) => Number(v))
const loadedAmounts = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
if (loadedAmounts.length) {
formData.installmentRatios = loadedAmounts.map((v: unknown) => Number(v))
}
if (formData.payMode === 'fq') {
applyFqPaymentDefaults()
const count = toIntOrUndefined(formData.installmentCount) || 6
formData.installmentRatios = normalizeInstallmentAmounts(
formData.installmentRatios,
formData.contractAmount,
count
)
syncLastConfirmedFqParams()
}
}
@@ -1741,8 +1753,7 @@
dialogFormLoading.value = true
try {
const [, , bundle] = await Promise.all([
loadSampleOptions(),
const [, bundle] = await Promise.all([
loadEthnicOptions(),
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
])
@@ -1779,7 +1790,13 @@
}
await Promise.all(hydrateTasks)
if (formData.samples.length) {
ensureSampleCodesInOptions(formData.samples)
await Promise.all([
remoteSearchSampleTypes(''),
remoteSearchSites(''),
remoteSearchUnits(''),
remoteSearchSampleCodes('')
])
hydrateSampleLookupOptions()
}
} catch {
/* 保留列表行已填基础字段 */
@@ -1792,13 +1809,13 @@
if (!formRef.value) return
await formRef.value.validate()
if (formData.payMode === 'fq') {
if (installmentRatiosError.value) {
await ElMessageBox.alert(installmentRatiosError.value, '缴费比例无效', { type: 'warning' })
if (installmentAmountsError.value) {
await ElMessageBox.alert(installmentAmountsError.value, '分期金额无效', { type: 'warning' })
return
}
const ratios = finalizeInstallmentRatios([...(formData.installmentRatios || [])])
if (!isInstallmentRatiosValid(ratios)) {
await ElMessageBox.alert('缴费比例之和须等于 100%', '缴费比例无效', { type: 'warning' })
const amounts = (formData.installmentRatios || []).map((v) => Number(v || 0))
if (!isInstallmentAmountsValid(amounts, formData.contractAmount)) {
await ElMessageBox.alert('分期金额合计须等于合同金额', '分期金额无效', { type: 'warning' })
return
}
}
@@ -1883,7 +1900,7 @@
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
installmentRatios:
formData.payMode === 'fq'
? finalizeInstallmentRatios([...(formData.installmentRatios || [])])
? (formData.installmentRatios || []).map((v) => Number(v || 0))
: undefined
},
paymentDetails: formData.paymentDetails.map(
@@ -2116,6 +2133,89 @@
sampleCodeOptions.value = merged
}
function ensureSampleTypesInOptions(samples: Array<Record<string, any>>) {
const merged = [...sampleTypeOptions.value]
for (const s of samples) {
const id = String(s.sampleTypeId || '')
if (!id) continue
const name = String(s.sampleTypeName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.typeName || existing.typeName === existing.id)) {
existing.typeName = name
}
continue
}
merged.push({
id,
typeCode: '',
typeName: name || id,
storageYears: s.storageYears ?? null,
storagePrice: s.originalPrice ?? null
})
}
sampleTypeOptions.value = merged
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const id = String(s.unitId || '')
if (!id) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id, unitName: name || id, unitCode: String(s.unitCode || '') })
}
unitOptions.value = merged
}
function ensureSitesInOptions(samples: Array<Record<string, any>>) {
const merged = [...siteOptions.value]
for (const s of samples) {
const id = String(s.transport?.destSiteId || '')
if (!id) continue
const name = String(s.transport?.destSiteName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.siteName || existing.siteName === existing.id)) {
existing.siteName = name
}
continue
}
merged.push({
id,
siteCode: '',
siteName: name || id,
siteType: '',
status: '',
address: '',
contactName: '',
contactPhone: '',
businessScope: '',
siteImage: '',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
})
}
siteOptions.value = merged
}
function hydrateSampleLookupOptions() {
ensureSampleTypesInOptions(formData.samples)
ensureSampleCodesInOptions(formData.samples)
ensureUnitsInOptions(formData.samples)
ensureSitesInOptions(formData.samples)
}
/* ====== 客户检索(母亲信息) ====== */
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
const parts = [c.customerName || '']
@@ -2248,23 +2348,91 @@
formData.salesUserId = u.id
}
/* ====== 样本类型/单位选项 ====== */
async function loadSampleOptions() {
async function remoteSearchSampleCodes(query: string) {
sampleCodeLoading.value = true
try {
const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchItemUnitList({ page: 1, pageSize: 100 }),
fetchSampleCodeList({ page: 1, pageSize: 100 }),
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
fetchSiteList({ page: 1, pageSize: 100 })
])
sampleTypeOptions.value = (stRes.list || []) as any
unitOptions.value = (uRes.list || []) as any
sampleCodeOptions.value = (scRes.list || []) as any
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
const res = await fetchSampleCodeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
ensureSampleCodesInOptions(formData.samples)
} catch {
/* ignore */
sampleCodeOptions.value = []
ensureSampleCodesInOptions(formData.samples)
} finally {
sampleCodeLoading.value = false
}
}
async function remoteSearchSampleTypes(query: string) {
sampleTypeLoading.value = true
try {
const res = await fetchCellSampleTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleTypeOptions.value = (res.list || []) as any
ensureSampleTypesInOptions(formData.samples)
} catch {
sampleTypeOptions.value = []
ensureSampleTypesInOptions(formData.samples)
} finally {
sampleTypeLoading.value = false
}
}
async function remoteSearchUnits(query: string) {
unitLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
unitOptions.value = (res.list || []) as any
ensureUnitsInOptions(formData.samples)
} catch {
unitOptions.value = []
ensureUnitsInOptions(formData.samples)
} finally {
unitLoading.value = false
}
}
async function remoteSearchSites(query: string) {
siteLoading.value = true
try {
const res = await fetchSiteList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
siteOptions.value = res.list || []
ensureSitesInOptions(formData.samples)
} catch {
siteOptions.value = []
ensureSitesInOptions(formData.samples)
} finally {
siteLoading.value = false
}
}
async function remoteSearchExpressCompanies(query: string) {
expressCompanyLoading.value = true
try {
const res = await fetchExpressCompanyList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
expressCompanyOptions.value = res.list || []
} catch {
expressCompanyOptions.value = []
} finally {
expressCompanyLoading.value = false
}
}
@@ -2341,33 +2509,32 @@
function onInstallmentCountChange() {
const count = toIntOrUndefined(formData.installmentCount) || 0
if (count > 0) {
formData.installmentRatios = finalizeInstallmentRatios(
resizeInstallmentRatios(formData.installmentRatios || [], count)
formData.installmentRatios = resizeInstallmentAmounts(
formData.installmentRatios || [],
count,
formData.contractAmount
)
}
handleFqParamsChange()
}
function onInstallmentRatioChange(changedIdx: number) {
if (changedIdx === formData.installmentRatios.length - 1) return
formData.installmentRatios = finalizeInstallmentRatios([...formData.installmentRatios])
function onInstallmentAmountChange(_changedIdx: number) {
syncPaymentDueAmounts()
handleFqParamsChange()
}
const installmentRatiosError = computed(() => {
const ratios = formData.installmentRatios || []
if (!ratios.length) return ''
const head = ratios.slice(0, -1)
const sum = head.reduce((s, v) => s + Number(v || 0), 0)
if (sum > 100 + 0.001) return '前 N−1 期比例之和不能超过 100%'
const installmentAmountsError = computed(() => {
const amounts = formData.installmentRatios || []
if (!amounts.length || formData.contractAmount == null) return ''
if (amounts.some((v) => Number(v) < 0)) return '分期金额不能为负数'
const sum = sumInstallmentAmounts(amounts)
if (Math.abs(sum - Number(formData.contractAmount)) > 0.01) {
return `分期金额合计须等于合同金额(${fmtMoney(formData.contractAmount)})`
}
return ''
})
const installmentRatiosSum = computed(() => {
const total = (formData.installmentRatios || []).reduce((s, v) => s + Number(v || 0), 0)
return Math.round(total * 100) / 100
})
const installmentAmountsSum = computed(() => sumInstallmentAmounts(formData.installmentRatios || []))
/** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
function syncPaymentDueAmounts() {
@@ -2388,27 +2555,16 @@
if (formData.payMode === 'fq') {
const count = formData.installmentCount || formData.paymentDetails.length
if (!count) return
const ratios = finalizeInstallmentRatios(formData.installmentRatios || [])
const amounts = normalizeInstallmentAmounts(
formData.installmentRatios || [],
contract,
Number(count)
)
const paidRows = formData.paymentDetails.filter((d) => String(d.payStatus) === 'yjf')
let amounts: number[]
if (paidRows.length > 0 && formData.paymentDetails.some((d) => isPayDetailLocked(d))) {
const paidSum = sumPaidAmount(formData.paymentDetails)
const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
const unpaidIndices = Array.from({ length: Number(count) }, (_, i) => i).filter(
(i) => !paidRows.some((d) => Number(d.installmentNo) === i + 1)
)
const unpaidAmounts = calcUnpaidAmountsByRatios(remaining, ratios, unpaidIndices)
amounts = Array(Number(count)).fill(0)
unpaidIndices.forEach((idx, j) => {
amounts[idx] = unpaidAmounts[j] ?? 0
})
paidRows.forEach((d) => {
const no = Number(d.installmentNo)
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
})
} else {
amounts = calcInstallmentDueAmountsByRatios(Number(contract), ratios)
}
paidRows.forEach((d) => {
const no = Number(d.installmentNo)
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
})
formData.paymentDetails.forEach((d, i) => {
if (isPayDetailLocked(d)) return
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
@@ -2422,7 +2578,14 @@
if (s.sampleStatus === 'yzf') return sum
return sum + (Number(s.price) || 0)
}, 0)
formData.contractAmount = Math.round(total * 100) / 100
const nextContract = Math.round(total * 100) / 100
if (formData.payMode === 'fq' && (formData.installmentRatios || []).length) {
formData.installmentRatios = scaleInstallmentAmountsToContract(
formData.installmentRatios,
nextContract
)
}
formData.contractAmount = nextContract
syncPaymentDueAmounts()
}
@@ -2756,15 +2919,20 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
}
&--col6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.basic-remark-attach-row {
margin-top: 16px;
}
.form-col-span-2 {
grid-column: 1 / -1;
}
@@ -3143,13 +3311,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--col5 {
&--col5,
&--col6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--col2.basic-remark-attach-row {
grid-template-columns: 1fr;
}
}
.client-sub-cards {
@@ -3157,106 +3322,48 @@
}
}
.installment-advance-ratios-row {
display: flex;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
.installment-amounts-row {
margin-top: 16px;
}
.installment-advance-field {
flex: 0 0 auto;
.installment-amounts-field.el-form-item {
margin-bottom: 0;
}
.installment-ratios-field {
flex: 1 1 360px;
min-width: 0;
.installment-amounts-summary-row {
margin-top: 4px;
}
.installment-ratios-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #c5e0d4;
border-radius: 8px;
background: #f8fafc;
}
.installment-ratios-bar {
display: flex;
height: 8px;
overflow: hidden;
border-radius: 4px;
background: #e2e8f0;
}
.installment-ratios-bar-seg {
background: #047857;
min-width: 2px;
transition: flex 0.2s ease;
&.is-auto {
background: #6bb99a;
}
& + & {
border-left: 1px solid rgba(255, 255, 255, 0.6);
}
}
.installment-ratios-cells {
.installment-amounts-cells {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 12px 16px;
}
.installment-ratio-cell {
.installment-amount-cell {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 72px;
min-width: 72px;
max-width: 96px;
:deep(.el-input__wrapper) {
padding-left: 8px;
padding-right: 4px;
}
:deep(.el-input__suffix) {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
}
&.is-auto :deep(.el-input__wrapper) {
background: #e8f4ee;
}
gap: 6px;
min-width: 120px;
flex: 1 1 120px;
max-width: 180px;
}
.installment-ratio-cell-no {
font-size: 11px;
color: var(--mint-text-muted, #64748b);
line-height: 1.2;
}
.installment-ratios-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.installment-ratios-sum {
.installment-amount-cell-no {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.installment-amounts-sum {
font-size: 13px;
font-weight: 600;
color: #065f46;
font-weight: 500;
&.is-error {
color: #dc2626;
}
}
.form-tip--error {
margin-top: 6px;
color: #dc2626;
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -6,6 +6,8 @@
width="1600px"
class="dialog-form-layout protocol-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -31,43 +33,6 @@
<template v-if="bundle">
<div>
<!-- KPI 摘要条 -->
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">协议状态</div>
<div class="kpi-value">
<el-tag
:type="protocolStatusTagType(String(protocol?.protocolStatus || ''))"
size="small"
>
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
</el-tag>
</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><User /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">母亲姓名</div>
<div class="kpi-value">{{ dash(partyCustomer?.motherName) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Calendar /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">登记日期</div>
<div class="kpi-value">{{ formatYmd(protocol?.registerDate) }}</div>
</div>
</div>
</div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基本信息</h3></div>
@@ -769,9 +734,9 @@
}}</div></div
>
<div class="payment-metric"
><div class="pm-label">缴费比例</div
><div class="pm-label">分期金额(元)</div
><div class="pm-value">{{
formatInstallmentRatios(payment?.installmentRatios)
formatInstallmentAmounts(payment?.installmentRatios)
}}</div></div
>
</template>
@@ -1152,7 +1117,14 @@
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -1172,7 +1144,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Calendar, CircleCheck, Close, User } from '@element-plus/icons-vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
@@ -1467,9 +1439,9 @@
return dash(val)
}
function formatInstallmentRatios(ratios?: number[] | null) {
if (!ratios?.length) return '—'
return ratios.map((r) => `${r}%`).join(' / ')
function formatInstallmentAmounts(values?: number[] | null) {
if (!values?.length) return '—'
return values.map((v) => fmtMoney(v)).join(' / ')
}
function formatYm(val: unknown) {
@@ -2,30 +2,30 @@
<template>
<div class="page-container">
<section class="panel">
<div
class="panel-entity-switch panel-entity-switch--multi"
role="tablist"
aria-label="视图类型"
>
<button
v-for="item in entityTabs"
:key="item.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === item.key }"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="item.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ item.title }}</span>
<span class="entity-switch-card__desc">{{ item.desc }}</span>
</span>
</button>
</div>
<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'" />
@@ -36,8 +36,6 @@
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Box, DocumentChecked, FolderOpened, FirstAidKit } from '@element-plus/icons-vue'
import SampleCollectionInfoTab from '../protocol-audit/SampleCollectionInfoTab.vue'
import SampleReceptionInfoTab from '../protocol-audit/SampleReceptionInfoTab.vue'
import SampleQcInfoTab from '../protocol-audit/SampleQcInfoTab.vue'
@@ -58,37 +56,44 @@
key: EntityTabKey
title: string
desc: string
icon: Component
}> = [
{
key: 'sample-collection',
title: '样本采集信息',
desc: '协议样本采集进度',
icon: FirstAidKit
desc: '协议样本采集进度'
},
{
key: 'sample-reception',
title: '样本接收信息',
desc: '接收结论与短信通知',
icon: Box
desc: '接收结论与短信通知'
},
{
key: 'sample-qc',
title: '样本质检信息',
desc: '质检结论与短信通知',
icon: DocumentChecked
desc: '质检结论与短信通知'
},
{
key: 'storage-entry',
title: '样本入库信息',
desc: '样本入库记录查询',
icon: FolderOpened
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)) {
@@ -98,106 +103,139 @@
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.panel-entity-switch--multi {
flex-wrap: wrap;
gap: 10px;
.entity-switch-card {
flex: 0 1 168px;
min-width: 152px;
max-width: 180px;
padding: 10px 12px;
gap: 8px;
}
.entity-switch-card__icon {
width: 32px;
height: 32px;
font-size: 17px;
}
.entity-switch-card__title {
font-size: 12px;
}
.entity-switch-card__desc {
font-size: 11px;
}
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border: 1px solid var(--mint-border, #c5e0d4);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a8d4c4;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #047857);
background: var(--primary-bg, #e8f4ee);
box-shadow: 0 0 0 1px rgb(4 120 87 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #065f46);
}
}
.entity-switch-card__main {
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
align-items: center;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.entity-switch-card.is-active & {
color: var(--primary-dark, #065f46);
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -237,9 +237,15 @@
<el-form-item label="细胞产品类型" prop="cellProductTypeId">
<el-select
v-model="formData.cellProductTypeId"
placeholder="请选择细胞产品类型"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="typeOptionsLoading"
:remote-method="remoteSearchTypeOptions"
@visible-change="onTypeSelectVisible"
@change="handleTypeChange"
>
<el-option
@@ -253,9 +259,15 @@
<el-form-item label="规格" prop="specId">
<el-select
v-model="formData.specId"
placeholder="请选择规格"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="specOptionsLoading"
:remote-method="remoteSearchSpecOptions"
@visible-change="onSpecSelectVisible"
@change="handleSpecChange"
>
<el-option
@@ -356,6 +368,8 @@
// --- Dropdown options ---
const typeOptions = ref<any[]>([])
const specOptions = ref<any[]>([])
const typeOptionsLoading = ref(false)
const specOptionsLoading = ref(false)
// --- Form data ---
const formData = reactive({
@@ -403,13 +417,56 @@
})
// --- Load options ---
async function loadOptions() {
const [typeRes, specRes] = await Promise.all([
fetchCellProductTypeList({ page: 1, pageSize: 100 }),
fetchCellProductSpecList({ page: 1, pageSize: 100 })
])
typeOptions.value = (typeRes as any)?.list || []
specOptions.value = (specRes as any)?.list || []
async function remoteSearchTypeOptions(query: string) {
typeOptionsLoading.value = true
try {
const typeRes = await fetchCellProductTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
typeOptions.value = (typeRes as any)?.list || typeRes || []
} catch {
typeOptions.value = []
} finally {
typeOptionsLoading.value = false
}
}
function ensureTypeInOptions(id: string, name?: string) {
if (!id) return
if (typeOptions.value.some((o) => o.id === id)) return
typeOptions.value = [{ id, productTypeName: name || id }, ...typeOptions.value]
}
async function remoteSearchSpecOptions(query: string) {
specOptionsLoading.value = true
try {
const specRes = await fetchCellProductSpecList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
specOptions.value = (specRes as any)?.list || specRes || []
} catch {
specOptions.value = []
} finally {
specOptionsLoading.value = false
}
}
function ensureSpecInOptions(id: string, name?: string) {
if (!id) return
if (specOptions.value.some((o) => o.id === id)) return
specOptions.value = [{ id, specName: name || id }, ...specOptions.value]
}
function onTypeSelectVisible(visible: boolean) {
if (visible) void remoteSearchTypeOptions('')
}
function onSpecSelectVisible(visible: boolean) {
if (visible) void remoteSearchSpecOptions('')
}
// --- Load data ---
@@ -482,7 +539,7 @@
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadOptions()
await Promise.all([remoteSearchTypeOptions(''), remoteSearchSpecOptions('')])
}
// --- Edit ---
@@ -499,9 +556,11 @@
formData.specText = row.specText || ''
formData.unitPrice = row.unitPrice ?? null
formData.remark = row.remark || ''
ensureTypeInOptions(row.cellProductTypeId, row.cellTypeName)
ensureSpecInOptions(row.specId, row.specText)
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadOptions()
await Promise.all([remoteSearchTypeOptions(''), remoteSearchSpecOptions('')])
}
// --- Submit ---
@@ -990,7 +990,13 @@
<el-select
v-model="selectedProjectSystemId"
class="price-selector-system-select"
placeholder="请选择项目价格体系"
placeholder="输入名称检索项目价格体系"
filterable
remote
reserve-keyword
:loading="projectSystemListLoading"
:remote-method="remoteSearchProjectSystems"
@visible-change="onProjectSystemSelectVisible"
@change="handleProjectSystemChange"
>
<el-option
@@ -1454,6 +1460,7 @@
const priceSelectorSelected = ref<any[]>([])
const priceSelectorTableRef = ref()
const projectSystemList = ref<any[]>([])
const projectSystemListLoading = ref(false)
const selectedProjectSystemId = ref<string>('')
const priceSelectorTitle = ref('从价格体系添加')
@@ -2216,6 +2223,26 @@
}
}
async function remoteSearchProjectSystems(query: string) {
projectSystemListLoading.value = true
try {
const res = await fetchProjectSystemList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
projectSystemList.value = (res as any)?.list || []
} catch {
projectSystemList.value = []
} finally {
projectSystemListLoading.value = false
}
}
function onProjectSystemSelectVisible(visible: boolean) {
if (visible) void remoteSearchProjectSystems('')
}
async function openProjectPriceSelector() {
priceSelectorVisible.value = true
priceSelectorSelected.value = []
@@ -2223,8 +2250,7 @@
priceSelectorKeyword.value = ''
selectedProjectSystemId.value = ''
try {
const res = await fetchProjectSystemList({ page: 1, pageSize: 100 })
projectSystemList.value = (res as any)?.list || []
await remoteSearchProjectSystems('')
const first = projectSystemList.value[0]
if (first?.id) {
selectedProjectSystemId.value = first.id
@@ -18,9 +18,15 @@
<el-form-item label="体检机构" required>
<el-select
v-model="formData.checkupInstitutionId"
placeholder="请选择体检机构"
placeholder="输入机构名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="institutionOptionsLoading"
:remote-method="remoteSearchInstitutions"
@visible-change="onInstitutionSelectVisible"
@change="onInstitutionChange"
>
<el-option
@@ -398,15 +404,35 @@
// ── 体检机构 ──────────────────────────
const institutionOptions = ref<any[]>([])
const loadInstitutions = async () => {
const institutionOptionsLoading = ref(false)
function ensureInstitutionInOptions(id: string, name?: string) {
if (!id) return
if (institutionOptions.value.some((o) => o.id === id)) return
institutionOptions.value = [{ id, institutionName: name || id }, ...institutionOptions.value]
}
async function remoteSearchInstitutions(query: string) {
institutionOptionsLoading.value = true
try {
const res = await fetchCheckupInstitutionList({ page: 1, pageSize: 100, status: '1' })
const res = await fetchCheckupInstitutionList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
institutionOptions.value = parseList(res)
} catch {
institutionOptions.value = []
} finally {
institutionOptionsLoading.value = false
}
}
function onInstitutionSelectVisible(visible: boolean) {
if (visible) void remoteSearchInstitutions('')
}
// ── 表单数据 ──────────────────────────
const formData = reactive({
checkupInstitutionId: '',
@@ -620,7 +646,8 @@
try {
formData.checkupInstitutionId = row.checkupInstitutionId || ''
formData.reserveTime = row.reserveTime || ''
await loadInstitutions()
ensureInstitutionInOptions(row.checkupInstitutionId, row.checkupInstitutionName)
await remoteSearchInstitutions('')
if (formData.checkupInstitutionId) {
await loadInstitutionCatalog(formData.checkupInstitutionId)
}
@@ -655,7 +682,7 @@
() => props.modelValue,
(val) => {
if (!val) return
loadInstitutions()
void remoteSearchInstitutions('')
if (isEditMode.value) {
loadEditData()
} else {
@@ -167,9 +167,15 @@
<el-form-item label="体检机构" required>
<el-select
v-model="formData.checkupInstitutionId"
placeholder="请选择体检机构"
placeholder="输入机构名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="institutionOptionsLoading"
:remote-method="remoteSearchInstitutions"
@visible-change="onInstitutionSelectVisible"
@change="onInstitutionChange"
>
<el-option
@@ -579,15 +585,35 @@
// ── 体检机构 ──────────────────────────
const institutionOptions = ref<any[]>([])
const loadInstitutions = async () => {
const institutionOptionsLoading = ref(false)
function ensureInstitutionInOptions(id: string, name?: string) {
if (!id) return
if (institutionOptions.value.some((o) => o.id === id)) return
institutionOptions.value = [{ id, institutionName: name || id }, ...institutionOptions.value]
}
async function remoteSearchInstitutions(query: string) {
institutionOptionsLoading.value = true
try {
const res = await fetchCheckupInstitutionList({ page: 1, pageSize: 100, status: '1' })
const res = await fetchCheckupInstitutionList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
institutionOptions.value = parseList(res)
} catch {
institutionOptions.value = []
} finally {
institutionOptionsLoading.value = false
}
}
function onInstitutionSelectVisible(visible: boolean) {
if (visible) void remoteSearchInstitutions('')
}
// ── 表单数据 ──────────────────────────
const formData = reactive({
checkupInstitutionId: '',
@@ -832,7 +858,7 @@
} else {
searchPatients()
}
loadInstitutions()
void remoteSearchInstitutions('')
}
}
)
@@ -2,46 +2,160 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="refund-confirm-tab">
<el-tabs v-model="activeTab" class="panel-pill-tabs" @tab-change="handleStatusTabChange">
<el-tab-pane label="待确认" name="pending" />
<el-tab-pane label="已确认" name="confirmed" />
</el-tabs>
<div class="finance-section-head finance-section-head--pending">
<h3 class="finance-section-title">
待确认
<span v-if="pendingPagination.total > 0" class="finance-section-count">{{
pendingPagination.total
}}</span>
</h3>
</div>
<div class="refund-confirm-body">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="申请单号/患者姓名"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<div class="filter-block">
<el-form :model="pendingSearchForm" class="filter-form" label-position="top" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="pendingSearchForm.keyword"
:placeholder="keywordPlaceholder"
clearable
@keyup.enter="handlePendingSearch"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">{{ filterHint }}</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handlePendingReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handlePendingSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持按申请单号、患者姓名模糊检索。</span>
<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>
</el-form>
</div>
<div v-loading="pendingLoading" class="pending-card-section">
<div v-if="pendingData.length" class="pending-card-grid">
<article v-for="row in pendingData" :key="String(row.id)" class="pending-card">
<div class="pending-card__head">
<div class="pending-card__identity">
<div class="pending-card__name">{{ pendingCardTitle(row) }}</div>
<div class="pending-card__sub">
{{ row.applicationNo || '-' }}
<template v-if="isBasicExperiment && row.customerName">
· {{ row.customerName }}
</template>
<template v-else-if="!isBasicExperiment && row.channelName">
· {{ row.channelName }}
</template>
</div>
</div>
<div class="pending-card__amount">
<div class="pending-card__amount-val pending-card__amount-val--refund">
{{ formatAmount(row.refundAmount) }}
</div>
<div class="pending-card__amount-label">退费金额</div>
</div>
</div>
</el-form>
<div class="pending-card__grid">
<div class="pending-card__field">
<label>缴费金额</label>
<span>{{ formatAmount(row.paymentAmount) }}</span>
</div>
<div class="pending-card__field">
<label>细胞类型/规格</label>
<span>{{ row.cellTypeSpec || '-' }}</span>
</div>
<div class="pending-card__field">
<label>{{ pickupTimeLabel }}</label>
<span>{{ row.infusionTime || '-' }}</span>
</div>
</div>
<div class="pending-card__foot">
<div class="pending-card__meta">
<el-tag type="warning" size="small">退费确认中</el-tag>
<span>提交 {{ row.createTime || '-' }}</span>
</div>
<el-button class="btn-op-item primary" @click="openConfirm(row)">
<el-icon><Check /></el-icon>确认退费
</el-button>
</div>
</article>
</div>
<div v-else-if="!pendingLoading" class="finance-empty">暂无待确认记录</div>
<div v-if="pendingPagination.total > 0" class="pagination list-pager-bar pending-pager">
<el-pagination
:current-page="pendingPagination.page"
:page-size="pendingPagination.pageSize"
:total="pendingPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(val) => (
(pendingPagination.page = 1),
(pendingPagination.pageSize = val),
loadPending()
)
"
@current-change="(val) => ((pendingPagination.page = val), loadPending())"
/>
</div>
</div>
<div class="finance-section-divider" />
<div class="finance-section-head">
<h3 class="finance-section-title">
已确认
<span v-if="confirmedPagination.total > 0" class="finance-section-count">{{
confirmedPagination.total
}}</span>
</h3>
</div>
<div class="filter-block">
<el-form
:model="confirmedSearchForm"
class="filter-form"
label-position="top"
@submit.prevent
>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="confirmedSearchForm.keyword"
:placeholder="keywordPlaceholder"
clearable
@keyup.enter="handleConfirmedSearch"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">{{ filterHint }}</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleConfirmedReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleConfirmedSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="refund-confirm-body">
<div class="list-body list-wrap refund-confirm-table-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
:data="confirmedData"
v-loading="confirmedLoading"
stripe
row-key="id"
@row-dblclick="handleRowDblClick"
@row-dblclick="openViewConfirm"
>
<el-table-column
type="index"
@@ -49,7 +163,9 @@
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
:index="
(index) => (confirmedPagination.page - 1) * confirmedPagination.pageSize + index + 1
"
/>
<el-table-column
prop="applicationNo"
@@ -146,13 +262,10 @@
header-align="center"
>
<template #default>
<el-tag :type="activeTab === 'pending' ? 'warning' : 'success'" size="small">
{{ activeTab === 'pending' ? '退费确认中' : '已退费' }}
</el-tag>
<el-tag type="success" size="small">已退费</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="updateTime"
label="确认时间"
min-width="170"
@@ -160,7 +273,6 @@
header-align="center"
/>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="refundFinanceRemark"
label="财务备注"
min-width="120"
@@ -170,30 +282,15 @@
>
<template #default="{ row }">{{ row.refundFinanceRemark || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'pending'"
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="140"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openConfirm(row)"
>
<el-icon><Check /></el-icon>确认退费
</el-button>
<el-button v-else class="btn-op-item" @click="openViewConfirm(row)">
<el-button class="btn-op-item" @click="openViewConfirm(row)">
<el-icon><View /></el-icon>查看确认信息
</el-button>
</template>
@@ -201,13 +298,19 @@
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:current-page="confirmedPagination.page"
:page-size="confirmedPagination.pageSize"
:total="confirmedPagination.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())"
@size-change="
(val) => (
(confirmedPagination.page = 1),
(confirmedPagination.pageSize = val),
loadConfirmed()
)
"
@current-change="(val) => ((confirmedPagination.page = val), loadConfirmed())"
/>
</div>
</div>
@@ -219,6 +322,8 @@
width="900px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -319,6 +424,8 @@
width="900px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<ClinicalPaymentSubmitReadonly
@@ -386,18 +493,31 @@
defineOptions({ name: 'ClinicalRefundConfirmTab' })
const emit = defineEmits<{ actionDone: [] }>()
const props = defineProps<{ bizType?: string }>()
const bizConfig = computed(() => getPilotBizConfig(props.bizType))
const isBasicExperiment = computed(
() => resolvePilotBizType(props.bizType) === 'basic_experiment'
)
const pickupTimeLabel = computed(() => bizConfig.value.pickupTimeLabel)
const filterHint = computed(() =>
isBasicExperiment.value
? '支持按申请单号、课题名称、客户名称模糊检索。'
: '支持按申请单号、患者姓名模糊检索。'
)
const keywordPlaceholder = computed(() =>
isBasicExperiment.value ? '申请单号/课题名称/客户名称' : '申请单号/患者姓名'
)
const activeTab = ref('pending')
const searchForm = reactive({ keyword: '' })
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pendingSearchForm = reactive({ keyword: '' })
const confirmedSearchForm = reactive({ keyword: '' })
const pendingData = ref<Record<string, unknown>[]>([])
const confirmedData = ref<Record<string, unknown>[]>([])
const pendingLoading = ref(false)
const confirmedLoading = ref(false)
const pendingPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const confirmedPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
@@ -456,40 +576,77 @@
window.open(fullFileUrl(url), '_blank')
}
async function loadData() {
loading.value = true
try {
const params = {
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
bizType: resolvePilotBizType(props.bizType)
}
const res =
activeTab.value === 'pending'
? await fetchClinicalRefundPending(params)
: await fetchClinicalRefundConfirmed(params)
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
function pendingCardTitle(row: Record<string, unknown>) {
if (isBasicExperiment.value) {
return String(row.projectTitle || row.applicationNo || '-')
}
return String(row.patientName || row.applicationNo || '-')
}
function buildPendingParams() {
return {
page: pendingPagination.page,
pageSize: pendingPagination.pageSize,
keyword: pendingSearchForm.keyword || undefined,
bizType: resolvePilotBizType(props.bizType)
}
}
function handleStatusTabChange() {
pagination.page = 1
loadData()
function buildConfirmedParams() {
return {
page: confirmedPagination.page,
pageSize: confirmedPagination.pageSize,
keyword: confirmedSearchForm.keyword || undefined,
bizType: resolvePilotBizType(props.bizType)
}
}
function handleSearch() {
pagination.page = 1
loadData()
async function loadPending() {
pendingLoading.value = true
try {
const res = await fetchClinicalRefundPending(buildPendingParams())
pendingData.value = res.list || []
pendingPagination.total = res.total ?? 0
} finally {
pendingLoading.value = false
}
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
async function loadConfirmed() {
confirmedLoading.value = true
try {
const res = await fetchClinicalRefundConfirmed(buildConfirmedParams())
confirmedData.value = res.list || []
confirmedPagination.total = res.total ?? 0
} finally {
confirmedLoading.value = false
}
}
function loadAll() {
return Promise.all([loadPending(), loadConfirmed()])
}
function handlePendingSearch() {
pendingPagination.page = 1
loadPending()
}
function handlePendingReset() {
pendingSearchForm.keyword = ''
pendingPagination.page = 1
loadPending()
}
function handleConfirmedSearch() {
confirmedPagination.page = 1
loadConfirmed()
}
function handleConfirmedReset() {
confirmedSearchForm.keyword = ''
confirmedPagination.page = 1
loadConfirmed()
}
function openConfirm(row: Record<string, unknown>) {
@@ -505,10 +662,6 @@
viewDialogVisible.value = true
}
function handleRowDblClick(row: Record<string, unknown>) {
if (activeTab.value !== 'pending') openViewConfirm(row)
}
async function handleConfirmAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
@@ -561,27 +714,215 @@
refundFinanceRemark: confirmForm.financeRemark.trim() || undefined
})
dialogVisible.value = false
loadData()
await loadAll()
emit('actionDone')
} finally {
submitLoading.value = false
}
}
watchDebounced(
[searchForm],
[pendingSearchForm],
() => {
pagination.page = 1
loadData()
pendingPagination.page = 1
loadPending()
},
{ debounce: 300, deep: true }
)
watchDebounced(
[confirmedSearchForm],
() => {
confirmedPagination.page = 1
loadConfirmed()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
loadAll()
})
</script>
<style scoped lang="scss">
.finance-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px 0;
}
.finance-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
&::before {
content: '';
width: 4px;
height: 12px;
border-radius: 999px;
background: var(--primary-dark, #059669);
}
}
.finance-section-head--pending .finance-section-title::before {
background: #d97706;
}
.finance-section-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #f1f5f9;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
.finance-section-head--pending .finance-section-count {
background: #fffbeb;
color: #d97706;
}
.finance-section-divider {
height: 8px;
margin: 8px 0 0;
background: #f8fafc;
border-top: 1px solid var(--mint-border, #e2e8f0);
border-bottom: 1px solid var(--mint-border, #e2e8f0);
}
.pending-card-section {
min-height: 80px;
padding: 0 0 4px;
}
.pending-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
padding: 12px 24px 0;
}
.pending-card {
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
padding: 16px 18px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c5e0d4;
box-shadow: 0 2px 8px rgb(15 23 42 / 5%);
}
}
.pending-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.pending-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
}
.pending-card__sub {
margin-top: 2px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.pending-card__amount {
flex-shrink: 0;
text-align: right;
}
.pending-card__amount-val {
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #059669);
font-variant-numeric: tabular-nums;
line-height: 1.3;
&--refund {
color: #d97706;
}
}
.pending-card__amount-label {
margin-top: 2px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.pending-card__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
margin-bottom: 14px;
}
.pending-card__field label {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
}
.pending-card__field span {
font-size: 13px;
color: var(--text, #1e293b);
line-height: 1.4;
}
.pending-card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.pending-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.pending-pager {
padding: 0 24px;
}
.finance-empty {
padding: 32px 24px;
text-align: center;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
File diff suppressed because it is too large. Load diff
@@ -510,7 +510,7 @@
patientName: props.scheduleRow?.patientName,
infusionTime: props.scheduleRow?.infusionTime
}
await materialSectionsRef.value?.ensureMaterialOptions()
await materialSectionsRef.value?.remoteSearchMaterials('')
await materialSectionsRef.value?.loadFromApiData({
remark: data.outboundMaterial?.remark,
cells: data.outboundMaterial?.cells as any,
@@ -116,8 +116,14 @@
<el-select
v-model="row.materialId"
filterable
placeholder="搜索并选择物料"
remote
reserve-keyword
clearable
placeholder="输入物料名称检索"
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
class="inner-cell-input"
@visible-change="(visible) => visible && remoteSearchMaterials('')"
@change="(v) => onMaterialSelect($index, v)"
>
<el-option
@@ -440,7 +446,7 @@
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { fetchCellProductList } from '@/api/cell-product'
import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
import { fetchQcMaterialList, type QcMaterialOption } from '@/api/qc-material'
import {
fetchOutboundReleaseMaterial,
saveOutboundReleaseMaterial,
@@ -506,6 +512,7 @@
const materialRows = ref<MaterialRow[]>([])
const paperRows = ref<PaperRow[]>([])
const materialOptions = ref<QcMaterialOption[]>([])
const materialOptionsLoading = ref(false)
const cellSelectVisible = ref(false)
const cellSearchKeyword = ref('')
@@ -559,6 +566,54 @@
)
}
function mapMaterialListToOptions(list: Array<Record<string, any>>): QcMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
function ensureMaterialsInOptions(rows: MaterialRow[]) {
const merged = [...materialOptions.value]
for (const row of rows) {
const id = String(row.materialId || '')
if (!id || merged.some((o) => o.id === id)) continue
merged.push({
id,
label: row.materialName || id,
materialCode: '',
materialName: row.materialName || '',
specModel: row.specModel || '',
unit: row.unit || '',
stockQty: 0
})
}
materialOptions.value = merged
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchQcMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
materialOptions.value = mapMaterialListToOptions(res.list || [])
ensureMaterialsInOptions(materialRows.value)
} catch {
materialOptions.value = []
ensureMaterialsInOptions(materialRows.value)
} finally {
materialOptionsLoading.value = false
}
}
function stockQtyDisplay(materialId: string) {
const opt = materialOptions.value.find((o) => o.id === materialId)
if (!opt || opt.stockQty == null) return '—'
@@ -733,11 +788,7 @@
if (!props.scheduleId) return
loading.value = true
try {
const [data, opts] = await Promise.all([
fetchOutboundReleaseMaterial(props.scheduleId),
fetchQcMaterialOptions().catch(() => [] as QcMaterialOption[])
])
materialOptions.value = opts || []
const data = await fetchOutboundReleaseMaterial(props.scheduleId)
formRemark.value = data.remark || ''
cellRows.value = (data.cells || []).map((c) => ({ ...c }))
materialRows.value = (data.qcMaterials || []).map((m) => ({
@@ -750,6 +801,7 @@
remark: m.remark || ''
}))
paperRows.value = mapPaperRowsFromApi(data.paperMaterials, data.paperAttachments)
await remoteSearchMaterials('')
} finally {
loading.value = false
}
@@ -25,9 +25,15 @@
<el-form-item label="体检机构">
<el-select
v-model="searchForm.checkupInstitutionId"
placeholder="全部"
placeholder="输入机构名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="institutionOptionsLoading"
:remote-method="remoteSearchInstitutions"
@visible-change="onInstitutionSelectVisible"
>
<el-option
v-for="inst in institutionOptions"
@@ -303,8 +309,6 @@
checkupInstitutionId: ''
})
const institutionOptions = ref<any[]>([])
// ── 表格 ─────────────────────────────
const loading = ref(false)
const tableData = ref<any[]>([])
@@ -353,15 +357,30 @@
}
}
const loadInstitutions = async () => {
const institutionOptions = ref<any[]>([])
const institutionOptionsLoading = ref(false)
async function remoteSearchInstitutions(query: string) {
institutionOptionsLoading.value = true
try {
const res: any = await fetchCheckupInstitutionList({ page: 1, pageSize: 100, status: '1' })
const res: any = await fetchCheckupInstitutionList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
institutionOptions.value = Array.isArray(res) ? res : (res?.list ?? [])
} catch {
institutionOptions.value = []
} finally {
institutionOptionsLoading.value = false
}
}
function onInstitutionSelectVisible(visible: boolean) {
if (visible) void remoteSearchInstitutions('')
}
// ── 事件 ──────────────────────────────
const handleSearch = () => {
pagination.page = 1
@@ -465,7 +484,7 @@
onMounted(() => {
loadData()
loadInstitutions()
void remoteSearchInstitutions('')
})
</script>
@@ -253,9 +253,15 @@
<el-form-item label="产品类型" prop="productTypeId">
<el-select
v-model="formData.productTypeId"
placeholder="请选择产品类型"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="item in productTypeOptions"
@@ -268,9 +274,15 @@
<el-form-item label="规格" prop="specId">
<el-select
v-model="formData.specId"
placeholder="请选择规格"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="specOptionsLoading"
:remote-method="remoteSearchSpecs"
@visible-change="onSpecSelectVisible"
>
<el-option
v-for="item in specOptions"
@@ -296,9 +308,15 @@
<el-form-item v-if="isEdit" label="储物格位置" prop="storageSlotId">
<el-select
v-model="formData.storageSlotId"
placeholder="请选择储物格"
placeholder="输入编号检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="storageSlotOptionsLoading"
:remote-method="remoteSearchStorageSlots"
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotOptions"
@@ -475,10 +493,15 @@
<template #default="{ row }">
<el-select
v-model="row.storageSlotId"
placeholder="请选择储物格"
placeholder="输入编号检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="storageSlotOptionsLoading"
:remote-method="remoteSearchStorageSlots"
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotOptions"
@@ -604,11 +627,13 @@
import {
fetchCellProductTypeList,
fetchCellProductSpecList,
fetchStorageFacilityList,
fetchStorageSlotList,
fetchItemUnitList
} from '@/api/admin'
import { loadStorageSlotSelectOptions, validateUniqueStorageSlotIds } from '@/utils/storage-slot-options'
import {
remoteSearchStorageSlotSelectOptions,
ensureStorageSlotsInOptions,
validateUniqueStorageSlotIds
} from '@/utils/storage-slot-options'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
@@ -678,9 +703,13 @@
const viewRow = ref<any>(null)
/* ---- 下拉选项 ---- */
const productTypeOptions = ref<Array<{ id: string; name: string }>>([])
const specOptions = ref<Array<{ id: string; name: string }>>([])
const productTypeOptions = ref<Array<{ id: string; productTypeName: string }>>([])
const specOptions = ref<Array<{ id: string; specName: string }>>([])
const storageSlotOptions = ref<Array<{ id: string; name: string }>>([])
const productTypeOptionsLoading = ref(false)
const specOptionsLoading = ref(false)
const storageSlotOptionsLoading = ref(false)
const storageSlotSearchOpts = ref<{ includeSlotIds?: string[] }>({})
/* ---- 编辑时状态选项 ---- */
const editStatusOptions = [
@@ -926,33 +955,77 @@
}
}
const facilityFlatList = ref<any[]>([])
async function loadStorageSlotOptions(opts?: { includeSlotIds?: string[] }) {
storageSlotOptions.value = await loadStorageSlotSelectOptions(
facilityFlatList.value,
fetchStorageSlotList,
{ availableOnly: true, includeSlotIds: opts?.includeSlotIds }
)
async function remoteSearchProductTypes(query: string) {
productTypeOptionsLoading.value = true
try {
const res = await fetchCellProductTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
productTypeOptions.value = res?.list || res || []
} catch {
productTypeOptions.value = []
} finally {
productTypeOptionsLoading.value = false
}
}
async function loadDropdownData() {
function ensureProductTypeInOptions(id: string, name?: string) {
if (!id) return
if (productTypeOptions.value.some((o) => o.id === id)) return
productTypeOptions.value = [{ id, productTypeName: name || id }, ...productTypeOptions.value]
}
async function remoteSearchSpecs(query: string) {
specOptionsLoading.value = true
try {
const [typeRes, specRes, facilityRes] = await Promise.all([
fetchCellProductTypeList({ page: 1, pageSize: 100 }),
fetchCellProductSpecList({ page: 1, pageSize: 100 }),
fetchStorageFacilityList()
])
productTypeOptions.value = typeRes?.list || typeRes || []
specOptions.value = specRes?.list || specRes || []
const facilities = facilityRes?.list || facilityRes || []
facilityFlatList.value = facilities
await loadStorageSlotOptions()
const res = await fetchCellProductSpecList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
specOptions.value = res?.list || res || []
} catch {
/* 下拉数据加载失败不阻塞页面 */
specOptions.value = []
} finally {
specOptionsLoading.value = false
}
}
function ensureSpecInOptions(id: string, name?: string) {
if (!id) return
if (specOptions.value.some((o) => o.id === id)) return
specOptions.value = [{ id, specName: name || id }, ...specOptions.value]
}
async function remoteSearchStorageSlots(query: string) {
storageSlotOptionsLoading.value = true
try {
const list = await remoteSearchStorageSlotSelectOptions(query, {
availableOnly: true,
includeSlotIds: storageSlotSearchOpts.value.includeSlotIds
})
storageSlotOptions.value = list
} catch {
storageSlotOptions.value = []
} finally {
storageSlotOptionsLoading.value = false
}
}
function onStorageSlotSelectVisible(visible: boolean) {
if (visible) void remoteSearchStorageSlots('')
}
function onProductTypeSelectVisible(visible: boolean) {
if (visible) void remoteSearchProductTypes('')
}
function onSpecSelectVisible(visible: boolean) {
if (visible) void remoteSearchSpecs('')
}
function handleSearch() {
pagination.page = 1
loadData()
@@ -1092,7 +1165,10 @@
dialogTitle.value = '新增产品'
resetForm()
formData.quantity = 1
await loadStorageSlotOptions()
storageSlotSearchOpts.value = {}
await remoteSearchStorageSlots('')
void remoteSearchProductTypes('')
void remoteSearchSpecs('')
dialogVisible.value = true
void searchUnitOptions('')
formRef.value?.clearValidate?.()
@@ -1122,9 +1198,17 @@
formData.passage = row.passage || ''
formData.batchNo = row.batchNo || ''
formData.storageSlotId = row.storageSlotId || ''
await loadStorageSlotOptions({
storageSlotSearchOpts.value = {
includeSlotIds: row.storageSlotId ? [row.storageSlotId] : undefined
})
}
ensureProductTypeInOptions(row.productTypeId, row.productTypeName)
ensureSpecInOptions(row.specId, row.specName)
storageSlotOptions.value = ensureStorageSlotsInOptions([], [
{ id: row.storageSlotId, name: row.storageSlotName }
])
await remoteSearchStorageSlots('')
void remoteSearchProductTypes('')
void remoteSearchSpecs('')
formData.expectedCellCount = row.expectedCellCount ?? undefined
formData.viability = row.viability ?? undefined
formData.freezeDate = row.freezeDate || ''
@@ -1247,7 +1331,6 @@
onMounted(() => {
loadData()
loadDropdownData()
})
</script>
@@ -501,6 +501,7 @@
ShoppingBag
} from '@element-plus/icons-vue'
import { watchDebounced } from '@vueuse/core'
import { useUserStore } from '@/store/modules/user'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { garmentStatusLabel, garmentStatusTagType } from '@/utils/status-tag-registry'
@@ -457,7 +457,17 @@
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
</el-form-item>
<el-form-item label="单位">
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
<el-select
v-model="formData.unit"
placeholder="输入单位检索"
clearable
filterable
remote
reserve-keyword
:loading="unitOptionsLoading"
:remote-method="remoteSearchUnits"
@visible-change="onUnitSelectVisible"
>
<el-option
v-for="item in unitOptions"
:key="item.value"
@@ -1177,6 +1187,7 @@
const activeTab = ref('list')
const tableRef = ref()
const unitOptions = ref<{ label: string; value: string }[]>([])
const unitOptionsLoading = ref(false)
const searchForm = reactive({ keyword: '', status: '' })
const tableData = ref<any[]>([])
@@ -1496,6 +1507,37 @@
loadData()
}
function ensureUnitInOptions(unitName: string) {
const n = (unitName || '').trim()
if (!n || unitOptions.value.some((o) => o.value === n)) return
unitOptions.value = [{ label: n, value: n }, ...unitOptions.value]
}
async function remoteSearchUnits(query: string) {
unitOptionsLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
unitOptions.value = (res.list || []).map((u: any) => ({
label: u.unitName,
value: u.unitName
}))
ensureUnitInOptions(formData.unit)
} catch {
unitOptions.value = []
ensureUnitInOptions(formData.unit)
} finally {
unitOptionsLoading.value = false
}
}
function onUnitSelectVisible(visible: boolean) {
if (visible) void remoteSearchUnits('')
}
function resetForm() {
formData.consumableCode = ''
formData.consumableName = ''
@@ -1515,6 +1557,7 @@
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
void remoteSearchUnits('')
}
function handleEdit(row: any) {
@@ -1532,6 +1575,7 @@
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
void remoteSearchUnits('')
}
async function handleSubmit() {
@@ -1979,8 +2023,6 @@
activeTab.value = tab
}
loadData()
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
})
</script>
@@ -1,7 +1,8 @@
<!-- 生产管理 — 我的生产任务 -->
<!-- 生产管理 — 我的生产任务(复制生产工单页改造:三 Tab + 完整筛选,仅展示本人任务) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div ref="pageRef" class="page-container my-production-task-page" :style="pageStyle">
<section class="panel my-production-task-panel">
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的生产任务</h2>
</div>
@@ -9,14 +10,14 @@
<div class="panel-pill-tabs">
<div class="panel-pill-tabs__track" role="tablist">
<button
v-for="tab in TASK_TABS"
v-for="tab in WORK_ORDER_TYPE_TABS"
:key="tab.name"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': activeTab === tab.name }"
:aria-selected="activeTab === tab.name"
@click="switchTab(tab.name)"
:class="{ 'is-active': activeTypeTab === tab.name }"
:aria-selected="activeTypeTab === tab.name"
@click="handleTabChange(tab.name)"
>
{{ tab.label }}
</button>
@@ -24,33 +25,88 @@
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
<p class="filter-hint">{{ filterHint }}</p>
<div class="filter-grid my-production-task-filter-main-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="任务号 / 任务名称 / 工单号 / 样本编码"
:placeholder="
activeTypeTab === 'temporary'
? '工单号'
: activeTypeTab === 'outbound'
? '工单号/申请单号/申请单名称'
: '工单号/样本编码/入库编码'
"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="工单状态">
<el-select
v-model="searchForm.workOrderStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="待配置" value="dpz" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
<el-option label="已作废" value="yzf" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.taskStatus" clearable placeholder="全部">
<el-select
v-model="searchForm.taskStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="待配置" value="dpz" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
</el-select>
</el-form-item>
<el-form-item label="工单类型">
<el-select v-model="searchForm.workOrderType" clearable placeholder="全部">
<el-option label="入库生产" value="inbound" />
<el-option label="出库生产" value="outbound" />
<el-option label="临时生产" value="temporary" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">{{ filterHint }}</span>
<div v-show="filterExpanded" id="my-production-task-filter-extra" class="filter-extra">
<div class="filter-grid my-production-task-filter-extra-grid">
<el-form-item v-if="activeTypeTab === 'outbound'" label="业务类型">
<el-select v-model="searchForm.bizType" placeholder="全部" clearable style="width: 100%">
<el-option label="中试服务" value="pilot_service" />
<el-option label="临床应用" value="clinical_application" />
<el-option label="基础实验" value="basic_experiment" />
</el-select>
</el-form-item>
<el-form-item :label="filterTimeLabel">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
</div>
<div class="filter-actions">
<el-button
link
type="primary"
class="btn-filter-toggle"
:aria-expanded="String(filterExpanded)"
aria-controls="my-production-task-filter-extra"
@click="filterExpanded = !filterExpanded"
>
<el-icon class="btn-filter-toggle-ic">
<ArrowDown v-if="!filterExpanded" />
<ArrowUp v-else />
</el-icon>
{{ filterExpanded ? '收起更多条件' : '展开更多条件' }}
</el-button>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
@@ -63,166 +119,255 @@
</el-form>
</div>
<div class="list-body list-wrap my-production-task-list">
<WorkOrderTaskSubList
:list-mode="activeTab === 'current' ? 'mine-current' : 'mine-history'"
:mine-filters="appliedFilters"
:page="pagination.page"
:page-size="pagination.pageSize"
:refresh-token="refreshToken"
embedded
@update:total="pagination.total = $event"
/>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[30, 60, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<ProductionWorkOrderCardList
page-mode="mine"
:rows="tableData"
:loading="loading"
:active-type-tab="activeTypeTab"
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:task-refresh-token="taskSubListRefreshToken"
:preset-tasks-by-work-order-id="presetTasksByWorkOrderId"
@view="handleView"
@tasks-changed="handleWorkOrderTasksChanged"
@page-change="onWorkOrderPageChange"
@page-size-change="onWorkOrderPageSizeChange"
/>
<ProductionWorkOrderViewDialogHost ref="viewDialogHostRef" />
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import WorkOrderTaskSubList from '../production-work-order/WorkOrderTaskSubList.vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Search, RefreshLeft, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import type { ProductionWorkOrderItem } from '@/api/production-work-order'
import {
fetchAllMineProductionTasks,
groupMineTasksByWorkOrder,
enrichMineEntriesWithWorkOrders,
filterMineEntriesByWorkOrderSearch,
paginateMineEntries,
buildMineTableRows
} from '@/utils/production-mine-task-groups'
import ProductionWorkOrderCardList from '../production-work-order/ProductionWorkOrderCardList.vue'
import ProductionWorkOrderViewDialogHost from '../production-work-order/ProductionWorkOrderViewDialogHost.vue'
defineOptions({ name: 'ProductionMyProductionTask' })
const PAGE_BOTTOM_GAP = 20
const TASK_TABS = [
{ name: 'current', label: '当前任务' },
{ name: 'history', label: '历史执行' }
const taskSubListRefreshToken = ref(0)
const viewDialogHostRef = ref<InstanceType<typeof ProductionWorkOrderViewDialogHost> | null>(null)
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound'
const WORK_ORDER_TYPE_TABS = [
{ name: 'inbound', label: '入库生产工单' },
{ name: 'outbound', label: '出库生产工单' },
{ name: 'temporary', label: '临时生产工单' }
] as const
const activeTab = ref<'current' | 'history'>('current')
const pageRef = ref<HTMLElement>()
const pageStyle = ref<Record<string, string>>({})
let pageResizeObserver: ResizeObserver | undefined
const filterHint = computed(() =>
activeTab.value === 'current'
? '仅展示当前指派给您的生产任务。'
: '展示您曾负责并已转出的任务,仅可查看。'
const activeTypeTab = ref<WorkOrderTypeTab>('inbound')
const filterExpanded = ref(false)
const filterTimeLabel = computed(() =>
activeTypeTab.value === 'outbound' ? '输注时间' : '创建时间'
)
const filterHint = computed(() => {
const timeHint =
activeTypeTab.value === 'outbound'
? '默认展示近 30 天内输注的工单。'
: '默认展示近 30 天内创建的工单。'
return `仅展示指派给当前登录人的生产任务(含已转出记录,转出任务仅可查看)。${timeHint}`
})
function updatePageHeight() {
const el = pageRef.value
if (!el) return
const top = el.getBoundingClientRect().top
const layoutContent = el.closest('.layout-content') as HTMLElement | null
const paddingBottom = layoutContent
? Number.parseInt(getComputedStyle(layoutContent).paddingBottom, 10) || 0
: 0
const height = Math.max(200, window.innerHeight - top - paddingBottom - PAGE_BOTTOM_GAP)
pageStyle.value = { height: `${height}px`, overflow: 'hidden' }
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function bindPageHeightListeners() {
updatePageHeight()
window.addEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = new ResizeObserver(() => updatePageHeight())
const panel = pageRef.value?.querySelector('.my-production-task-panel')
const filterBlock = pageRef.value?.querySelector('.filter-block')
const pagerBar = pageRef.value?.querySelector('.list-pager-bar')
if (panel) pageResizeObserver.observe(panel)
if (filterBlock) pageResizeObserver.observe(filterBlock)
if (pagerBar) pageResizeObserver.observe(pagerBar)
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
function unbindPageHeightListeners() {
window.removeEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = undefined
}
onMounted(() => nextTick(bindPageHeightListeners))
onUnmounted(unbindPageHeightListeners)
const searchForm = reactive({
keyword: '',
workOrderStatus: '',
taskStatus: '',
workOrderType: ''
bizType: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const tableData = ref<ProductionWorkOrderItem[]>([])
const presetTasksByWorkOrderId = ref<Record<string, Record<string, any>[]>>({})
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const appliedFilters = reactive({
keyword: '',
taskStatus: '',
workOrderType: ''
})
function buildWorkOrderSearchFilters() {
const timeParams =
activeTypeTab.value === 'outbound'
? {
infusionTimeStart: createTimeRange.value?.[0] || undefined,
infusionTimeEnd: createTimeRange.value?.[1] || undefined
}
: {
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined
}
return {
keyword: searchForm.keyword || undefined,
workOrderStatus: searchForm.workOrderStatus || undefined,
bizType: searchForm.bizType || undefined,
activeTypeTab: activeTypeTab.value,
...timeParams
}
}
const refreshToken = ref(0)
const pagination = reactive({ page: 1, pageSize: 30, total: 0 })
async function loadData() {
loading.value = true
try {
const tasks = await fetchAllMineProductionTasks({
keyword: searchForm.keyword || undefined,
taskStatus: searchForm.taskStatus || undefined,
workOrderType: activeTypeTab.value
})
let entries = groupMineTasksByWorkOrder(tasks)
entries = await enrichMineEntriesWithWorkOrders(entries)
entries = filterMineEntriesByWorkOrderSearch(entries, buildWorkOrderSearchFilters())
const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize)
const tableModel = buildMineTableRows(pageResult.items)
tableData.value = tableModel.rows
presetTasksByWorkOrderId.value = tableModel.presetTasksByWorkOrderId
pagination.total = pageResult.total
} finally {
loading.value = false
}
}
function handleSearch() {
appliedFilters.keyword = searchForm.keyword
appliedFilters.taskStatus = searchForm.taskStatus
appliedFilters.workOrderType = searchForm.workOrderType
pagination.page = 1
refreshToken.value += 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.workOrderStatus = ''
searchForm.taskStatus = ''
searchForm.workOrderType = ''
handleSearch()
}
function handleTabChange() {
searchForm.bizType = ''
createTimeRange.value = getDefaultLast30DaysRange()
filterExpanded.value = false
pagination.page = 1
refreshToken.value += 1
nextTick(updatePageHeight)
loadData()
}
function switchTab(name: 'current' | 'history') {
if (activeTab.value === name) return
activeTab.value = name
handleTabChange()
}
function handlePageChange() {
refreshToken.value += 1
}
function handleSizeChange() {
function handleTypeTabChange() {
pagination.page = 1
refreshToken.value += 1
filterExpanded.value = false
if (activeTypeTab.value !== 'outbound') {
searchForm.bizType = ''
}
loadData()
}
function handleTabChange(name: WorkOrderTypeTab) {
if (activeTypeTab.value === name) return
activeTypeTab.value = name
handleTypeTabChange()
}
async function handleWorkOrderTasksChanged() {
taskSubListRefreshToken.value += 1
await loadData()
}
function onWorkOrderPageChange(page: number) {
pagination.page = page
loadData()
}
function onWorkOrderPageSizeChange(size: number) {
pagination.page = 1
pagination.pageSize = size
loadData()
}
function handleView(row: ProductionWorkOrderItem) {
viewDialogHostRef.value?.openView(row)
}
watchDebounced(
[searchForm, createTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.my-production-task-page {
display: flex;
flex-direction: column;
.my-production-task-filter-main-grid,
.my-production-task-filter-extra-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.my-production-task-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
@media (max-width: 1100px) {
.my-production-task-filter-main-grid,
.my-production-task-filter-extra-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.my-production-task-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
@media (max-width: 560px) {
.my-production-task-filter-main-grid,
.my-production-task-filter-extra-grid {
grid-template-columns: 1fr;
}
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
.filter-hint {
margin: 0 0 10px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.filter-extra {
padding-top: 10px;
margin-top: 8px;
border-top: 0;
}
.filter-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
margin-top: 10px;
border-top: 0;
}
.btn-filter-toggle {
height: 36px;
padding: 0 4px;
font-weight: 600;
}
.btn-filter-toggle-ic {
margin-right: 4px;
}
</style>
@@ -21,7 +21,17 @@
/>
</el-form-item>
<el-form-item label="产品类型">
<el-select v-model="searchForm.productTypeId" placeholder="全部" clearable filterable>
<el-select
v-model="searchForm.productTypeId"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="pt in productTypeOptions"
:key="pt.id"
@@ -210,9 +220,14 @@
<el-form-item label="产品类型">
<el-select
v-model="formData.productTypeId"
placeholder="请选择产品类型"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="pt in productTypeOptions"
@@ -1103,11 +1118,13 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const productTypeOptions = ref<Record<string, any>[]>([])
const productTypeOptionsLoading = ref(false)
const productTypeNameById = ref<Record<string, string>>({})
const productTypeMap = computed(() => {
const map: Record<string, string> = {}
const map: Record<string, string> = { ...productTypeNameById.value }
productTypeOptions.value.forEach((pt: Record<string, any>) => {
map[pt.id] = pt.productTypeName || ''
if (pt.id) map[pt.id] = pt.productTypeName || map[pt.id] || ''
})
return map
})
@@ -1116,15 +1133,37 @@
selectedRows.value = rows
}
async function loadProductTypes() {
async function remoteSearchProductTypes(query: string) {
productTypeOptionsLoading.value = true
try {
const res = await fetchCellProductTypeList({ page: 1, pageSize: 100 })
const res = await fetchCellProductTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
productTypeOptions.value = res.list || res || []
for (const pt of productTypeOptions.value) {
if (pt.id) productTypeNameById.value[pt.id] = pt.productTypeName || ''
}
} catch {
/* ignore */
productTypeOptions.value = []
} finally {
productTypeOptionsLoading.value = false
}
}
function onProductTypeSelectVisible(visible: boolean) {
if (visible) void remoteSearchProductTypes('')
}
function ensureProductTypeInOptions(id: string) {
if (!id) return
if (productTypeOptions.value.some((pt) => pt.id === id)) return
const name = productTypeNameById.value[id] || id
productTypeOptions.value = [...productTypeOptions.value, { id, productTypeName: name }]
if (!productTypeNameById.value[id]) productTypeNameById.value[id] = name
}
async function loadData() {
loading.value = true
try {
@@ -1202,6 +1241,7 @@
formData.routeCode = row.routeCode || ''
formData.routeName = row.routeName || ''
formData.productTypeId = row.productTypeId || ''
ensureProductTypeInOptions(row.productTypeId)
formData.version = row.version || ''
formData.remark = row.remark || ''
dialogVisible.value = true
@@ -2161,7 +2201,7 @@
)
onMounted(() => {
loadData()
loadProductTypes()
void remoteSearchProductTypes('')
})
</script>
@@ -59,9 +59,14 @@
<el-select
v-model="row.componentMaterialId"
filterable
remote
reserve-keyword
clearable
placeholder="选择试剂/耗材"
placeholder="输入名称检索试剂/耗材"
class="inner-cell-select"
:loading="componentOptionsLoading"
:remote-method="remoteSearchComponents"
@visible-change="onComponentSelectVisible"
@change="(id: string) => onComponentSelect(row, id)"
>
<el-option
@@ -117,7 +122,6 @@
fetchUpdateProductionMaterial,
fetchProductionMaterialFormula,
fetchReplaceProductionMaterialFormula,
fetchProductionMaterialOptions,
fetchProductionMaterialList,
type ProductionMaterialFormulaLine,
type ProductionMaterialOption
@@ -150,13 +154,61 @@
const formulaLines = ref<ProductionMaterialFormulaLine[]>([])
const componentOptions = ref<ProductionMaterialOption[]>([])
const componentOptionsLoading = ref(false)
async function loadComponentOptions() {
const [reagents, consumables] = await Promise.all([
fetchProductionMaterialOptions({ materialType: 'reagent' }),
fetchProductionMaterialOptions({ materialType: 'consumable' })
])
componentOptions.value = [...(reagents || []), ...(consumables || [])]
function mapProductionMaterialListToOptions(list: Array<Record<string, any>>): ProductionMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}(${m.materialCode || ''})`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
materialType: String(m.materialType || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
function ensureComponentsInOptions(lines: ProductionMaterialFormulaLine[]) {
for (const ln of lines) {
if (!ln.componentMaterialId) continue
if (componentOptions.value.some((o) => o.id === ln.componentMaterialId)) continue
componentOptions.value.push({
id: ln.componentMaterialId,
label: `${ln.componentMaterialName || ''}(${ln.componentMaterialCode || ''})`.trim(),
materialCode: ln.componentMaterialCode || '',
materialName: ln.componentMaterialName || '',
materialType: ln.componentMaterialType || 'reagent',
specModel: '',
unit: ln.usageUnit || '',
stockQty: 0
})
}
}
async function remoteSearchComponents(query: string) {
componentOptionsLoading.value = true
try {
const keyword = query?.trim() || undefined
const [reagentRes, consumableRes] = await Promise.all([
fetchProductionMaterialList({ page: 1, pageSize: 100, keyword, materialType: 'reagent' }),
fetchProductionMaterialList({ page: 1, pageSize: 100, keyword, materialType: 'consumable' })
])
componentOptions.value = [
...mapProductionMaterialListToOptions(reagentRes.list || []),
...mapProductionMaterialListToOptions(consumableRes.list || [])
]
ensureComponentsInOptions(formulaLines.value)
} catch {
componentOptions.value = []
ensureComponentsInOptions(formulaLines.value)
} finally {
componentOptionsLoading.value = false
}
}
function onComponentSelectVisible(visible: boolean) {
if (visible) void remoteSearchComponents('')
}
function addFormulaLine() {
@@ -190,14 +242,14 @@
remark: ''
})
formulaLines.value = []
await loadComponentOptions()
await remoteSearchComponents('')
visible.value = true
}
async function openEdit(id: string) {
isEdit.value = true
materialId.value = id
await loadComponentOptions()
await remoteSearchComponents('')
const res = await fetchProductionMaterialList({
page: 1,
pageSize: 100,
@@ -218,6 +270,7 @@
remark: cfg.remark || ''
})
formulaLines.value = (await fetchProductionMaterialFormula(id)) || []
ensureComponentsInOptions(formulaLines.value)
visible.value = true
}
@@ -106,8 +106,13 @@
<el-select
v-model="formData.configuredMaterialId"
filterable
placeholder="请选择配置试剂"
remote
reserve-keyword
placeholder="输入名称检索配置试剂"
style="width: 100%"
:loading="configuredOptionsLoading"
:remote-method="remoteSearchConfiguredMaterials"
@visible-change="onConfiguredSelectVisible"
@change="onConfiguredChange"
>
<el-option
@@ -263,12 +268,13 @@
fetchUpdateProductionMaterialPrep,
fetchCompleteProductionMaterialPrep,
fetchDeleteProductionMaterialPrep,
fetchProductionMaterialOptions,
fetchProductionMaterialList,
fetchProductionMaterialFormula,
fetchProductionMaterialRequisitionOptions,
type ProductionMaterialPrep,
type ProductionMaterialPrepLine,
type ProductionMaterialFormulaLine,
type ProductionMaterialOption,
type ProductionMaterialRequisitionOption
} from '@/api/production-material'
@@ -302,7 +308,8 @@
expireDate: [{ required: true, message: '请选择过期日期', trigger: 'change' }]
}
const configuredOptions = ref<Awaited<ReturnType<typeof fetchProductionMaterialOptions>>>([])
const configuredOptions = ref<ProductionMaterialOption[]>([])
const configuredOptionsLoading = ref(false)
const formulaComponentOptions = ref<ProductionMaterialFormulaLine[]>([])
const requisitionOptions = ref<ProductionMaterialRequisitionOption[]>([])
@@ -343,9 +350,62 @@
handleSearch()
}
async function loadConfiguredOptions() {
configuredOptions.value =
(await fetchProductionMaterialOptions({ materialType: 'configured_reagent' })) || []
function mapProductionMaterialListToOptions(list: Array<Record<string, any>>): ProductionMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}(${m.materialCode || ''})`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
materialType: String(m.materialType || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
function ensureConfiguredInOptions(id: string, name?: string, code?: string) {
if (!id) return
if (configuredOptions.value.some((o) => o.id === id)) return
configuredOptions.value.push({
id,
label: name && code ? `${name}(${code})` : name || code || id,
materialCode: code || '',
materialName: name || '',
materialType: 'configured_reagent',
specModel: '',
unit: '',
stockQty: 0
})
}
async function remoteSearchConfiguredMaterials(query: string) {
configuredOptionsLoading.value = true
try {
const res = await fetchProductionMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
materialType: 'configured_reagent'
})
configuredOptions.value = mapProductionMaterialListToOptions(res.list || [])
if (formData.configuredMaterialId) {
const opt = configuredOptions.value.find((o) => o.id === formData.configuredMaterialId)
if (!opt) {
ensureConfiguredInOptions(formData.configuredMaterialId)
}
}
} catch {
configuredOptions.value = []
if (formData.configuredMaterialId) {
ensureConfiguredInOptions(formData.configuredMaterialId)
}
} finally {
configuredOptionsLoading.value = false
}
}
function onConfiguredSelectVisible(visible: boolean) {
if (visible) void remoteSearchConfiguredMaterials('')
}
async function onConfiguredChange(id: string) {
@@ -418,7 +478,7 @@
dialogIsEdit.value = false
editingId.value = ''
resetForm()
await loadConfiguredOptions()
await remoteSearchConfiguredMaterials('')
formData.prepTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
dialogVisible.value = true
}
@@ -435,6 +495,11 @@
remark: detail.remark,
lines: (detail.lines || []).map((ln) => ({ ...ln }))
})
ensureConfiguredInOptions(
detail.configuredMaterialId,
detail.configuredMaterialName,
detail.configuredMaterialCode
)
await onConfiguredChange(detail.configuredMaterialId)
}
@@ -443,7 +508,7 @@
dialogIsEdit.value = true
editingId.value = row.id
resetForm()
await loadConfiguredOptions()
await remoteSearchConfiguredMaterials('')
const detail = await fetchProductionMaterialPrepDetail(row.id)
await fillFormFromDetail(detail)
dialogVisible.value = true
@@ -454,7 +519,7 @@
dialogIsEdit.value = false
editingId.value = row.id
resetForm()
await loadConfiguredOptions()
await remoteSearchConfiguredMaterials('')
const detail = await fetchProductionMaterialPrepDetail(row.id)
await fillFormFromDetail(detail)
dialogVisible.value = true
@@ -15,9 +15,14 @@
<el-form-item label="物料筛选">
<el-select
v-model="searchForm.materialId"
placeholder="全部物料"
placeholder="输入物料名称检索"
clearable
filterable
remote
reserve-keyword
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
@visible-change="onMaterialSelectVisible"
>
<el-option
v-for="m in materialOptions"
@@ -364,7 +369,7 @@
fetchMarkProductionMaterialUsageFullyUsed
} from '@/api/production-material'
import type { ProductionMaterialUsage } from '@/api/production-material'
import { fetchProductionMaterialOptions } from '@/api/production-material'
import { fetchProductionMaterialList } from '@/api/production-material'
import type { ProductionMaterialOption } from '@/api/production-material'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
@@ -405,6 +410,7 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const materialOptions = ref<ProductionMaterialOption[]>([])
const materialOptionsLoading = ref(false)
function isManualOutRow(row: ProductionMaterialUsage) {
if (row.sourceType === 'manual_out') return true
@@ -512,17 +518,40 @@
}
}
async function loadMaterialOptions() {
function mapProductionMaterialListToOptions(list: Array<Record<string, any>>): ProductionMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
materialType: String(m.materialType || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchProductionMaterialOptions({
const res = await fetchProductionMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
materialType: searchForm.materialType || undefined
})
materialOptions.value = res || []
materialOptions.value = mapProductionMaterialListToOptions(res.list || [])
} catch {
materialOptions.value = []
} finally {
materialOptionsLoading.value = false
}
}
function onMaterialSelectVisible(visible: boolean) {
if (visible) void remoteSearchMaterials('')
}
async function loadData() {
loading.value = true
try {
@@ -679,7 +708,7 @@
})
onMounted(async () => {
await loadMaterialOptions()
await remoteSearchMaterials('')
if (route.query.materialId) {
searchForm.materialId = route.query.materialId as string
}
@@ -36,9 +36,14 @@
<el-select
v-model="form.assigneeUserId"
filterable
placeholder="请选择负责人"
remote
reserve-keyword
clearable
placeholder="输入姓名检索负责人"
style="width: 100%"
:loading="userLoading"
:remote-method="remoteSearchUsers"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="u in userOptions"
@@ -54,10 +59,15 @@
>
<el-select
v-model="form.routeId"
placeholder="请选择工艺路线(可选)"
placeholder="输入路线名称检索(可选)"
filterable
remote
reserve-keyword
clearable
style="width: 100%"
:loading="routeLoading"
:remote-method="remoteSearchRoutes"
@visible-change="onRouteSelectVisible"
>
<el-option
v-for="route in routeOptions"
@@ -215,7 +225,7 @@
import { Rank, Plus, CopyDocument, Delete } from '@element-plus/icons-vue'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import { fetchCreateProductionTask } from '@/api/production-task'
import { fetchUserOptions } from '@/api/admin'
import { fetchUserList } from '@/api/admin'
import { productionTaskTypeForWorkOrder } from '@/utils/production-labels'
import { fetchProcessRouteList } from '@/api/production'
import type { FormInstance, FormRules } from 'element-plus'
@@ -247,8 +257,9 @@
const formRef = ref<FormInstance>()
const routeOptions = ref<any[]>([])
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const userOptions = ref<Api.User.UserItem[]>([])
const userLoading = ref(false)
const routeLoading = ref(false)
const form = reactive({
taskName: '',
assigneeUserId: '',
@@ -263,24 +274,48 @@
const editor = useTaskStepEditor()
async function loadUserOptions() {
async function remoteSearchUsers(query: string) {
userLoading.value = true
try {
userOptions.value = (await fetchUserOptions().catch(() => [])) as any[]
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: query?.trim() || undefined,
status: '1'
})
userOptions.value = res.list || []
} catch {
userOptions.value = []
} finally {
userLoading.value = false
}
}
async function loadRouteOptions() {
function onUserSelectVisible(visible: boolean) {
if (visible) void remoteSearchUsers('')
}
async function remoteSearchRoutes(query: string) {
routeLoading.value = true
try {
const res = await fetchProcessRouteList({ page: 1, pageSize: 100 })
const kw = query?.trim()
const res = await fetchProcessRouteList({
page: 1,
pageSize: 100,
routeName: kw || undefined
})
routeOptions.value = res.list || []
} catch {
routeOptions.value = []
} finally {
routeLoading.value = false
}
}
function onRouteSelectVisible(visible: boolean) {
if (visible) void remoteSearchRoutes('')
}
function handleClose() {
dialogVisible.value = false
phase.value = 'form'
@@ -343,8 +378,8 @@
) {
form.taskName = `${props.workOrderRow.workOrderNo}任务`
}
loadRouteOptions()
loadUserOptions()
void remoteSearchRoutes('')
void remoteSearchUsers('')
}
}
}
@@ -16,9 +16,14 @@
<el-select
v-model="form.toUserId"
filterable
placeholder="请选择用户"
remote
reserve-keyword
clearable
placeholder="输入姓名检索用户"
style="width: 100%"
:loading="userLoading"
:remote-method="remoteSearchUsers"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="u in userOptions"
@@ -54,7 +59,7 @@
import { ElMessage } from 'element-plus'
import { Close, Check } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { fetchUserOptions } from '@/api/admin'
import { fetchUserList } from '@/api/admin'
import {
fetchForwardProductionTask,
fetchReassignProductionTask,
@@ -81,22 +86,34 @@
const formRef = ref<FormInstance>()
const submitting = ref(false)
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const userOptions = ref<Api.User.UserItem[]>([])
const form = reactive({ toUserId: '', remark: '' })
const rules: FormRules = {
toUserId: [{ required: true, message: '请选择负责人', trigger: 'change' }]
}
async function loadUsers() {
async function remoteSearchUsers(query: string) {
userLoading.value = true
try {
userOptions.value = (await fetchUserOptions().catch(() => [])) as any[]
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: query?.trim() || undefined,
status: '1'
})
userOptions.value = res.list || []
} catch {
userOptions.value = []
} finally {
userLoading.value = false
}
}
function onUserSelectVisible(visible: boolean) {
if (visible) void remoteSearchUsers('')
}
function handleClose() {
dialogVisible.value = false
form.toUserId = ''
@@ -132,7 +149,7 @@
watch(
() => props.visible,
(val) => {
if (val) loadUsers()
if (val) void remoteSearchUsers('')
}
)
</script>
@@ -7,6 +7,7 @@
<div class="production-work-order-card__head-main">
<div class="production-work-order-card__title-row">
<el-checkbox
v-if="!isMineMode"
:model-value="isSelected(row.id)"
class="production-work-order-card__checkbox"
@change="(val) => handleCardSelect(row, Boolean(val))"
@@ -151,20 +152,32 @@
</div>
</dl>
</div>
<div class="production-work-order-card__ops">
<div v-if="showCardOpsRow" class="production-work-order-card__ops">
<el-button
v-if="canDispatchTask(row)"
v-if="isMineMode"
class="btn-op-item primary"
type="primary"
@click="emit('view', row)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-else-if="showCardDispatchComplete && canDispatchTask(row)"
class="btn-op-item primary"
type="primary"
@click="emit('dispatch', row)"
>
<el-icon><Position /></el-icon>派发任务
</el-button>
<el-button v-else class="btn-op-item primary" @click="emit('view', row)">
<el-button
v-else-if="!canDispatchTask(row)"
class="btn-op-item primary"
@click="emit('view', row)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-tooltip
v-if="activeTypeTab === 'inbound'"
v-if="!isMineMode && activeTypeTab === 'inbound'"
:content="dispatchQcTooltip(row)"
:disabled="canDispatchQc(row)"
placement="top"
@@ -179,11 +192,15 @@
</el-button>
</span>
</el-tooltip>
<el-button v-if="canComplete(row)" class="btn-op-item" @click="emit('complete', row)">
<el-button
v-if="!isMineMode && showCardDispatchComplete && canComplete(row)"
class="btn-op-item"
@click="emit('complete', row)"
>
<el-icon><Check /></el-icon>完成工单
</el-button>
<el-dropdown
v-if="hasMoreActions(row)"
v-if="!isMineMode && hasMoreActions(row)"
trigger="hover"
@command="(cmd) => emit('command', cmd, row)"
>
@@ -222,6 +239,8 @@
<WorkOrderTaskSubList
embedded
:work-order="row"
:list-mode="isMineMode ? 'mine' : 'workOrder'"
:preset-tasks="isMineMode ? presetTasksByWorkOrderId?.[row.id] : undefined"
:refresh-token="taskRefreshToken"
@changed="emit('tasks-changed')"
/>
@@ -230,7 +249,7 @@
<el-empty
v-if="!loading && !rows.length"
class="production-work-order-cards-empty"
description="暂无生产工单"
:description="isMineMode ? '暂无指派给您的生产任务' : '暂无生产工单'"
:image-size="72"
/>
</div>
@@ -248,6 +267,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { View, Position, Document, Check, More, EditPen, Delete } from '@element-plus/icons-vue'
import type { ProductionWorkOrderItem } from '@/api/production-work-order'
import { receptionSourceLabel } from '@/utils/qc-labels'
@@ -266,16 +286,26 @@
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound'
const props = defineProps<{
rows: ProductionWorkOrderItem[]
loading: boolean
activeTypeTab: WorkOrderTypeTab
page: number
pageSize: number
total: number
taskRefreshToken: number
selectedIds: string[]
}>()
const props = withDefaults(
defineProps<{
rows: ProductionWorkOrderItem[]
loading: boolean
activeTypeTab: WorkOrderTypeTab
page: number
pageSize: number
total: number
taskRefreshToken: number
selectedIds?: string[]
pageMode?: 'manage' | 'mine'
presetTasksByWorkOrderId?: Record<string, Record<string, any>[]>
}>(),
{
selectedIds: () => [],
pageMode: 'manage'
}
)
const isMineMode = computed(() => props.pageMode === 'mine')
const emit = defineEmits<{
view: [row: ProductionWorkOrderItem]
@@ -291,6 +321,16 @@
const RECEPTION_OVER_HOURS_MS = 24 * 60 * 60 * 1000
/** 我的任务:仅查看;管理端入库/出库隐藏操作行,临时保留 */
const showCardOpsRow = computed(
() => isMineMode.value || props.activeTypeTab === 'temporary'
)
/** 入库/出库 Tab 展示派发/完成;临时 Tab 隐藏 */
const showCardDispatchComplete = computed(
() => !isMineMode.value && props.activeTypeTab !== 'temporary'
)
function protocolStatusLabelBySource(status: string, source: string): string {
return source === 'research_donation' ? donationStatusLabel(status) : protocolStatusLabel(status)
}
@@ -515,9 +555,9 @@
.production-work-order-card__fee-in-head {
align-self: stretch;
width: calc(100% + 32px);
margin: 0 -16px;
border-top: 1px solid #d1fae5;
width: 100%;
margin: 0;
border-top: none;
border-bottom: none;
}
@@ -0,0 +1,123 @@
<!-- 生产工单 · 查看详情弹窗宿主 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="viewVisible"
title="工单详情"
:width="viewDialogWidth"
:grid-columns="viewDialogGridColumns"
:compact="viewDialogCompact"
:loading="viewLoading"
:icon="Document"
:meta="detail?.workOrderNo || ''"
:sections="viewSections"
modal-class="protocol-dialog-mask"
>
<template #protocol-payment-details>
<QcWorkOrderProtocolPaymentPanel :summary="detail?.paymentSummary" embedded />
</template>
<template #diagnosis-attachments>
<ProtocolAttachmentReadonly
:files="viewDiagnosisAttachmentFiles"
@preview="handleReceptionAttachmentPreview"
@download="handleReceptionAttachmentDownload"
/>
</template>
<template #product-table>
<div v-if="detail?.products?.length" class="view-subtable-wrap">
<el-table
:data="detail?.products || []"
stripe
size="small"
class="list-table view-subtable"
>
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="120"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="100"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="quantity" label="数量" min-width="70" align="center" />
<el-table-column label="产品状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="productStatusTag(row.productStatus || '')" size="small">
{{ productStatusLabel(row.productStatus || '') }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无产品明细</p>
</template>
<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>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { Document } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import { productStatusLabel, productStatusTagType } from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import { useProductionWorkOrderViewDialog } from './useProductionWorkOrderViewDialog'
defineOptions({ name: 'ProductionWorkOrderViewDialogHost' })
function productStatusTag(status: string): TagType {
return productStatusTagType(status) as TagType
}
const {
viewVisible,
viewLoading,
detail,
viewDialogWidth,
viewDialogGridColumns,
viewDialogCompact,
viewSections,
viewReceptionAttachmentFiles,
viewDiagnosisAttachmentFiles,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
openView
} = useProductionWorkOrderViewDialog()
defineExpose({ openView })
</script>
@@ -314,9 +314,6 @@
<TaskOutputList
:items="editor.taskOutputs.value"
:loading="false"
:product-type-options="editor.productTypeOptions.value"
:spec-options="editor.specOptions.value"
:storage-slot-options="editor.storageSlotOptions.value"
:unit-options="editor.itemUnitOptions.value"
:unit-options-loading="editor.itemUnitOptionsLoading.value"
:disabled="readonly"
@@ -93,9 +93,6 @@
<TaskOutputProductDialog
v-model="dialogVisible"
:edit-item="editingItem"
:product-type-options="productTypeOptions"
:spec-options="specOptions"
:storage-slot-options="storageSlotOptions"
:unit-options="unitOptions"
:unit-options-loading="unitOptionsLoading"
@confirm="handleDialogConfirm"
@@ -114,9 +111,6 @@
defineProps<{
items: Record<string, any>[]
loading: boolean
productTypeOptions: Array<{ id: string; productTypeName: string }>
specOptions: Array<{ id: string; specName: string }>
storageSlotOptions: Array<{ id: string; name: string }>
unitOptions: { label: string; value: string }[]
unitOptionsLoading: boolean
disabled?: boolean
@@ -182,9 +176,6 @@
}
function enrichOutputRow(row: Record<string, any>) {
const productType = props.productTypeOptions.find((o) => o.id === row.productTypeId)
const spec = props.specOptions.find((o) => o.id === row.specId)
const slot = props.storageSlotOptions.find((o) => o.id === row.storageSlotId)
const productCode = row.productCode || row.outputBarcode || ''
const productName = row.productName || row.outputName || ''
return {
@@ -193,9 +184,9 @@
productName,
outputBarcode: productCode,
outputName: productName,
productTypeName: row.productTypeName || productType?.productTypeName || '',
specName: row.specName || spec?.specName || '',
storageSlotName: row.storageSlotName || slot?.name || ''
productTypeName: row.productTypeName || '',
specName: row.specName || '',
storageSlotName: row.storageSlotName || ''
}
}
@@ -38,9 +38,15 @@
<el-form-item label="产品类型">
<el-select
v-model="formData.productTypeId"
placeholder="请选择产品类型"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="item in productTypeOptions"
@@ -54,9 +60,15 @@
<el-form-item label="规格">
<el-select
v-model="formData.specId"
placeholder="请选择规格"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="specOptionsLoading"
:remote-method="remoteSearchSpecs"
@visible-change="onSpecSelectVisible"
>
<el-option
v-for="item in specOptions"
@@ -85,10 +97,15 @@
<el-form-item v-if="isEdit" label="储物格位置">
<el-select
v-model="formData.storageSlotId"
placeholder="请选择储物格"
placeholder="输入编号检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="storageSlotOptionsLoading"
:remote-method="remoteSearchStorageSlots"
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotSelectOptions"
@@ -275,10 +292,15 @@
<template #default="{ row }">
<el-select
v-model="row.storageSlotId"
placeholder="请选择储物格"
placeholder="输入编号检索"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="storageSlotOptionsLoading"
:remote-method="remoteSearchStorageSlots"
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotSelectOptions"
@@ -329,8 +351,13 @@
import { ElMessage } from 'element-plus'
import { fetchUploadAttachment } from '@/api/business'
import { fetchNextCellProductCode } from '@/api/cell-product'
import { fetchCellProductTypeList, fetchCellProductSpecList } from '@/api/admin'
import {
remoteSearchStorageSlotSelectOptions,
ensureStorageSlotsInOptions,
validateUniqueStorageSlotIds
} from '@/utils/storage-slot-options'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { validateUniqueStorageSlotIds } from '@/utils/storage-slot-options'
defineOptions({ name: 'TaskOutputProductDialog' })
@@ -347,9 +374,6 @@
const props = defineProps<{
modelValue: boolean
editItem?: Record<string, any> | null
productTypeOptions: Array<{ id: string; productTypeName: string }>
specOptions: Array<{ id: string; specName: string }>
storageSlotOptions: Array<{ id: string; name: string }>
unitOptions: { label: string; value: string }[]
unitOptionsLoading: boolean
}>()
@@ -381,8 +405,86 @@
const isEdit = computed(() => !!props.editItem)
const productTypeOptions = ref<Array<{ id: string; productTypeName: string }>>([])
const specOptions = ref<Array<{ id: string; specName: string }>>([])
const storageSlotOptions = ref<Array<{ id: string; name: string }>>([])
const productTypeOptionsLoading = ref(false)
const specOptionsLoading = ref(false)
const storageSlotOptionsLoading = ref(false)
const storageSlotSearchOpts = ref<{ includeSlotIds?: string[] }>({})
async function remoteSearchProductTypes(query: string) {
productTypeOptionsLoading.value = true
try {
const res = await fetchCellProductTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
productTypeOptions.value = res?.list || res || []
} catch {
productTypeOptions.value = []
} finally {
productTypeOptionsLoading.value = false
}
}
function ensureProductTypeInOptions(id: string, name?: string) {
if (!id) return
if (productTypeOptions.value.some((o) => o.id === id)) return
productTypeOptions.value = [{ id, productTypeName: name || id }, ...productTypeOptions.value]
}
async function remoteSearchSpecs(query: string) {
specOptionsLoading.value = true
try {
const res = await fetchCellProductSpecList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
specOptions.value = res?.list || res || []
} catch {
specOptions.value = []
} finally {
specOptionsLoading.value = false
}
}
function ensureSpecInOptions(id: string, name?: string) {
if (!id) return
if (specOptions.value.some((o) => o.id === id)) return
specOptions.value = [{ id, specName: name || id }, ...specOptions.value]
}
async function remoteSearchStorageSlots(query: string) {
storageSlotOptionsLoading.value = true
try {
storageSlotOptions.value = await remoteSearchStorageSlotSelectOptions(query, {
availableOnly: true,
includeSlotIds: storageSlotSearchOpts.value.includeSlotIds
})
} catch {
storageSlotOptions.value = []
} finally {
storageSlotOptionsLoading.value = false
}
}
function onStorageSlotSelectVisible(visible: boolean) {
if (visible) void remoteSearchStorageSlots('')
}
function onProductTypeSelectVisible(visible: boolean) {
if (visible) void remoteSearchProductTypes('')
}
function onSpecSelectVisible(visible: boolean) {
if (visible) void remoteSearchSpecs('')
}
const storageSlotSelectOptions = computed(() => {
const base = props.storageSlotOptions || []
const base = storageSlotOptions.value || []
if (!isEdit.value || !formData.storageSlotId) return base
if (base.some((o) => o.id === formData.storageSlotId)) return base
const label =
@@ -456,7 +558,13 @@
async function initAddForm() {
resetForm()
formData.quantity = 1
storageSlotSearchOpts.value = {}
emit('searchUnit', '')
await Promise.all([
remoteSearchProductTypes(''),
remoteSearchSpecs(''),
remoteSearchStorageSlots('')
])
await nextTick()
formRef.value?.clearValidate?.()
try {
@@ -479,6 +587,7 @@
function onDialogOpened() {
if (props.editItem) {
populateFormFromEditItem(props.editItem)
void hydrateLookupOptionsFromEdit(props.editItem)
emit('searchUnit', props.editItem.unit || '')
nextTick(() => formRef.value?.clearValidate?.())
} else {
@@ -579,10 +688,14 @@
}
function buildSharedResult(): Record<string, any> {
const productType = productTypeOptions.value.find((o) => o.id === formData.productTypeId)
const spec = specOptions.value.find((o) => o.id === formData.specId)
return {
productName: formData.productName.trim(),
productTypeId: formData.productTypeId || undefined,
productTypeName: productType?.productTypeName || props.editItem?.productTypeName || '',
specId: formData.specId || undefined,
specName: spec?.specName || props.editItem?.specName || '',
passage: formData.passage || undefined,
batchNo: formData.batchNo.trim(),
expectedCellCount: formData.expectedCellCount ?? undefined,
@@ -596,6 +709,26 @@
}
}
function resolveSlotName(slotId: string) {
return storageSlotOptions.value.find((o) => o.id === slotId)?.name || ''
}
async function hydrateLookupOptionsFromEdit(item: Record<string, any>) {
ensureProductTypeInOptions(item.productTypeId, item.productTypeName)
ensureSpecInOptions(item.specId, item.specName)
storageSlotSearchOpts.value = {
includeSlotIds: item.storageSlotId ? [item.storageSlotId] : undefined
}
storageSlotOptions.value = ensureStorageSlotsInOptions([], [
{ id: item.storageSlotId, name: item.storageSlotName || item.storage_slot_name }
])
await Promise.all([
remoteSearchProductTypes(''),
remoteSearchSpecs(''),
remoteSearchStorageSlots('')
])
}
function resetForm() {
formData.cellProductId = ''
formData.productCode = ''
@@ -712,6 +845,10 @@
cellProductId: formData.cellProductId || undefined,
productCode: formData.productCode.trim(),
storageSlotId: formData.storageSlotId || undefined,
storageSlotName:
resolveSlotName(formData.storageSlotId) ||
props.editItem?.storageSlotName ||
'',
quantity: formData.quantity
})
} else {
@@ -719,6 +856,7 @@
...shared,
productCode: line.productCode.trim(),
storageSlotId: line.storageSlotId,
storageSlotName: resolveSlotName(line.storageSlotId),
quantity: 1
}))
emit('confirm', results)
@@ -60,8 +60,8 @@
<el-tag :type="productionTaskTypeTag(row.taskType, row.orderSource).type" size="small">
{{ productionTaskTypeTag(row.taskType, row.orderSource).label }}
</el-tag>
<span v-if="isMineHistory && row.handoverTime" class="work-order-task-card__sep">·</span>
<span v-if="isMineHistory && row.handoverTime" class="work-order-task-card__handover"
<span v-if="isMineHistoryRow(row) && row.handoverTime" class="work-order-task-card__sep">·</span>
<span v-if="isMineHistoryRow(row) && row.handoverTime" class="work-order-task-card__handover"
>转出 {{ row.handoverTime }}</span
>
<div class="work-order-task-sublist__ops">
@@ -72,7 +72,7 @@
size="small"
@click.stop="handleEnterExecution(row)"
>
<el-icon><VideoPlay /></el-icon>{{ isMineHistory ? '查看' : '进入执行' }}
<el-icon><VideoPlay /></el-icon>{{ isMineHistoryRow(row) ? '查看' : '进入执行' }}
</el-button>
<el-button
v-else
@@ -229,7 +229,7 @@
const props = defineProps<{
workOrder?: Record<string, any>
listMode?: 'workOrder' | 'mine-current' | 'mine-history'
listMode?: 'workOrder' | 'mine' | 'mine-current' | 'mine-history'
mineFilters?: {
keyword?: string
taskStatus?: string
@@ -239,6 +239,7 @@
pageSize?: number
refreshToken?: number
embedded?: boolean
presetTasks?: Record<string, any>[]
}>()
const emit = defineEmits<{
@@ -248,7 +249,7 @@
const userStore = useUserStore()
const isMineMode = computed(() => (props.listMode || 'workOrder').startsWith('mine'))
const isMineHistory = computed(() => props.listMode === 'mine-history')
const usesPresetTasks = computed(() => props.presetTasks !== undefined)
const operateScope = computed<ProductionTaskOperateScope>(() =>
isMineMode.value ? 'mine' : 'manage'
)
@@ -276,15 +277,21 @@
return uid && String(row.currentAssigneeUserId || '') === uid
}
function isMineHistoryRow(row: Record<string, any>) {
if (!isMineMode.value) return false
if (props.listMode === 'mine-history') return true
return !isCurrentAssignee(row)
}
function canEnterExecution(row: Record<string, any>) {
if (isMineHistory.value) return false
if (isMineHistoryRow(row)) return false
if (row.taskStatus === 'ywc') return false
if (isMineMode.value) return isCurrentAssignee(row)
return row.taskStatus === 'dpz' || row.taskStatus === 'jxz'
}
function canForwardTask(row: Record<string, any>) {
if (isMineHistory.value) return false
if (isMineHistoryRow(row)) return false
if (isMineMode.value) return isCurrentAssignee(row)
return operateScope.value === 'manage'
}
@@ -294,7 +301,7 @@
}
function canEditTask(row: Record<string, any>) {
if (isMineHistory.value) return false
if (isMineHistoryRow(row)) return false
if (isMineMode.value) return isCurrentAssignee(row)
return row.taskStatus === 'dpz' || row.taskStatus === 'jxz'
}
@@ -312,6 +319,10 @@
async function loadData() {
loading.value = true
try {
if (usesPresetTasks.value) {
tableData.value = props.presetTasks || []
return
}
if (isMineMode.value) {
const res = await fetchMyProductionTasks({
scope: props.listMode === 'mine-history' ? 'history' : 'current',
@@ -338,7 +349,9 @@
}
function handleTaskMutationSuccess() {
loadData()
if (!usesPresetTasks.value) {
loadData()
}
emit('changed')
}
@@ -430,6 +443,7 @@
props.listMode,
props.page,
props.pageSize,
props.presetTasks,
props.mineFilters?.keyword,
props.mineFilters?.taskStatus,
props.mineFilters?.workOrderType
@@ -69,33 +69,13 @@
</el-form>
</div>
<div class="list-toolbar">
<div v-if="activeTypeTab === 'temporary'" class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
v-if="activeTypeTab !== 'outbound'"
class="btn-main"
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon
>{{ activeTypeTab === 'temporary' ? '新增临时工单' : '新增生产工单' }}
</el-button>
<el-button
class="btn-ghost"
:disabled="!canToolbarDispatch"
@click="handleToolbarDispatch"
>
<el-icon><Position /></el-icon>派发任务
</el-button>
<el-button
class="btn-ghost"
:disabled="!canToolbarComplete"
@click="handleToolbarComplete"
>
<el-icon><Check /></el-icon>完成工单
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增临时工单
</el-button>
</div>
</div>
@@ -0,0 +1,343 @@
import { ref, computed } from 'vue'
import {
fetchProductionWorkOrderDetail,
type ProductionWorkOrderDetail,
type ProductionWorkOrderItem
} from '@/api/production-work-order'
import type { OutboundQcDetail } from '@/api/qc-work-order'
import {
receptionSourceLabel,
acceptConclusionLabel,
transportTypeLabel
} from '@/utils/qc-labels'
import {
protocolStatusLabel,
protocolStatusTagType,
donationStatusLabel,
donationStatusTagType,
bizTypeLabel,
bizTypeTagType,
payStatusTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import {
buildOutboundPilotContextSections,
resolveOutboundViewVariant
} from '@/utils/outbound-qc-pilot-view-sections'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { downloadAttachmentFile } from '@/utils/download-attachment'
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 woStatusTag(status: string) {
const map: Record<string, { label: string; type: string }> = {
dpz: { label: '待配置', type: 'info' },
jxz: { label: '进行中', type: 'warning' },
ywc: { label: '已完成', type: 'success' },
yzf: { label: '已作废', type: 'danger' }
}
return map[status] || { label: status, type: 'info' }
}
function sourceTag(source: string) {
const map: Record<string, { label: string; type: string }> = {
cell_storage: { label: '细胞存储', type: 'success' },
research_donation: { label: '科研捐赠', type: 'warning' }
}
return map[source] || { label: source, type: 'info' }
}
export function useProductionWorkOrderViewDialog() {
const viewVisible = ref(false)
const viewLoading = ref(false)
const detail = ref<ProductionWorkOrderDetail | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const isOutboundContextView = computed(
() =>
detail.value?.workOrderType === 'outbound' &&
(detail.value?.bizType === 'pilot_service' ||
detail.value?.bizType === 'clinical_application' ||
detail.value?.bizType === 'basic_experiment')
)
const viewDialogWidth = computed(() => (isOutboundContextView.value ? '1200px' : '720px'))
const viewDialogGridColumns = computed<2 | 3 | 4>(() => (isOutboundContextView.value ? 4 : 2))
const viewDialogCompact = computed(() => isOutboundContextView.value)
const viewReceptionAttachmentFiles = computed(() => {
const r = detail.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>>
})
const viewDiagnosisAttachmentFiles = computed(() => {
const urls = detail.value?.patient?.diagnosisAttachments || ''
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: receptionAttachmentFullUrl(url), fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
function receptionAttachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function handleReceptionAttachmentPreview(file: Record<string, unknown>) {
const url = receptionAttachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleReceptionAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: receptionAttachmentFullUrl })
}
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[]>(() => {
if (!detail.value) return []
const d = detail.value
const isTemp = d.workOrderType === 'temporary'
const isOutbound = d.workOrderType === 'outbound'
const r = d.reception || {}
const baseFields: ViewSection['fields'] = [
{ label: '工单号', value: d.workOrderNo },
{
label: '工单类型',
value: isTemp ? '临时' : isOutbound ? '出库' : '入库',
tagType: isTemp ? 'warning' : isOutbound ? 'primary' : 'success'
}
]
if (isOutbound) {
baseFields.push({
label: '业务类型',
value: d.bizTypeLabel || bizTypeLabel(d.bizType || '') || '—',
tagType: bizTypeTagType(d.bizType || '') as TagType
})
}
if (!isTemp && !isOutbound) {
baseFields.push(
{
label: '协议状态',
value: protocolStatusLabelBySource(
String(d.protocolStatus || ''),
d.receptionSource || 'cell_storage'
),
tagType: protocolStatusTagTypeBySource(
String(d.protocolStatus || ''),
d.receptionSource || 'cell_storage'
)
},
{ label: '样本编码', value: d.sampleCode },
{
label: '来源类型',
value:
r.sourceTypeLabel ||
d.receptionSourceLabel ||
receptionSourceLabel(d.receptionSource || ''),
tagType: sourceTag(d.receptionSource || '').type as TagType
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
)
}
baseFields.push(
{
label: '工单状态',
value: woStatusTag(d.workOrderStatus).label,
tagType: woStatusTag(d.workOrderStatus).type as TagType
},
{ label: '备注', value: d.remark || '—', span: true },
{ label: '创建时间', value: d.createTime || '—' }
)
const sections: ViewSection[] = [{ title: '基本信息', fields: baseFields }]
if (
isOutbound &&
(d.bizType === 'pilot_service' ||
d.bizType === 'clinical_application' ||
d.bizType === 'basic_experiment')
) {
const outboundDetail: OutboundQcDetail = {
workOrder: {} as OutboundQcDetail['workOrder'],
application: d.application,
patient: d.patient,
infusionPlan: d.infusionPlan,
infusionSchedule: d.infusionSchedule,
products: d.products
}
const variant = resolveOutboundViewVariant(d.bizType)
sections.push(...buildOutboundPilotContextSections(outboundDetail, variant))
const feeSummary = d.feeSummary
if (variant === 'basic_experiment' || variant === 'clinical_application') {
sections.push({
title: '缴费信息',
fields: feeSummary
? [
{
label: '售卖金额(元)',
value:
feeSummary.saleAmount != null ? formatMoneyDisplay(feeSummary.saleAmount) : '—'
},
{
label: '应缴金额(元)',
value:
feeSummary.paymentAmount != null
? formatMoneyDisplay(feeSummary.paymentAmount)
: '—'
},
{
label: '实收金额(元)',
value:
feeSummary.transferAmount != null
? formatMoneyDisplay(feeSummary.transferAmount)
: '—'
},
{
label: '缴费状态',
value: feeSummary.payStatusLabel || '—',
tagType: payStatusTagType(feeSummary.payStatus || '') as TagType
}
]
: [{ label: '缴费信息', value: '暂无缴费记录', span: true }]
})
}
return sections
}
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 transportSection =
transportFields.length > 1 || r.transportType
? [{ title: '运输信息', fields: transportFields }]
: []
const paymentSection =
!isTemp && (d.receptionSource || 'cell_storage') === 'cell_storage'
? [{ title: '协议缴费信息', fields: [], slot: 'protocol-payment-details' }]
: []
return [
{ title: '基本信息', fields: baseFields },
...transportSection,
...paymentSection,
...(!isTemp
? [
{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
...(String(r.acceptConclusion || '') === 'js'
? [
{
label: '实验室入库编码',
value: r.labStorageCode || d.labStorageCode || '—'
}
]
: []),
{
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 || '')),
tagType:
r.acceptConclusion === 'js'
? 'success'
: r.acceptConclusion === 'jj'
? 'danger'
: 'info'
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
}
]
: [])
]
})
async function openView(row: ProductionWorkOrderItem) {
viewLoading.value = true
viewVisible.value = true
try {
detail.value = await fetchProductionWorkOrderDetail(row.id)
} finally {
viewLoading.value = false
}
}
return {
viewVisible,
viewLoading,
detail,
viewDialogWidth,
viewDialogGridColumns,
viewDialogCompact,
viewSections,
viewReceptionAttachmentFiles,
viewDiagnosisAttachmentFiles,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
openView
}
}
@@ -24,13 +24,6 @@ import {
} from '@/api/production'
import { fetchItemUnitList, fetchSampleCodeList } from '@/api/admin'
import { fetchCellProductList } from '@/api/cell-product'
import {
fetchCellProductTypeList,
fetchCellProductSpecList,
fetchStorageFacilityList,
fetchStorageSlotList
} from '@/api/admin'
import { loadStorageSlotSelectOptions } from '@/utils/storage-slot-options'
import {
fetchProductionMaterialRequisitionOptions,
fetchProductionMaterialOptions,
@@ -138,9 +131,6 @@ export function useTaskStepEditor() {
const cellProductMasterById = ref<Record<string, Record<string, any>>>({})
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const sampleCodeOptionsLoading = ref(false)
const productTypeOptions = ref<Array<{ id: string; productTypeName: string }>>([])
const specOptions = ref<Array<{ id: string; specName: string }>>([])
const storageSlotOptions = ref<Array<{ id: string; name: string }>>([])
function mapItemUnitOption(u: { unitName?: string; unitCode?: string }) {
const name = (u.unitName || '').trim()
@@ -563,31 +553,6 @@ export function useTaskStepEditor() {
.filter((group) => group.processes.length > 0)
})
async function loadProductTypeAndSpecOptions() {
try {
const [typeRes, specRes] = await Promise.all([
fetchCellProductTypeList({ page: 1, pageSize: 100 }),
fetchCellProductSpecList({ page: 1, pageSize: 100 })
])
productTypeOptions.value = typeRes?.list || typeRes || []
specOptions.value = specRes?.list || specRes || []
} catch {
/* dropdown data failure is non-blocking */
}
}
async function loadStorageSlotOptions() {
try {
const facilityRes = await fetchStorageFacilityList()
const facilities = facilityRes?.list || facilityRes || []
storageSlotOptions.value = await loadStorageSlotSelectOptions(
facilities,
fetchStorageSlotList,
{ availableOnly: true }
)
} catch {
storageSlotOptions.value = []
}
}
async function loadConfigReferenceData() {
const [processes, categories, sops, outputTypes] = await Promise.all([
fetchAllProcesses(),
@@ -625,16 +590,11 @@ export function useTaskStepEditor() {
searchReagentOptions(''),
searchConsumableOptions(''),
searchCellProductOptions(''),
searchSampleCodeOptions(''),
loadProductTypeAndSpecOptions(),
loadStorageSlotOptions()
searchSampleCodeOptions('')
])
}
function enrichTaskOutputRow(row: Record<string, any>) {
const productType = productTypeOptions.value.find((o) => o.id === row.productTypeId)
const spec = specOptions.value.find((o) => o.id === row.specId)
const slot = storageSlotOptions.value.find((o) => o.id === row.storageSlotId)
const productCode = row.productCode || row.outputBarcode || ''
const productName = row.productName || row.outputName || ''
return {
@@ -643,9 +603,9 @@ export function useTaskStepEditor() {
productName,
outputBarcode: productCode,
outputName: productName,
productTypeName: row.productTypeName || productType?.productTypeName || '',
specName: row.specName || spec?.specName || '',
storageSlotName: row.storageSlotName || slot?.name || ''
productTypeName: row.productTypeName || '',
specName: row.specName || '',
storageSlotName: row.storageSlotName || ''
}
}
@@ -1530,9 +1490,6 @@ export function useTaskStepEditor() {
cellProductOptionsLoading,
sampleCodeOptions,
sampleCodeOptionsLoading,
productTypeOptions,
specOptions,
storageSlotOptions,
sopIdsArr,
onDragStart,
onDropToCenter,
@@ -470,7 +470,17 @@
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
</el-form-item>
<el-form-item label="单位">
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
<el-select
v-model="formData.unit"
placeholder="输入单位检索"
clearable
filterable
remote
reserve-keyword
:loading="unitOptionsLoading"
:remote-method="remoteSearchUnits"
@visible-change="onUnitSelectVisible"
>
<el-option
v-for="item in unitOptions"
:key="item.value"
@@ -1263,6 +1273,7 @@
const activeTab = ref('list')
const unitOptions = ref<{ label: string; value: string }[]>([])
const unitOptionsLoading = ref(false)
const searchForm = reactive<{ keyword: string; status: string }>({ keyword: '', status: '' })
const tableRef = ref()
@@ -1532,6 +1543,37 @@
loadData()
}
function ensureUnitInOptions(unitName: string) {
const n = (unitName || '').trim()
if (!n || unitOptions.value.some((o) => o.value === n)) return
unitOptions.value = [{ label: n, value: n }, ...unitOptions.value]
}
async function remoteSearchUnits(query: string) {
unitOptionsLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
unitOptions.value = (res.list || []).map((u: any) => ({
label: u.unitName,
value: u.unitName
}))
ensureUnitInOptions(formData.unit)
} catch {
unitOptions.value = []
ensureUnitInOptions(formData.unit)
} finally {
unitOptionsLoading.value = false
}
}
function onUnitSelectVisible(visible: boolean) {
if (visible) void remoteSearchUnits('')
}
function resetForm() {
formData.reagentCode = ''
formData.reagentName = ''
@@ -1551,6 +1593,7 @@
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
void remoteSearchUnits('')
}
function handleEdit(row: any) {
@@ -1568,6 +1611,7 @@
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
void remoteSearchUnits('')
}
async function handleSubmit() {
@@ -2041,10 +2085,8 @@
{ debounce: 300, deep: true }
)
onMounted(async () => {
onMounted(() => {
loadData()
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
})
</script>
@@ -16,10 +16,15 @@
<el-form-item label="检测项目">
<el-select
v-model="searchForm.envDetectItemId"
placeholder="全部"
placeholder="输入项目名称检索"
clearable
filterable
remote
reserve-keyword
:loading="itemOptionsLoading"
:remote-method="remoteSearchFilterItems"
style="width: 100%"
@visible-change="onFilterItemSelectVisible"
>
<el-option
v-for="item in itemOptions"
@@ -32,16 +37,21 @@
<el-form-item label="检测人">
<el-select
v-model="searchForm.inspectorId"
placeholder="全部"
placeholder="输入姓名检索"
clearable
filterable
remote
reserve-keyword
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -216,9 +226,15 @@
<el-form-item label="检测项目" prop="envDetectItemId">
<el-select
v-model="formData.envDetectItemId"
placeholder="请选择检测项目"
placeholder="输入项目名称检索"
filterable
remote
reserve-keyword
clearable
:loading="activeItemOptionsLoading"
:remote-method="remoteSearchActiveItems"
style="width: 100%"
@visible-change="onActiveItemSelectVisible"
>
<el-option
v-for="item in activeItemOptions"
@@ -233,14 +249,19 @@
v-model="formData.inspectorIds"
multiple
filterable
placeholder="请选择检测人,至少一名"
remote
reserve-keyword
placeholder="输入姓名检索,至少一名"
:loading="userOptionsLoading"
:remote-method="remoteSearchUsers"
style="width: 100%"
@visible-change="onUserSelectVisible"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
v-for="u in userOptions"
:key="u.id"
:label="formatUserOptionLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
@@ -394,12 +415,12 @@
VideoPlay
} from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { fetchUserOptions } from '@/api/admin'
import { fetchUserList } from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { fetchEnvDetectItemsActive, type EnvDetectItem } from '@/api/env-detect-item'
import { fetchEnvDetectItemList, type EnvDetectItem } from '@/api/env-detect-item'
import {
fetchEnvDetectRecordList,
fetchCreateEnvDetectRecord,
@@ -431,7 +452,10 @@
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const itemOptions = ref<EnvDetectItem[]>([])
const activeItemOptions = ref<EnvDetectItem[]>([])
const userOptions = ref<Array<{ label: string; value: string }>>([])
const userOptions = ref<Api.User.UserItem[]>([])
const userOptionsLoading = ref(false)
const itemOptionsLoading = ref(false)
const activeItemOptionsLoading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('新增登记')
@@ -566,51 +590,154 @@
return { userId, label }
}
function ensureCurrentUserInOptions() {
const { userId, label } = getCurrentUserHandler()
if (!userId) return
if (!userOptions.value.some((o) => o.value === userId)) {
userOptions.value = [{ label: label || userId, value: userId }, ...userOptions.value]
function formatUserOptionLabel(u: Api.User.UserItem) {
const parts = [u.nickName || '']
if (u.userNo) parts.push(u.userNo)
return parts.filter(Boolean).join(' · ')
}
function createUserStub(id: string, nickName: string): Api.User.UserItem {
return {
id,
nickName: nickName || id,
userNo: '',
deptId: '',
email: '',
sex: '',
avatar: '',
status: '1',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
}
}
function formatNowDatetime() {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
function ensureCurrentUserInOptions() {
const { userId, label } = getCurrentUserHandler()
if (!userId) return
if (!userOptions.value.some((u) => u.id === userId)) {
userOptions.value = [createUserStub(userId, label), ...userOptions.value]
}
}
function mergeInspectorOptions(ids: string[], names: string[]) {
const merged = [...userOptions.value]
ids.forEach((id, index) => {
if (!id) return
if (!merged.some((o) => o.value === id)) {
merged.push({ value: id, label: names[index] || id })
if (!merged.some((u) => u.id === id)) {
merged.push(createUserStub(id, names[index] || id))
}
})
userOptions.value = merged
}
async function loadUserOptions() {
async function remoteSearchUsers(query: string) {
userOptionsLoading.value = true
try {
userOptions.value = await fetchUserOptions()
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: query?.trim() || undefined,
status: '1'
})
userOptions.value = res.list || []
ensureCurrentUserInOptions()
if (formData.inspectorIds.length) {
mergeInspectorOptions(formData.inspectorIds, [])
}
} catch {
userOptions.value = []
ensureCurrentUserInOptions()
} finally {
userOptionsLoading.value = false
}
ensureCurrentUserInOptions()
}
async function loadItemOptions() {
function onUserSelectVisible(visible: boolean) {
if (visible) void remoteSearchUsers('')
}
function ensureEnvDetectItemInList(
list: EnvDetectItem[],
itemId: string,
itemName?: string,
itemCode?: string
): EnvDetectItem[] {
const id = String(itemId || '').trim()
if (!id || list.some((item) => item.id === id)) return list
return [
{
id,
itemCode: itemCode || id,
itemName: itemName || id,
status: '1',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
},
...list
]
}
async function remoteSearchFilterItems(query: string) {
itemOptionsLoading.value = true
try {
const active = await fetchEnvDetectItemsActive()
activeItemOptions.value = active || []
itemOptions.value = active || []
const res = await fetchEnvDetectItemList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
let list = res.list || []
if (searchForm.envDetectItemId) {
list = ensureEnvDetectItemInList(list, searchForm.envDetectItemId)
}
itemOptions.value = list
} catch {
itemOptions.value = []
} finally {
itemOptionsLoading.value = false
}
}
function onFilterItemSelectVisible(visible: boolean) {
if (visible) void remoteSearchFilterItems('')
}
async function remoteSearchActiveItems(query: string) {
activeItemOptionsLoading.value = true
try {
const res = await fetchEnvDetectItemList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
let list = res.list || []
if (formData.envDetectItemId) {
list = ensureEnvDetectItemInList(list, formData.envDetectItemId)
}
activeItemOptions.value = list
} catch {
activeItemOptions.value = []
itemOptions.value = []
} finally {
activeItemOptionsLoading.value = false
}
}
function onActiveItemSelectVisible(visible: boolean) {
if (visible) void remoteSearchActiveItems('')
}
function formatNowDatetime() {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
const handleSelectionChange = (rows: EnvDetectRecord[]) => {
selectedRows.value = rows
}
@@ -663,15 +790,15 @@
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增登记'
await Promise.all([loadUserOptions(), loadItemOptions()])
resetForm()
void remoteSearchUsers('')
void remoteSearchActiveItems('')
dialogVisible.value = true
}
async function handleEdit(row: EnvDetectRecord) {
isEdit.value = true
dialogTitle.value = '编辑登记'
await Promise.all([loadUserOptions(), loadItemOptions()])
editingId.value = row.id
formData.detectTime = row.detectTime
formData.inspectorIds = [...(row.inspectorIds || [])]
@@ -679,21 +806,15 @@
formData.resultDesc = row.resultDesc
formData.remark = row.remark || ''
attachmentFiles.value = urlsToAttachmentFiles(row.attachmentUrls)
await Promise.all([remoteSearchUsers(''), remoteSearchActiveItems('')])
mergeInspectorOptions(row.inspectorIds || [], row.inspectorNames || [])
if (
row.envDetectItemId &&
!activeItemOptions.value.some((item) => item.id === row.envDetectItemId)
) {
activeItemOptions.value = [
{
id: row.envDetectItemId,
itemCode: row.itemCode,
itemName: row.itemName,
status: '1',
orderNum: 0
},
...activeItemOptions.value
]
if (row.envDetectItemId) {
activeItemOptions.value = ensureEnvDetectItemInList(
activeItemOptions.value,
row.envDetectItemId,
row.itemName,
row.itemCode
)
}
dialogVisible.value = true
}
@@ -839,8 +960,7 @@
{ debounce: 300, deep: true }
)
onMounted(async () => {
await Promise.all([loadUserOptions(), loadItemOptions()])
onMounted(() => {
loadData()
})
</script>
@@ -667,15 +667,3 @@
}
}
</style>
<!-- 弹窗 teleport 到 body,顶距类名不可 scoped -->
<style lang="scss">
.lab-test-item-dialog-mask {
align-items: flex-start !important;
.el-overlay-dialog {
margin-top: 10px;
margin-bottom: auto;
}
}
</style>
@@ -1,45 +1,138 @@
<!-- 质检管理 — 我的质检任务 -->
<!-- 质检管理 — 我的质检任务(复制质检工单页改造:三 Tab + 完整筛选,仅展示本人任务) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div ref="pageRef" class="page-container my-qc-task-page" :style="pageStyle">
<section class="panel my-qc-task-panel">
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的质检任务</h2>
</div>
<div class="panel-pill-tabs">
<div class="panel-pill-tabs__track" role="tablist">
<button
v-for="tab in WORK_ORDER_TYPE_TABS"
:key="tab.name"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': activeTypeTab === tab.name }"
:aria-selected="activeTypeTab === tab.name"
@click="handleTabChange(tab.name)"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
<p class="filter-hint">{{ filterHint }}</p>
<div class="filter-grid my-qc-task-filter-main-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="任务号 / 项目 / 工单号 / 样本编码"
:placeholder="
activeTypeTab === 'temporary'
? '工单号'
: activeTypeTab === 'outbound'
? '工单号/申请单号/申请单名称'
: '工单号/样本编码/入库编码'
"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="工单状态">
<el-select
v-model="searchForm.workOrderStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="待配置" value="dpz" />
<el-option label="已配置" value="ypz" />
<el-option label="已完成" value="ywc" />
<el-option label="已作废" value="yzf" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.taskStatus" clearable placeholder="全部">
<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>
<el-form-item label="任务类型">
<el-select v-model="searchForm.taskType" clearable placeholder="全部">
<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.qcBizType" clearable placeholder="全部">
<el-option label="入库质检" value="inbound" />
<el-option label="出库质检" value="outbound" />
<el-option label="临时质检" value="temporary" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">仅展示指派给当前登录人的质检任务。</span>
<div
v-show="filterExpanded"
id="my-qc-task-filter-extra"
class="filter-extra"
>
<div class="filter-grid my-qc-task-filter-extra-grid">
<el-form-item v-if="activeTypeTab === 'inbound'" label="触发来源">
<el-select
v-model="searchForm.triggerSource"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="样本接收" value="reception" />
<el-option label="生产过程" value="production" />
</el-select>
</el-form-item>
<el-form-item v-if="activeTypeTab === 'outbound'" label="业务类型">
<el-select v-model="searchForm.bizType" placeholder="全部" clearable style="width: 100%">
<el-option label="中试服务" value="pilot_service" />
<el-option label="临床应用" value="clinical_application" />
<el-option label="基础实验" value="basic_experiment" />
</el-select>
</el-form-item>
<el-form-item :label="filterTimeLabel">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
</div>
<div class="filter-actions">
<el-button
link
type="primary"
class="btn-filter-toggle"
:aria-expanded="String(filterExpanded)"
aria-controls="my-qc-task-filter-extra"
@click="filterExpanded = !filterExpanded"
>
<el-icon class="btn-filter-toggle-ic">
<ArrowDown v-if="!filterExpanded" />
<ArrowUp v-else />
</el-icon>
{{ filterExpanded ? '收起更多条件' : '展开更多条件' }}
</el-button>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
@@ -52,168 +145,262 @@
</el-form>
</div>
<div class="list-body list-wrap my-qc-task-list">
<div class="my-qc-task-cards">
<QcWorkOrderTaskSubList
list-mode="mine"
:mine-filters="appliedFilters"
:page="pagination.page"
:page-size="pagination.pageSize"
:refresh-token="refreshToken"
@update:total="pagination.total = $event"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[30, 60, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<QcWorkOrderCardList
page-mode="mine"
:rows="tableData"
:loading="loading"
:active-type-tab="activeTypeTab"
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:task-refresh-token="taskSubListRefreshToken"
:preset-tasks-by-work-order-id="presetTasksByWorkOrderId"
@view="handleView"
@tasks-changed="handleWorkOrderTasksChanged"
@page-change="onWorkOrderPageChange"
@page-size-change="onWorkOrderPageSizeChange"
/>
<QcWorkOrderViewDialogHost ref="viewDialogHostRef" />
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import QcWorkOrderTaskSubList from '../qc-work-order/QcWorkOrderTaskSubList.vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Search, RefreshLeft, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import {
fetchAllMineTasks,
groupMineTasksByWorkOrder,
enrichMineEntriesWithWorkOrders,
filterMineEntriesByWorkOrderSearch,
paginateMineEntries,
buildMineTableRows
} from '@/utils/qc-mine-task-groups'
import QcWorkOrderCardList from '../qc-work-order/QcWorkOrderCardList.vue'
import QcWorkOrderViewDialogHost from '../qc-work-order/QcWorkOrderViewDialogHost.vue'
defineOptions({ name: 'QualityInspectionMyQcTask' })
const PAGE_BOTTOM_GAP = 20
const taskSubListRefreshToken = ref(0)
const viewDialogHostRef = ref<InstanceType<typeof QcWorkOrderViewDialogHost> | null>(null)
const pageRef = ref<HTMLElement>()
const pageStyle = ref<Record<string, string>>({})
let pageResizeObserver: ResizeObserver | undefined
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound'
function updatePageHeight() {
const el = pageRef.value
if (!el) return
const top = el.getBoundingClientRect().top
const layoutContent = el.closest('.layout-content') as HTMLElement | null
const paddingBottom = layoutContent
? Number.parseInt(getComputedStyle(layoutContent).paddingBottom, 10) || 0
: 0
const height = Math.max(200, window.innerHeight - top - paddingBottom - PAGE_BOTTOM_GAP)
pageStyle.value = {
height: `${height}px`,
overflow: 'hidden'
}
}
const WORK_ORDER_TYPE_TABS = [
{ name: 'inbound', label: '入库质检工单' },
{ name: 'outbound', label: '出库质检工单' },
{ name: 'temporary', label: '临时质检工单' }
] as const
function bindPageHeightListeners() {
updatePageHeight()
window.addEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = new ResizeObserver(() => updatePageHeight())
const panel = pageRef.value?.querySelector('.my-qc-task-panel')
const filterBlock = pageRef.value?.querySelector('.filter-block')
const pagerBar = pageRef.value?.querySelector('.list-pager-bar')
if (panel) pageResizeObserver.observe(panel)
if (filterBlock) pageResizeObserver.observe(filterBlock)
if (pagerBar) pageResizeObserver.observe(pagerBar)
}
function unbindPageHeightListeners() {
window.removeEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = undefined
}
onMounted(() => {
nextTick(bindPageHeightListeners)
const activeTypeTab = ref<WorkOrderTypeTab>('inbound')
const filterExpanded = ref(false)
const filterTimeLabel = computed(() =>
activeTypeTab.value === 'outbound' ? '输注时间' : '创建时间'
)
const filterHint = computed(() => {
const timeHint =
activeTypeTab.value === 'outbound'
? '默认展示近 30 天内输注的工单。'
: '默认展示近 30 天内创建的工单。'
return `仅展示指派给当前登录人的质检任务。${timeHint}`
})
onUnmounted(unbindPageHeightListeners)
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({
keyword: '',
workOrderStatus: '',
taskStatus: '',
taskType: '',
qcBizType: ''
bizType: '',
triggerSource: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const tableData = ref<QcWorkOrder[]>([])
const presetTasksByWorkOrderId = ref<Record<string, QcTask[]>>({})
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const appliedFilters = reactive({
keyword: '',
taskStatus: '',
taskType: '',
qcBizType: ''
})
function buildWorkOrderSearchFilters() {
const timeParams =
activeTypeTab.value === 'outbound'
? {
infusionTimeStart: createTimeRange.value?.[0] || undefined,
infusionTimeEnd: createTimeRange.value?.[1] || undefined
}
: {
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined
}
return {
keyword: searchForm.keyword || undefined,
workOrderStatus: searchForm.workOrderStatus || undefined,
bizType: searchForm.bizType || undefined,
triggerSource:
activeTypeTab.value === 'inbound' && searchForm.triggerSource
? searchForm.triggerSource
: undefined,
activeTypeTab: activeTypeTab.value,
...timeParams
}
}
const refreshToken = ref(0)
const pagination = reactive({
page: 1,
pageSize: 30,
total: 0
})
function syncAppliedFilters() {
appliedFilters.keyword = searchForm.keyword.trim()
appliedFilters.taskStatus = searchForm.taskStatus
appliedFilters.taskType = searchForm.taskType
appliedFilters.qcBizType = searchForm.qcBizType
async function loadData() {
loading.value = true
try {
const tasks = await fetchAllMineTasks({
keyword: searchForm.keyword || undefined,
taskStatus: searchForm.taskStatus || undefined,
taskType: searchForm.taskType || undefined,
qcBizType: activeTypeTab.value
})
let entries = groupMineTasksByWorkOrder(tasks)
entries = await enrichMineEntriesWithWorkOrders(entries)
entries = filterMineEntriesByWorkOrderSearch(entries, buildWorkOrderSearchFilters())
const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize)
const tableModel = buildMineTableRows(pageResult.items)
tableData.value = tableModel.rows
presetTasksByWorkOrderId.value = tableModel.presetTasksByWorkOrderId
pagination.total = pageResult.total
} finally {
loading.value = false
}
}
function handleSearch() {
syncAppliedFilters()
pagination.page = 1
refreshToken.value += 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.workOrderStatus = ''
searchForm.taskStatus = ''
searchForm.taskType = ''
searchForm.qcBizType = ''
handleSearch()
}
function handlePageChange() {
refreshToken.value += 1
}
function handleSizeChange() {
searchForm.bizType = ''
searchForm.triggerSource = ''
createTimeRange.value = getDefaultLast30DaysRange()
filterExpanded.value = false
pagination.page = 1
refreshToken.value += 1
loadData()
}
function handleTypeTabChange() {
pagination.page = 1
filterExpanded.value = false
if (activeTypeTab.value !== 'inbound') {
searchForm.triggerSource = ''
}
if (activeTypeTab.value !== 'outbound') {
searchForm.bizType = ''
}
loadData()
}
function handleTabChange(name: WorkOrderTypeTab) {
if (activeTypeTab.value === name) return
activeTypeTab.value = name
handleTypeTabChange()
}
async function handleWorkOrderTasksChanged() {
taskSubListRefreshToken.value += 1
await loadData()
}
function onWorkOrderPageChange(page: number) {
pagination.page = page
loadData()
}
function onWorkOrderPageSizeChange(size: number) {
pagination.page = 1
pagination.pageSize = size
loadData()
}
function handleView(row: QcWorkOrder) {
viewDialogHostRef.value?.openView(row)
}
watchDebounced(
[searchForm, createTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.my-qc-task-page {
display: flex;
flex-direction: column;
box-sizing: border-box;
.my-qc-task-filter-main-grid,
.my-qc-task-filter-extra-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.my-qc-task-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.my-qc-task-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-top: 12px;
}
.my-qc-task-cards {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@media (max-width: 1100px) {
.my-qc-task-filter-main-grid,
.my-qc-task-filter-extra-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.my-qc-task-filter-main-grid,
.my-qc-task-filter-extra-grid {
grid-template-columns: 1fr;
}
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
.filter-extra {
padding-top: 10px;
margin-top: 8px;
border-top: 0;
}
.filter-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
margin-top: 10px;
border-top: 0;
}
.btn-filter-toggle {
height: 36px;
padding: 0 4px;
font-weight: 600;
}
.btn-filter-toggle-ic {
margin-right: 4px;
}
</style>
@@ -15,9 +15,14 @@
<el-form-item label="物料筛选">
<el-select
v-model="searchForm.materialId"
placeholder="全部物料"
placeholder="输入物料名称检索"
clearable
filterable
remote
reserve-keyword
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
@visible-change="onMaterialSelectVisible"
>
<el-option
v-for="m in materialOptions"
@@ -352,7 +357,7 @@
fetchMarkQcMaterialUsageFullyUsed
} from '@/api/qc-material-usage'
import type { QcMaterialUsage } from '@/api/qc-material-usage'
import { fetchQcMaterialOptions } from '@/api/qc-material'
import { fetchQcMaterialList } from '@/api/qc-material'
import type { QcMaterialOption } from '@/api/qc-material'
import { formatMoneyDisplay } from '@/utils/money-format'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
@@ -385,6 +390,7 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const materialOptions = ref<QcMaterialOption[]>([])
const materialOptionsLoading = ref(false)
function isManualOutRow(row: QcMaterialUsage) {
if (row.sourceType === 'manual_out') return true
@@ -503,15 +509,38 @@
}
}
async function loadMaterialOptions() {
function mapQcMaterialListToOptions(list: Array<Record<string, any>>): QcMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchQcMaterialOptions()
materialOptions.value = res || []
const res = await fetchQcMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
materialOptions.value = mapQcMaterialListToOptions(res.list || [])
} catch {
materialOptions.value = []
} finally {
materialOptionsLoading.value = false
}
}
function onMaterialSelectVisible(visible: boolean) {
if (visible) void remoteSearchMaterials('')
}
async function loadData() {
loading.value = true
try {
@@ -661,13 +690,13 @@
})
onMounted(async () => {
await loadMaterialOptions()
if (route.query.materialId) {
searchForm.materialId = route.query.materialId as string
}
if (route.query.stockInNo) {
searchForm.keyword = route.query.stockInNo as string
}
await remoteSearchMaterials('')
loadData()
})
</script>
@@ -10,6 +10,10 @@
:group-index="cardIndex(idx)"
active-type-tab="inbound"
:task-refresh-token="taskRefreshToken"
:show-work-order-ops="!isMineMode"
:show-work-order-view-only="isMineMode"
:task-list-mode="isMineMode ? 'mine' : 'workOrder'"
:preset-tasks-by-work-order-id="presetTasksByWorkOrderId"
@configure="(row) => emit('configure', row)"
@view="(row) => emit('view', row)"
@command="(cmd, row) => emit('command', cmd, row)"
@@ -25,6 +29,10 @@
:active-type-tab="activeTypeTab"
:card-index="cardIndex(idx)"
:task-refresh-token="taskRefreshToken"
:preset-tasks="isMineMode ? presetTasksByWorkOrderId?.[row.id] : undefined"
:show-work-order-ops="!isMineMode"
:show-work-order-view-only="isMineMode"
:task-list-mode="isMineMode ? 'mine' : 'workOrder'"
@configure="(row) => emit('configure', row)"
@view="(row) => emit('view', row)"
@command="(cmd, row) => emit('command', cmd, row)"
@@ -34,7 +42,7 @@
<el-empty
v-if="!loading && !rows.length"
class="qc-work-order-cards-empty"
description="暂无质检工单"
:description="isMineMode ? '暂无指派给您的质检任务' : '暂无质检工单'"
:image-size="72"
/>
</div>
@@ -54,6 +62,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import { groupInboundQcWorkOrders } from '@/utils/qc-work-order-groups'
import QcWorkOrderSampleGroup from './QcWorkOrderSampleGroup.vue'
import QcWorkOrderSingleCard from './QcWorkOrderSingleCard.vue'
@@ -62,15 +71,24 @@
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound' | 'all'
const props = defineProps<{
rows: QcWorkOrder[]
loading: boolean
activeTypeTab: WorkOrderTypeTab
page: number
pageSize: number
total: number
taskRefreshToken: number
}>()
const props = withDefaults(
defineProps<{
rows: QcWorkOrder[]
loading: boolean
activeTypeTab: WorkOrderTypeTab
page: number
pageSize: number
total: number
taskRefreshToken: number
pageMode?: 'manage' | 'mine'
presetTasksByWorkOrderId?: Record<string, QcTask[]>
}>(),
{
pageMode: 'manage'
}
)
const isMineMode = computed(() => props.pageMode === 'mine')
const emit = defineEmits<{
configure: [row: QcWorkOrder]
@@ -28,7 +28,7 @@
{{ summary.statusHint }}
</span>
<el-tag
v-if="summary.overallPayStatusLabel"
v-if="!hideOverallPayStatus && summary.overallPayStatusLabel"
:type="overallStatusTagType"
size="small"
class="qc-protocol-payment__compact-status"
@@ -41,6 +41,7 @@
v-for="(row, index) in detailRows"
:key="row.installmentNo ?? index"
class="qc-protocol-payment__detail-chip"
:class="{ 'qc-protocol-payment__detail-chip--head-only': hideInstallmentDetailAmounts && isInstallment }"
>
<div class="qc-protocol-payment__detail-chip-head">
<span v-if="isInstallment && row.installmentLabel" class="qc-protocol-payment__detail-chip-label">
@@ -60,7 +61,10 @@
{{ row.payStatusLabel || payStatusLabel(row.payStatus || '') }}
</el-tag>
</div>
<div class="qc-protocol-payment__detail-chip-body">
<div
v-if="!(hideInstallmentDetailAmounts && isInstallment)"
class="qc-protocol-payment__detail-chip-body"
>
<span>应缴 {{ formatAmount(row.dueAmount) }}</span>
<span>实收 {{ formatAmount(row.transferAmount) }}</span>
</div>
@@ -172,6 +176,10 @@
embedded?: boolean
/** 列表卡片等窄区域:单行摘要 + 紧凑明细,不展示表格 */
compact?: boolean
/** 紧凑摘要行隐藏整体缴费状态 Tag */
hideOverallPayStatus?: boolean
/** 分期明细 chip 隐藏应缴/实收金额 */
hideInstallmentDetailAmounts?: boolean
}>()
const isInstallment = computed(() => props.summary?.payMode === 'fq')
@@ -319,6 +327,10 @@
margin-bottom: 4px;
}
.qc-protocol-payment__detail-chip--head-only .qc-protocol-payment__detail-chip-head {
margin-bottom: 0;
}
.qc-protocol-payment__detail-chip-label {
font-size: 12px;
font-weight: 600;
@@ -41,6 +41,10 @@
:row="activeRow"
:active-type-tab="activeTypeTab"
:task-refresh-token="taskRefreshToken"
:show-work-order-ops="showWorkOrderOps"
:show-work-order-view-only="showWorkOrderViewOnly"
:task-list-mode="taskListMode"
:preset-tasks="activePresetTasks"
@configure="(row) => emit('configure', row)"
@view="(row) => emit('view', row)"
@command="(cmd, row) => emit('command', cmd, row)"
@@ -62,16 +66,28 @@
type QcWorkOrderSampleGroupModel,
triggerSourceTabLabel
} from '@/utils/qc-work-order-groups'
import type { QcTask } from '@/api/qc-task'
import QcWorkOrderSingleCard from './QcWorkOrderSingleCard.vue'
defineOptions({ name: 'QcWorkOrderSampleGroup' })
const props = defineProps<{
group: QcWorkOrderSampleGroupModel
groupIndex: number
activeTypeTab: 'inbound' | 'temporary' | 'outbound' | 'all'
taskRefreshToken: number
}>()
const props = withDefaults(
defineProps<{
group: QcWorkOrderSampleGroupModel
groupIndex: number
activeTypeTab: 'inbound' | 'temporary' | 'outbound' | 'all'
taskRefreshToken: number
showWorkOrderOps?: boolean
showWorkOrderViewOnly?: boolean
taskListMode?: 'workOrder' | 'mine'
presetTasksByWorkOrderId?: Record<string, QcTask[]>
}>(),
{
showWorkOrderOps: true,
showWorkOrderViewOnly: false,
taskListMode: 'workOrder'
}
)
const emit = defineEmits<{
configure: [row: QcWorkOrder]
@@ -96,6 +112,12 @@
props.group.workOrders.find((w) => w.id === activeWorkOrderId.value)
)
const activePresetTasks = computed(() => {
const row = activeRow.value
if (!row || !props.presetTasksByWorkOrderId) return undefined
return props.presetTasksByWorkOrderId[row.id]
})
const contextRow = computed(() => {
const reception =
props.group.workOrders.find((w) => (w.triggerSource || 'reception') === 'reception') ||
@@ -188,7 +188,16 @@
</div>
</dl>
</div>
<div v-if="activeTypeTab !== 'all'" class="qc-work-order-card__ops">
<div
v-if="(showWorkOrderOps || showWorkOrderViewOnly) && activeTypeTab !== 'all'"
class="qc-work-order-card__ops"
>
<template v-if="showWorkOrderViewOnly">
<el-button class="btn-op-item primary" type="primary" @click="emit('view', row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
<template v-else-if="showWorkOrderOps">
<el-button
v-if="canConfigureWorkOrder(row)"
class="btn-op-item primary"
@@ -226,6 +235,7 @@
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</div>
<QcWorkOrderProtocolPaymentPanel
@@ -245,7 +255,9 @@
<QcWorkOrderTaskSubList
embedded
:work-order="row"
:list-mode="taskListMode"
:refresh-token="taskRefreshToken"
:preset-tasks="presetTasks"
@changed="emit('tasks-changed')"
/>
</section>
@@ -256,6 +268,7 @@
import { computed } from 'vue'
import { Setting, View, More, EditPen, Delete } from '@element-plus/icons-vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import {
workOrderStatusLabel,
receptionSourceLabel,
@@ -279,13 +292,28 @@
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound' | 'all'
type CardLayout = 'standalone' | 'inbound-grouped'
const props = defineProps<{
row: QcWorkOrder
activeTypeTab: WorkOrderTypeTab
layout?: CardLayout
cardIndex?: number | null
taskRefreshToken: number
}>()
const props = withDefaults(
defineProps<{
row: QcWorkOrder
activeTypeTab: WorkOrderTypeTab
layout?: CardLayout
cardIndex?: number | null
taskRefreshToken: number
/** 我的任务等场景:不展示工单级操作按钮 */
showWorkOrderOps?: boolean
/** 我的任务等场景:仅展示「查看」按钮 */
showWorkOrderViewOnly?: boolean
/** 传入则不再请求任务列表 */
presetTasks?: QcTask[]
/** 内嵌任务区的列表模式(我的任务页须为 mine 以走负责人校验与 operateScope) */
taskListMode?: 'workOrder' | 'mine'
}>(),
{
showWorkOrderOps: true,
showWorkOrderViewOnly: false,
taskListMode: 'workOrder'
}
)
const emit = defineEmits<{
configure: [row: QcWorkOrder]
@@ -4,221 +4,114 @@
<div
class="qc-work-order-task-sublist"
:class="{
'qc-work-order-task-sublist--mine': listMode === 'mine',
'qc-work-order-task-sublist--embedded': embedded
}"
>
<div class="qc-work-order-task-sublist__panel" v-loading="loading">
<div
v-if="tableData.length"
class="qc-task-cards-wrap"
:class="{ 'qc-task-cards-wrap--mine': listMode === 'mine' }"
>
<div v-if="listMode !== 'mine'" class="qc-work-order-task-sublist__head">
<div class="qc-work-order-task-sublist__head-title">质检任务</div>
<div class="qc-work-order-task-sublist__head-extra">
<span class="qc-work-order-task-sublist__count">共 {{ tableData.length }} 项任务</span>
<el-button
class="qc-work-order-task-sublist__refresh-icon"
link
type="primary"
:loading="loading"
@click="loadData"
>
<el-icon><RefreshLeft /></el-icon>
</el-button>
</div>
</div>
<div
v-if="listMode === 'mine'"
class="qc-task-cards-row"
>
<div
v-for="(row, idx) in tableData"
:key="row.id || idx"
class="qc-task-card"
@dblclick="handleView(row)"
>
<div class="qc-task-card__name">{{ row.itemName || row.taskNo || '—' }}</div>
<div class="qc-task-card__code">{{ row.taskNo || '—' }}</div>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
{{ taskStatusLabel(row.taskStatus) }}
</el-tag>
<el-tag :type="taskTypeTagType(row.taskType)" size="small">
{{ taskTypeLabel(row.taskType) }}
</el-tag>
<el-tag
v-if="row.conclusion === 'tg' || row.conclusion === 'btg'"
:type="conclusionTagType(row.conclusion)"
size="small"
>
{{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待结论</el-tag>
</div>
<div class="qc-task-card__context">
<span v-if="row.workOrderNo" class="qc-task-card__context-item"
>工单 {{ row.workOrderNo }}</span
>
<span v-if="row.sampleCode" class="qc-task-card__context-item"
>样本 {{ row.sampleCode }}</span
>
<el-tag
v-if="row.qcBizType"
:type="qcBizTypeTagType(row.qcBizType)"
size="small"
>
{{ row.qcBizTypeLabel || qcBizTypeLabel(row.qcBizType) }}
</el-tag>
</div>
<div class="qc-task-card__assignees">
{{ row.assigneeUserNames ? `负责人:${row.assigneeUserNames}` : '负责人:—' }}
</div>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(row)">
<el-button
v-if="canStartTask(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleStart(row)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="canFillResult(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openResult(row)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
</template>
<div v-if="tableData.length" class="qc-task-cards-wrap">
<div v-if="showTaskSublistHead" class="qc-work-order-task-sublist__head">
<div class="qc-work-order-task-sublist__head-title">质检任务</div>
<div class="qc-work-order-task-sublist__head-extra">
<span class="qc-work-order-task-sublist__count">共 {{ tableData.length }} 项任务</span>
<el-button
v-else
class="btn-op-item primary"
class="qc-work-order-task-sublist__refresh-icon"
link
type="primary"
size="small"
@click.stop="handleView(row)"
:loading="loading"
@click="loadData"
>
<el-icon><View /></el-icon>查看
<el-icon><RefreshLeft /></el-icon>
</el-button>
<el-dropdown
v-if="hasMoreTaskActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item" size="small" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<div v-else class="qc-task-cards-grid">
<div
v-for="(row, idx) in tableData"
:key="row.id || idx"
class="qc-task-card qc-task-card--compact"
@dblclick="handleView(row)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ row.itemName || row.taskNo || '—' }}</span>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
{{ taskStatusLabel(row.taskStatus) }}
</el-tag>
<el-tag :type="taskTypeTagType(row.taskType)" size="small">
{{ taskTypeLabel(row.taskType) }}
</el-tag>
<el-tag
v-if="row.conclusion === 'tg' || row.conclusion === 'btg'"
:type="conclusionTagType(row.conclusion)"
size="small"
>
{{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待结论</el-tag>
<div class="qc-task-cards-grid">
<div
v-for="(row, idx) in tableData"
:key="row.id || idx"
class="qc-task-card qc-task-card--compact"
@dblclick="handleView(row)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ row.itemName || row.taskNo || '—' }}</span>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
{{ taskStatusLabel(row.taskStatus) }}
</el-tag>
<el-tag :type="taskTypeTagType(row.taskType)" size="small">
{{ taskTypeLabel(row.taskType) }}
</el-tag>
<el-tag
v-if="row.conclusion === 'tg' || row.conclusion === 'btg'"
:type="conclusionTagType(row.conclusion)"
size="small"
>
{{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待结论</el-tag>
</div>
</div>
</div>
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__code">{{ row.taskNo || '—' }}</span>
<span class="qc-task-card__sep">·</span>
<span class="qc-task-card__assignees">{{
row.assigneeUserNames || '—'
}}</span>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(row)">
<el-button
v-if="canStartTask(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleStart(row)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="canFillResult(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openResult(row)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
</template>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleView(row)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreTaskActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item" size="small" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__code">{{ row.taskNo || '—' }}</span>
<span class="qc-task-card__sep">·</span>
<span class="qc-task-card__assignees">{{
row.assigneeUserNames || '—'
}}</span>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(row)">
<el-button
v-if="canStartTask(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleStart(row)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="canFillResult(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openResult(row)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
</template>
</el-dropdown>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleView(row)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreTaskActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item" size="small" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="!loading"
class="qc-task-cards-wrap qc-task-cards-wrap--empty"
:class="{ 'qc-task-cards-wrap--mine': listMode === 'mine' }"
>
<div v-if="listMode !== 'mine'" class="qc-work-order-task-sublist__head">
<div v-else-if="!loading" class="qc-task-cards-wrap qc-task-cards-wrap--empty">
<div v-if="showTaskSublistHead" class="qc-work-order-task-sublist__head">
<div class="qc-work-order-task-sublist__head-title">质检任务</div>
<div class="qc-work-order-task-sublist__head-extra">
<span class="qc-work-order-task-sublist__count">共 0 项任务</span>
@@ -235,9 +128,7 @@
</div>
<el-empty
class="qc-work-order-task-sublist__empty"
:description="
listMode === 'mine' ? '暂无指派给您的质检任务' : '暂无任务,请先在工单行「配置检测」'
"
description="暂无任务,请先在工单行「配置检测」"
:image-size="56"
/>
</div>
@@ -404,15 +295,17 @@
</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="form-grid-2 result-conclusion-row">
<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>
<div class="result-attachment-block">
<ProtocolFinanceConfirmFields
v-model:finance-remark="resultRemark"
@@ -598,7 +491,6 @@
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import {
fetchQcTaskList,
fetchMyQcTaskList,
fetchQcTaskDetail,
fetchStartQcTask,
fetchDeleteQcTask,
@@ -661,43 +553,31 @@
defineOptions({ name: 'QcWorkOrderTaskSubList' })
/** 我的任务页一次加载上限(后端 pageSize 最大 100) */
const MINE_TASK_MAX_PAGE_SIZE = 100
type QcTaskMineFilters = {
keyword?: string
taskType?: string
taskStatus?: string
qcBizType?: string
}
const props = withDefaults(
defineProps<{
workOrder?: Record<string, any>
/** workOrder:工单展开行;mine:我的质检任务 */
/** workOrder:工单管理;mine:我的任务内嵌(仅影响 operateScope 与按钮显隐) */
listMode?: 'workOrder' | 'mine'
mineFilters?: QcTaskMineFilters
page?: number
pageSize?: number
/** 父级配置检测成功后递增,用于刷新已展开子列表 */
refreshToken?: number
/** 内嵌于工单卡片时收紧布局 */
embedded?: boolean
/** 父级传入任务列表时不再请求接口 */
presetTasks?: QcTask[]
}>(),
{
listMode: 'workOrder',
page: 1,
pageSize: 30
listMode: 'workOrder'
}
)
const emit = defineEmits<{
changed: []
'update:total': [number]
}>()
const userStore = useUserStore()
const listMode = computed(() => props.listMode || 'workOrder')
const usesPresetTasks = computed(() => props.presetTasks !== undefined)
const showTaskSublistHead = computed(() => props.embedded || listMode.value !== 'mine')
const operateScope = computed<QcTaskOperateScope>(() =>
listMode.value === 'mine' ? 'mine' : 'manage'
)
@@ -819,7 +699,7 @@
return n
}
const resultDialogWidth = computed(() => (resultTaskType.value === 'sysz' ? '970px' : '720px'))
const resultDialogWidth = computed(() => (resultTaskType.value === 'sysz' ? '1200px' : '960px'))
function canFillResult(row: QcTask) {
if (row.taskType === 'sysz') {
@@ -1295,23 +1175,8 @@
}
async function loadData() {
if (listMode.value === 'mine') {
loading.value = true
try {
const f = props.mineFilters || {}
const res = await fetchMyQcTaskList({
page: props.page,
pageSize: Math.min(props.pageSize, MINE_TASK_MAX_PAGE_SIZE),
keyword: f.keyword,
taskType: f.taskType,
taskStatus: f.taskStatus,
qcBizType: f.qcBizType
})
tableData.value = res.list || []
emit('update:total', res.total || 0)
} finally {
loading.value = false
}
if (usesPresetTasks.value) {
emit('changed')
return
}
const workOrderId = props.workOrder?.id
@@ -1505,19 +1370,20 @@
}
watch(
() =>
listMode.value === 'mine'
? ([
props.page,
props.pageSize,
props.refreshToken,
props.mineFilters?.keyword,
props.mineFilters?.taskType,
props.mineFilters?.taskStatus,
props.mineFilters?.qcBizType
] as const)
: ([props.workOrder?.id, props.refreshToken] as const),
() => props.presetTasks,
(tasks) => {
if (tasks !== undefined) {
tableData.value = tasks
loading.value = false
}
},
{ immediate: true, deep: true }
)
watch(
() => [props.workOrder?.id, props.refreshToken] as const,
() => {
if (usesPresetTasks.value) return
loadData()
},
{ immediate: true }
@@ -1530,57 +1396,6 @@
background: #f8fafc;
text-align: left;
&--mine {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
background: transparent;
.qc-work-order-task-sublist__panel {
flex: 1;
min-height: 0;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.qc-task-cards-wrap--mine {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
padding: 0 20px 4px;
box-sizing: border-box;
}
.qc-task-cards-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
align-content: start;
width: 100%;
}
.qc-task-card {
width: 100%;
max-width: none;
flex: unset;
}
.qc-task-cards-wrap--empty {
display: flex;
flex: 1;
min-height: 100%;
align-items: center;
justify-content: center;
padding: 0 20px;
box-sizing: border-box;
}
}
&--embedded {
padding: 0;
background: transparent;
@@ -1668,12 +1483,9 @@
}
}
.qc-task-cards-wrap--empty:not(.qc-task-cards-wrap--mine) {
.qc-task-cards-wrap--empty {
flex: 1;
min-height: 204px;
}
.qc-task-cards-wrap--empty {
flex-direction: column;
align-items: stretch;
@@ -1681,25 +1493,16 @@
width: 100%;
flex-shrink: 0;
}
}
.qc-task-cards-wrap--empty.qc-task-cards-wrap--mine {
min-height: 0;
}
.qc-task-cards-wrap--empty:not(.qc-task-cards-wrap--mine) .qc-work-order-task-sublist__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0;
}
.qc-task-cards-wrap--empty.qc-task-cards-wrap--mine .qc-work-order-task-sublist__empty {
align-self: center;
padding: 8px 0 4px;
.qc-work-order-task-sublist__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0;
}
}
.qc-work-order-task-sublist__empty {
@@ -1923,12 +1726,13 @@
.result-conclusion-section {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 16px;
.section-title {
margin-bottom: 0;
margin-bottom: 16px;
}
.result-conclusion-row {
margin-bottom: 16px;
}
:deep(.el-form-item) {
@@ -0,0 +1,157 @@
<!-- 质检工单 · 查看详情弹窗宿主(入库/临时 SystemViewDialog + 出库 OutboundQcViewDialog) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="viewVisible"
title="工单详情"
width="1120px"
:grid-columns="4"
compact
:loading="viewLoading"
:icon="Document"
:meta="detail?.workOrderNo || ''"
:sections="viewSections"
modal-class="qc-work-order-view-dialog-mask"
>
<template #protocol-payment-details>
<QcWorkOrderProtocolPaymentPanel :summary="detail?.paymentSummary" embedded />
</template>
<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 v-for="task in viewTasksWithAttachments" #[taskAttachmentSlotName(task.id)]>
<div :key="task.id" class="qc-work-order-task-attachments">
<ProtocolAttachmentReadonly
:files="task.attachmentFiles || []"
@preview="handleTaskAttachmentPreview"
@download="handleTaskAttachmentDownload"
/>
</div>
</template>
<template v-for="task in viewLabTasks" #[taskMaterialSlotName(task.id)]>
<div v-if="task.materialUsages?.length" :key="task.id" class="view-subtable-wrap">
<el-table :data="task.materialUsages" 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="stockInNo"
label="入库编号"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column label="批号" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.batchNo || '—' }}</template>
</el-table-column>
<el-table-column label="领用人" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.receiverName || '—' }}</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 :key="`${task.id}-empty`" class="view-empty-hint">暂无物料使用记录</p>
</template>
</SystemViewDialog>
<OutboundQcViewDialog v-model:visible="outboundViewVisible" :work-order-id="outboundViewId" />
</template>
<script setup lang="ts">
import { Document } from '@element-plus/icons-vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import OutboundQcViewDialog from './OutboundQcViewDialog.vue'
import QcWorkOrderProtocolPaymentPanel from './QcWorkOrderProtocolPaymentPanel.vue'
import { useQcWorkOrderViewDialog } from './useQcWorkOrderViewDialog'
defineOptions({ name: 'QcWorkOrderViewDialogHost' })
const {
viewVisible,
viewLoading,
detail,
viewSections,
viewTasksWithAttachments,
viewLabTasks,
viewReceptionAttachmentFiles,
outboundViewVisible,
outboundViewId,
taskAttachmentSlotName,
taskMaterialSlotName,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
handleTaskAttachmentPreview,
handleTaskAttachmentDownload,
openView
} = useQcWorkOrderViewDialog()
defineExpose({ openView })
</script>
<style scoped lang="scss">
.view-reception-attachments {
width: 100%;
}
.view-kv {
dt {
margin: 0 0 6px;
font-size: 11px;
color: #94a3b8;
}
dd {
margin: 0;
}
&.view-kv-span-2 {
grid-column: 1 / -1;
}
}
.view-subtable-wrap {
width: 100%;
overflow-x: auto;
}
.view-empty-hint {
margin: 0;
font-size: 13px;
color: #94a3b8;
}
</style>
@@ -0,0 +1,498 @@
import { ref, computed } from 'vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { fetchQcWorkOrderDetail, type QcWorkOrder, type QcWorkOrderDetail } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import {
fetchQcTaskResults,
fetchQcTaskMaterialUsages,
type QcTaskResultItem,
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import {
workOrderStatusLabel,
receptionSourceLabel,
acceptConclusionLabel,
transportTypeLabel,
taskStatusLabel,
qcTaskConclusionLabel,
qcBizTypeLabel
} from '@/utils/qc-labels'
import {
protocolStatusLabel,
protocolStatusTagType,
donationStatusLabel,
donationStatusTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import type { ViewField, ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
type ViewMaterialUsageRow = {
stockInNo: string
materialName: string
batchNo: string
receiverName: string
unit: string
qty: number | undefined
remark: string
}
type QcWorkOrderTaskView = QcTask & {
results?: QcTaskResultItem[]
materialUsages?: ViewMaterialUsageRow[]
}
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 materialRowsFromApi(items: QcTaskMaterialUsageItem[]): ViewMaterialUsageRow[] {
return (items || [])
.filter((u) => (u.requisitionUsageId || u.parentUsageId || '').trim())
.map((u) => ({
stockInNo: u.stockInNo || '',
materialName: u.materialName || '',
batchNo: u.batchNo || '',
receiverName: u.requisitionReceiverName || u.receiverName || '',
unit: (u.usageUnit || '').trim(),
qty: Math.abs(Number(u.changeQty) || 0) || undefined,
remark: u.remark || ''
}))
}
export function useQcWorkOrderViewDialog() {
const viewVisible = ref(false)
const viewLoading = ref(false)
const detail = ref<QcWorkOrderDetail | null>(null)
const viewTasks = ref<QcWorkOrderTaskView[]>([])
const outboundViewVisible = ref(false)
const outboundViewId = ref('')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewTasksWithAttachments = computed(() =>
viewTasks.value.filter((t) => (t.attachmentFiles?.length || 0) > 0)
)
const viewLabTasks = computed(() => viewTasks.value.filter((t) => t.taskType === 'sysz'))
function taskAttachmentSlotName(taskId: string) {
return `task-att-${taskId}`
}
function taskMaterialSlotName(taskId: string) {
return `task-mat-${taskId}`
}
const viewReceptionAttachmentFiles = computed(() => {
const r = detail.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 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 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(String(file.fileUrl || '')) })
}
function handleTaskAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleTaskAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl(String(file.fileUrl || '')) })
}
function conclusionTagType(conclusion?: string) {
if (conclusion === 'tg') return 'success' as const
if (conclusion === 'btg') return 'danger' as const
return 'info' as const
}
function taskStatusTagType(status?: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
return 'info' as const
}
function formatConfiguredLabItems(d: QcWorkOrderDetail) {
return d.labItems?.map((li) => `${li.itemName || ''}(${li.itemCode || ''})`).join('、') || '无'
}
function formatConfiguredThirdItems(d: QcWorkOrderDetail) {
return (
d.thirdPartyItems
?.map(
(li) =>
`${li.companyName || ''} — ${li.itemName || ''}(${li.feeAmount != null ? '¥' + formatMoneyDisplay(li.feeAmount) : '—'})`
)
.join('、') || '无'
)
}
function buildConfiguredOnlySection(
title: string,
configuredLabel: string,
configuredValue: string,
attachmentLabel: string
): ViewSection {
return {
title,
fields: [
{ label: configuredLabel, value: configuredValue, span: true },
{ label: '检测结论', value: '—' },
{ label: '检测参数', value: '—' },
{ label: '备注', value: '—' },
{ label: attachmentLabel, value: '—' }
]
}
}
function buildTaskTypeSections(
tasks: QcWorkOrderTaskView[],
taskType: 'sysz' | 'dsfjc',
sectionTitle: string
): ViewSection[] {
const filtered = tasks.filter((t) => t.taskType === taskType)
if (!filtered.length) return []
return filtered.flatMap((task, index) => {
const title = filtered.length > 1 ? `${sectionTitle}(${index + 1})` : sectionTitle
const taskInfoFields: ViewField[] = [
{ label: '检测项目', value: task.itemName || '—' },
{ label: '项目编码', value: task.itemCode || '—' },
{
label: '任务状态',
value: taskStatusLabel(task.taskStatus || ''),
tagType: taskStatusTagType(task.taskStatus)
},
{
label: '检测结论',
value: task.conclusionLabel || qcTaskConclusionLabel(task.conclusion || '') || '—',
tagType: conclusionTagType(task.conclusion)
},
{ label: '任务号', value: task.taskNo || '—' },
{ label: '完成时间', value: task.completedTime || '—' }
]
if (taskType === 'dsfjc') {
taskInfoFields.splice(2, 0, { label: '第三方公司', value: task.companyName || '—' })
if (task.feeAmount != null) {
taskInfoFields.push({ label: '费用(元)', value: String(task.feeAmount) })
}
}
const paramFields: ViewField[] = (task.results || []).map((item) => ({
label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
value: item.paramValue || '—'
}))
const remarkField: ViewField = { label: '备注', value: task.remark || '—', span: true }
const section: ViewSection =
taskType === 'sysz'
? {
title,
fields: [],
fieldGroups: [
{ fields: taskInfoFields },
{
title: '检测参数',
titleStyle: 'field',
fields: paramFields,
emptyHint: '—'
},
{ fields: [remarkField] }
]
}
: {
title,
fields: [
...taskInfoFields,
...(paramFields.length ? paramFields : [{ label: '检测参数', value: '—' as const }]),
remarkField
]
}
if (task.attachmentFiles?.length) {
section.slot = taskAttachmentSlotName(task.id)
section.slotTitle = taskType === 'dsfjc' ? '检测材料' : '检测附件'
section.slotTitleStyle = 'field'
} else if (taskType === 'dsfjc') {
section.fields!.push({ label: '检测材料', value: '—' })
} else if (taskType === 'sysz') {
section.fieldGroups?.push({ fields: [{ label: '检测附件', value: '—' }] })
} else {
section.fields!.push({ label: '检测附件', value: '—' })
}
const sections: ViewSection[] = [section]
if (taskType === 'sysz') {
sections.push({
title: '',
fields: [],
slotTitle: '物料使用',
slotTitleStyle: 'field',
slot: taskMaterialSlotName(task.id)
})
}
return sections
})
}
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[]>(() => {
if (!detail.value) return []
const d = detail.value
const isTemp = d.workOrderType === 'temporary'
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 transportSection =
transportFields.length > 1 || r.transportType
? [{ title: '运输信息', fields: transportFields }]
: []
const paymentSection =
!isTemp && (d.receptionSource || 'cell_storage') === 'cell_storage'
? [{ title: '协议缴费信息', fields: [], slot: 'protocol-payment-details' }]
: []
return [
{
title: '基本信息',
fields: [
{ label: '工单号', value: d.workOrderNo },
{
label: '工单类型',
value: isTemp ? '临时' : '入库',
tagType: isTemp ? 'warning' : 'success'
},
...(isTemp
? [
{
label: '业务类型',
value: d.qcBizTypeLabel || qcBizTypeLabel('temporary'),
tagType: 'warning' as const
},
{ label: '产品编码', value: d.cellProductCode || '—' },
{ label: '产品名称', value: d.cellProductName || '—' },
{ label: '产品类型', value: d.cellProductTypeName || '—' },
{ label: '代次', value: d.cellProductPassage || '—' },
{ label: '规格', value: d.cellProductSpecName || '—' }
]
: []),
...(isTemp
? []
: [
{
label: '协议状态',
value: protocolStatusLabelBySource(
String(d.protocolStatus || ''),
d.receptionSource || 'cell_storage'
),
tagType: protocolStatusTagTypeBySource(
String(d.protocolStatus || ''),
d.receptionSource || 'cell_storage'
)
},
{ label: '样本编码', value: d.sampleCode },
{
label: '来源类型',
value: d.receptionSourceLabel || receptionSourceLabel(d.receptionSource || ''),
tagType:
d.receptionSource === 'research_donation'
? ('warning' as const)
: ('success' as const)
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
]),
{
label: '状态',
value: workOrderStatusLabel(d.workOrderStatus),
tagType:
d.workOrderStatus === 'ywc'
? 'success'
: d.workOrderStatus === 'ypz'
? 'warning'
: 'info'
},
{ label: '备注', value: d.remark || '—', span: true },
{ label: '创建时间', value: d.createTime || '—' }
]
},
...(isTemp ? [] : transportSection),
...(isTemp ? [] : paymentSection),
...(isTemp
? []
: [
{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
...(String(r.acceptConclusion || '') === 'js'
? [
{
label: '实验室入库编码',
value: r.labStorageCode || d.labStorageCode || '—'
}
]
: []),
{
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 || '')),
tagType:
r.acceptConclusion === 'js'
? 'success'
: r.acceptConclusion === 'jj'
? 'danger'
: 'info'
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
}
]),
...(viewTasks.value.some((t) => t.taskType === 'sysz')
? buildTaskTypeSections(viewTasks.value, 'sysz', '实验室项目')
: d.hasLab
? [
buildConfiguredOnlySection(
'实验室项目',
'已配置项目',
formatConfiguredLabItems(d),
'检测附件'
)
]
: []),
...(viewTasks.value.some((t) => t.taskType === 'dsfjc')
? buildTaskTypeSections(viewTasks.value, 'dsfjc', '第三方项目')
: d.hasThirdParty
? [
buildConfiguredOnlySection(
'第三方项目',
'已配置项目',
formatConfiguredThirdItems(d),
'检测材料'
)
]
: [])
]
})
async function openInboundOrTempView(row: QcWorkOrder) {
viewVisible.value = true
viewLoading.value = true
viewTasks.value = []
try {
const d = await fetchQcWorkOrderDetail(row.id)
detail.value = d
const rawTasks = (d.tasks || []) as QcWorkOrderTaskView[]
viewTasks.value = await Promise.all(
rawTasks.map(async (task) => {
const base: QcWorkOrderTaskView = { ...task, results: [], materialUsages: [] }
if (task.taskStatus !== 'ywc') return base
try {
base.results = (await fetchQcTaskResults(task.id)) || []
} catch {
base.results = []
}
if (task.taskType === 'sysz') {
try {
base.materialUsages = materialRowsFromApi(await fetchQcTaskMaterialUsages(task.id))
} catch {
base.materialUsages = []
}
}
return base
})
)
} finally {
viewLoading.value = false
}
}
async function openView(row: QcWorkOrder) {
const woType = row.workOrderType || row.qcBizType || 'inbound'
if (woType === 'outbound') {
outboundViewId.value = row.id
outboundViewVisible.value = true
return
}
await openInboundOrTempView(row)
}
return {
viewVisible,
viewLoading,
detail,
viewSections,
viewTasksWithAttachments,
viewLabTasks,
viewReceptionAttachmentFiles,
outboundViewVisible,
outboundViewId,
taskAttachmentSlotName,
taskMaterialSlotName,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
handleTaskAttachmentPreview,
handleTaskAttachmentDownload,
openView
}
}
@@ -0,0 +1,444 @@
<!-- 样本接收 · 卡片列表(待接收 / 已接收) -->
<template>
<div class="list-body list-wrap sample-reception-cards-body">
<div v-loading="loading" class="sample-reception-cards-wrap">
<article
v-for="(row, idx) in rows"
:key="String(row.protocolSampleId || row.id || idx)"
class="sample-reception-card"
:class="{ 'is-eta-overdue': isPendingOverdue(row) }"
@dblclick="emit('card-dblclick', row)"
>
<header class="sample-reception-card__head">
<div class="sample-reception-card__head-main">
<div class="sample-reception-card__title-row">
<el-checkbox
v-if="activeTab === 'pending'"
:model-value="isSelected(String(row.protocolSampleId || ''))"
class="sample-reception-card__checkbox"
@change="(val) => emit('toggle-select', row, Boolean(val))"
@click.stop
/>
<span class="sample-reception-card__index">{{ cardIndex(idx) }}</span>
<span class="sample-reception-card__code">{{ row.sampleCode || '—' }}</span>
<el-tag
:type="
protocolStatusTagTypeBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage')
)
"
size="small"
>
{{
protocolStatusLabelBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage')
)
}}
</el-tag>
<el-tag
:type="row.sourceType === 'research_donation' ? 'warning' : 'success'"
size="small"
>
{{ row.sourceTypeLabel || '细胞存储' }}
</el-tag>
<el-tag
v-if="activeTab === 'received'"
:type="row.acceptConclusion === 'js' ? 'success' : 'danger'"
size="small"
>
{{ acceptConclusionLabel(String(row.acceptConclusion || '')) }}
</el-tag>
</div>
<dl class="sample-reception-card__meta">
<div class="sample-reception-card__meta-item">
<dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</dd>
</div>
<div
v-if="activeTab === 'received'"
class="sample-reception-card__meta-item"
>
<dt>实验室入库编码</dt>
<dd>
{{
row.acceptConclusion === 'js' ? row.labStorageCode || '—' : '—'
}}
</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>母亲姓名</dt>
<dd>{{ row.motherName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>新生儿姓名</dt>
<dd>{{ row.newbornName || '—' }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>样本采集时间</dt>
<dd>{{ row.sampleCollectTime || '—' }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>预计到达时间</dt>
<dd>{{ row.etaTime || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>运输方式</dt>
<dd>{{ transportTypeLabel(String(row.transportType || '')) }}</dd>
</div>
<template v-if="row.transportType === 'kdys'">
<div class="sample-reception-card__meta-item">
<dt>快递公司</dt>
<dd>{{ row.expressCompanyName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>快递单号</dt>
<dd>{{ row.waybillNo || '—' }}</dd>
</div>
</template>
<template v-else-if="row.transportType === 'ryxd'">
<div class="sample-reception-card__meta-item">
<dt>携带人</dt>
<dd>{{ row.carrierName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>携带人联系电话</dt>
<dd>{{ row.carrierPhone || '—' }}</dd>
</div>
</template>
<template v-else>
<div class="sample-reception-card__meta-item">
<dt>快递单号</dt>
<dd>{{ row.waybillNo || '—' }}</dd>
</div>
</template>
<template v-if="activeTab === 'received'">
<div class="sample-reception-card__meta-item">
<dt>接收时间</dt>
<dd>{{ row.receptionTime || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>经手人</dt>
<dd>{{ row.handlerName || '—' }}</dd>
</div>
</template>
</dl>
</div>
<div class="sample-reception-card__ops">
<QcWorkOrderProtocolPaymentPanel
v-if="showProtocolPayment(row)"
:summary="protocolPaymentSummary(row)"
compact
class="sample-reception-card__ops-payment"
/>
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
type="primary"
@click.stop="emit('receive', row)"
>
<el-icon><CircleCheck /></el-icon>样本接收
</el-button>
<template v-else>
<el-button class="btn-op-item" @click.stop="emit('view', row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click.stop="emit('edit', row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
</template>
</div>
</header>
</article>
<el-empty
v-if="!loading && !rows.length"
class="sample-reception-cards-empty"
:description="activeTab === 'pending' ? '暂无待接收样本' : '暂无已接收记录'"
:image-size="72"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(v) => emit('page-size-change', v)"
@current-change="(v) => emit('page-change', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { CircleCheck, View, EditPen } from '@element-plus/icons-vue'
import {
transportTypeLabel,
acceptConclusionLabel,
protocolStatusLabel,
protocolStatusTagType,
donationStatusLabel,
donationStatusTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
defineOptions({ name: 'SampleReceptionCardList' })
type ReceptionTab = 'pending' | 'received'
const props = defineProps<{
rows: Record<string, unknown>[]
loading: boolean
activeTab: ReceptionTab
page: number
pageSize: number
total: number
selectedIds: string[]
}>()
const emit = defineEmits<{
receive: [row: Record<string, unknown>]
view: [row: Record<string, unknown>]
edit: [row: Record<string, unknown>]
'toggle-select': [row: Record<string, unknown>, checked: boolean]
'card-dblclick': [row: Record<string, unknown>]
'page-change': [page: number]
'page-size-change': [size: number]
}>()
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 parseEtaLocalDate(eta: unknown): Date | null {
const s = String(eta || '').trim()
if (!s) return null
const normalized = s.replace('T', ' ').slice(0, 19)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!m) return null
const y = Number(m[1])
const mo = Number(m[2]) - 1
const d = Number(m[3])
const dt = new Date(y, mo, d)
if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null
return dt
}
function startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
function isEtaTodayOrEarlier(etaTime: unknown) {
const etaDay = parseEtaLocalDate(etaTime)
if (!etaDay) return false
return etaDay.getTime() <= startOfLocalDay(new Date()).getTime()
}
function isPendingOverdue(row: Record<string, unknown>) {
return props.activeTab === 'pending' && isEtaTodayOrEarlier(row.etaTime)
}
function cardIndex(i: number) {
return (props.page - 1) * props.pageSize + i + 1
}
function isSelected(id: string) {
return props.selectedIds.includes(id)
}
function showProtocolPayment(row: Record<string, unknown>) {
const source = String(row.sourceType || 'cell_storage').trim()
return source === 'cell_storage' || source === ''
}
function protocolPaymentSummary(row: Record<string, unknown>): QcProtocolPaymentSummary | null {
const summary = row.paymentSummary
if (!summary || typeof summary !== 'object') return null
return summary as QcProtocolPaymentSummary
}
</script>
<style scoped lang="scss">
.sample-reception-cards-body {
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.sample-reception-cards-wrap {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 120px;
padding: 16px;
}
.sample-reception-card {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
cursor: default;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.08);
}
&.is-eta-overdue {
border-color: #fca5a5;
background: #fef2f2;
&:hover {
border-color: #f87171;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.1);
}
.sample-reception-card__head {
background: #fee2e2;
}
}
}
.sample-reception-card__head {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
background: #fafafa;
}
.sample-reception-card__head-main {
min-width: 0;
}
.sample-reception-card__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.sample-reception-card__checkbox {
flex-shrink: 0;
margin-right: -4px;
}
.sample-reception-card__index {
flex-shrink: 0;
font-size: 12px;
color: #94a3b8;
min-width: 20px;
}
.sample-reception-card__code {
font-size: 15px;
font-weight: 600;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.sample-reception-card__meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px 12px;
margin: 0;
}
.sample-reception-card__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: #0f172a;
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #0f172a;
word-break: break-word;
}
&--key {
dt {
font-weight: 600;
}
dd {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
}
.sample-reception-card__ops {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 12px;
flex-shrink: 0;
padding-top: 10px;
}
.sample-reception-card__ops-payment {
flex: 1 1 240px;
min-width: 0;
margin-right: auto;
:deep(.qc-protocol-payment) {
border: none;
background: #e8f4ee;
border-radius: 8px;
}
:deep(.qc-protocol-payment--compact) {
width: 100%;
padding: 10px 12px;
box-sizing: border-box;
}
:deep(.qc-protocol-payment__compact-details) {
border-top: none;
margin-top: 8px;
padding-top: 8px;
}
:deep(.qc-protocol-payment__detail-chip) {
border: none;
background: rgb(255 255 255 / 60%);
}
}
.sample-reception-cards-empty {
padding: 24px 0;
}
</style>
@@ -68,7 +68,7 @@
{{
activeTab === 'pending'
? '勾选多个样本后可批量接收;预计到达时间默认为当天前 15 天至后 15 天。'
: '双击行可查看接收详情;接收时间默认近 30 天。'
: '双击卡片可查看接收详情;接收时间默认近 30 天。'
}}
</span>
<div class="filter-actions-end">
@@ -98,435 +98,257 @@
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="protocolSampleId"
:row-class-name="sampleReceptionRowClassName"
@row-dblclick="handleRowDblClick"
@selection-change="handlePendingSelectionChange"
>
<el-table-column
v-if="activeTab === 'pending'"
type="selection"
min-width="48"
align="center"
header-align="center"
reserve-selection
/>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="
protocolStatusTagTypeBySource(
String(row.protocolStatus || ''),
row.sourceType || 'cell_storage'
)
"
size="small"
>{{
protocolStatusLabelBySource(
String(row.protocolStatus || ''),
row.sourceType || 'cell_storage'
)
}}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="sourceTypeLabel"
label="来源类型"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="row.sourceType === 'research_donation' ? 'warning' : 'success'"
size="small"
>
{{ row.sourceTypeLabel || '细胞存储' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.sampleCode || '-' }}</template>
</el-table-column>
<el-table-column
prop="labStorageCode"
label="实验室入库编码"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
{{
activeTab === 'received' && row.acceptConclusion === 'js'
? row.labStorageCode || '-'
: '-'
}}
</template>
</el-table-column>
<el-table-column
prop="sampleTypeName"
label="样本类型"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.sampleTypeName || '-' }}</template>
</el-table-column>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.newbornName || '-' }}</template>
</el-table-column>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.sampleCollectTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="etaTime"
label="预计到达时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.etaTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="transportType"
label="运输方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
transportTypeLabel(String(row.transportType || ''))
}}</template>
</el-table-column>
<el-table-column
prop="waybillNo"
label="快递单号"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.waybillNo || '-' }}</template>
</el-table-column>
<el-table-column
prop="expressCompanyName"
label="快递公司"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.expressCompanyName || '-' }}</template>
</el-table-column>
<el-table-column
prop="carrierName"
label="携带人"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.carrierName || '-' }}</template>
</el-table-column>
<el-table-column
prop="carrierPhone"
label="携带人联系电话"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.carrierPhone || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'received'"
prop="receptionTime"
label="接收时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'received'"
prop="handlerName"
label="经手人"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.handlerName || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'received'"
prop="acceptConclusion"
label="结论"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.acceptConclusion === 'js' ? 'success' : 'danger'" size="small">
{{ acceptConclusionLabel(String(row.acceptConclusion || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openReceive(row)"
>
<el-icon><CircleCheck /></el-icon>样本接收
</el-button>
<template v-else>
<el-button class="btn-op-item" @click="openView(row)">查看</el-button>
<el-button class="btn-op-item" @click="openEdit(row)">编辑</el-button>
</template>
</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>
<SampleReceptionCardList
:rows="tableData"
:loading="loading"
:active-tab="activeTab"
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:selected-ids="selectedPendingIds"
@receive="openReceive"
@view="openView"
@edit="openEdit"
@toggle-select="handleCardToggleSelect"
@card-dblclick="handleRowDblClick"
@page-change="(val) => ((pagination.page = val), loadData())"
@page-size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
/>
<el-dialog
v-model="receiveDialogVisible"
:title="receiveDialogTitle"
width="920px"
width="1600px"
class="dialog-form-layout sample-reception-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@closed="handleReceiveDialogClosed"
>
<div v-if="currentRow" class="sample-reception-readonly">
<h4 class="detail-section-title">样本信息</h4>
<div class="readonly-grid form-grid-3">
<div class="readonly-item"
><span class="label">协议状态</span
><el-tag
:type="
protocolStatusTagTypeBySource(
String(currentRow.protocolStatus || ''),
currentRow.sourceType || 'cell_storage'
)
"
size="small"
>{{
protocolStatusLabelBySource(
String(currentRow.protocolStatus || ''),
currentRow.sourceType || 'cell_storage'
)
}}</el-tag
></div
<DialogFormBody body-class="protocol-dialog-body min-h-[200px]">
<div class="detail-tabs">
<div
v-for="tab in receiveFormTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeReceiveFormTab === tab.key }"
@click="scrollReceiveToSection(tab.key)"
>
<div class="readonly-item"
><span class="label">样本编码</span>{{ currentRow.sampleCode || '-' }}</div
>
<div class="readonly-item"
><span class="label">样本类型</span>{{ currentRow.sampleTypeName || '-' }}</div
>
<div class="readonly-item"
><span class="label">预计到达</span>{{ currentRow.etaTime || '-' }}</div
>
<div class="readonly-item readonly-col-span-2">
<span class="label">客户</span>{{ currentRow.motherName || '-' }} /
{{ currentRow.newbornName || '-' }}
{{ tab.label }}
</div>
</div>
</div>
<div v-if="currentRow" class="form-section-divider" />
<h4 class="detail-section-title">运输信息</h4>
<div class="readonly-grid form-grid-3">
<div class="readonly-item">
<span class="label">运输方式</span
>{{ transportTypeLabel(String(currentRow?.transportType || '')) }}
</div>
<div class="readonly-item"
><span class="label">预计到达</span>{{ currentRow?.etaTime || '-' }}</div
<el-form
ref="receiveFormRef"
:model="receiveForm"
:rules="receiveRules"
label-position="top"
scroll-to-error
class="protocol-form"
>
<template v-if="currentRow?.transportType === 'kdys'">
<div class="readonly-item"
><span class="label">快递公司</span>{{ currentRow?.expressCompanyName || '-' }}</div
>
<div class="readonly-item"
><span class="label">快递单号</span>{{ currentRow?.waybillNo || '-' }}</div
>
</template>
<template v-else-if="currentRow?.transportType === 'ryxd'">
<div class="readonly-item"
><span class="label">携带人</span>{{ currentRow?.carrierName || '-' }}</div
>
<div class="readonly-item"
><span class="label">携带人电话</span>{{ currentRow?.carrierPhone || '-' }}</div
>
</template>
</div>
<div class="form-section-divider" />
<h4 class="detail-section-title">接收登记</h4>
<el-form
ref="receiveFormRef"
:model="receiveForm"
:rules="receiveRules"
label-position="top"
class="biz-dialog-form"
>
<div class="form-grid-3">
<el-form-item label="样本接收时间" prop="receptionTime">
<el-date-picker
v-model="receiveForm.receptionTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择接收时间"
style="width: 100%"
@change="onReceiveReceptionTimeChange"
/>
</el-form-item>
<el-form-item label="经手人" prop="handlerUserId">
<el-select
v-model="receiveForm.handlerUserId"
filterable
placeholder="选择经手人"
style="width: 100%"
@change="onHandlerChange"
>
<el-option
v-for="u in userOptions"
:key="String(u.value)"
:label="String(u.label)"
:value="String(u.value)"
/>
</el-select>
</el-form-item>
<el-form-item label="接收温度(℃)">
<el-input v-model="receiveForm.receiveTemperature" placeholder="如 4" clearable />
</el-form-item>
<el-form-item label="体积(ml)">
<el-input-number
v-model="receiveForm.volumeMl"
:min="0"
:controls="false"
placeholder="体积"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="净重(g)">
<el-input-number
v-model="receiveForm.netWeightG"
:min="0"
:controls="false"
placeholder="净重"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="是否接收" prop="acceptConclusion">
<el-radio-group
v-model="receiveForm.acceptConclusion"
@change="onReceiveAcceptConclusionChange"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="showLabStorageCodeField"
label="实验室入库编码"
prop="labStorageCode"
>
<template #label>
实验室入库编码<span class="form-label-tip">RK+接收日期+序号,保存后不可修改</span>
</template>
<el-input
v-model="receiveForm.labStorageCode"
placeholder="自动生成"
clearable
:disabled="labStorageCodeReadonly"
@input="onLabStorageCodeManualInput"
/>
</el-form-item>
</div>
<el-form-item label="检查项确认" class="form-col-span-2">
<div v-if="activeCheckItems.length" class="check-list">
<el-checkbox
v-for="item in activeCheckItems"
:key="item.id"
v-model="checkValues[item.id]"
>
{{ item.itemName }}
</el-checkbox>
<div id="form-section-sample" class="form-tab-pane">
<div v-if="currentRow" class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>样本信息</h3></div>
</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">协议状态</div>
<div class="data-value">
<el-tag
:type="
protocolStatusTagTypeBySource(
String(currentRow.protocolStatus || ''),
currentRow.sourceType || 'cell_storage'
)
"
size="small"
>
{{
protocolStatusLabelBySource(
String(currentRow.protocolStatus || ''),
currentRow.sourceType || 'cell_storage'
)
}}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">样本编码</div>
<div class="data-value">{{ currentRow.sampleCode || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">样本类型</div>
<div class="data-value">{{ currentRow.sampleTypeName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ currentRow.etaTime || '-' }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">客户</div>
<div class="data-value">
{{ currentRow.motherName || '-' }} / {{ currentRow.newbornName || '-' }}
</div>
</div>
</div>
</div>
</div>
<p v-else class="check-empty-hint"
>暂无启用的检查项,请先在「接收检查项配置」中维护。</p
>
</el-form-item>
<ProtocolFinanceConfirmFields
v-model:finance-remark="receiveForm.remark"
v-model:attachments="attachments"
remark-label="备注信息"
attachment-label="接收附件"
upload-folder="sample-reception"
/>
</el-form>
<div id="form-section-transport" class="form-tab-pane">
<div v-if="currentRow" class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>运输信息</h3></div>
</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ currentRow.etaTime || '-' }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
</div>
<div id="form-section-reception" class="form-tab-pane">
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>接收登记</h3></div>
</div>
<div class="form-grid form-grid--col4">
<el-form-item label="样本接收时间" prop="receptionTime">
<el-date-picker
v-model="receiveForm.receptionTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择接收时间"
style="width: 100%"
@change="onReceiveReceptionTimeChange"
/>
</el-form-item>
<el-form-item label="经手人" prop="handlerUserId">
<el-select
v-model="receiveForm.handlerUserId"
filterable
placeholder="选择经手人"
style="width: 100%"
@change="onHandlerChange"
>
<el-option
v-for="u in userOptions"
:key="String(u.value)"
:label="String(u.label)"
:value="String(u.value)"
/>
</el-select>
</el-form-item>
<el-form-item label="接收温度(℃)">
<el-input
v-model="receiveForm.receiveTemperature"
placeholder="如 4"
clearable
/>
</el-form-item>
<el-form-item label="体积(ml)">
<el-input-number
v-model="receiveForm.volumeMl"
:min="0"
:controls="false"
placeholder="体积"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="净重(g)">
<el-input-number
v-model="receiveForm.netWeightG"
:min="0"
:controls="false"
placeholder="净重"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="是否接收" prop="acceptConclusion">
<el-radio-group
v-model="receiveForm.acceptConclusion"
@change="onReceiveAcceptConclusionChange"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="showLabStorageCodeField"
label="实验室入库编码"
prop="labStorageCode"
>
<template #label>
实验室入库编码<span class="form-label-tip"
>RK+接收日期+序号,保存后不可修改</span
>
</template>
<el-input
v-model="receiveForm.labStorageCode"
placeholder="自动生成"
clearable
:disabled="labStorageCodeReadonly"
@input="onLabStorageCodeManualInput"
/>
</el-form-item>
<el-form-item label="检查项确认" class="form-col-span-2">
<div v-if="activeCheckItems.length" class="check-list">
<el-checkbox
v-for="item in activeCheckItems"
:key="item.id"
v-model="checkValues[item.id]"
>
{{ item.itemName }}
</el-checkbox>
</div>
<p v-else class="check-empty-hint">
暂无启用的检查项,请先在「接收检查项配置」中维护。
</p>
</el-form-item>
</div>
<div class="form-grid form-grid--attachment">
<ProtocolFinanceConfirmFields
v-model:finance-remark="receiveForm.remark"
v-model:attachments="attachments"
remark-label="备注信息"
attachment-label="接收附件"
upload-folder="sample-reception"
/>
</div>
</div>
</div>
</el-form>
</DialogFormBody>
<template #footer>
<el-button class="btn-ghost" @click="receiveDialogVisible = false">
<el-icon><Close /></el-icon>取消
@@ -857,7 +679,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check, Search, RefreshLeft, CircleCheck, Files } from '@element-plus/icons-vue'
import { Close, Check, Search, RefreshLeft, Files } from '@element-plus/icons-vue'
import { fetchUserOptions } from '@/api/admin'
import {
fetchSampleReceptionPendingList,
@@ -872,8 +694,10 @@
fetchSampleReceptionCheckItemsActive,
type SampleReceptionCheckItem
} from '@/api/sample-reception-check-item'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SampleReceptionCardList from './SampleReceptionCardList.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
@@ -966,6 +790,10 @@
const activeCheckItems = ref<SampleReceptionCheckItem[]>([])
const checkValues = reactive<Record<string, boolean>>({})
const selectedPendingRows = ref<Record<string, unknown>[]>([])
const selectedPendingIds = computed(() =>
selectedPendingRows.value.map((r) => String(r.protocolSampleId || ''))
)
const batchReceiveDialogVisible = ref(false)
type BatchReceiveEntry = {
@@ -1044,6 +872,22 @@
receiveDialogMode.value === 'edit' ? '编辑样本接收' : '样本接收'
)
const receiveFormTabs = [
{ key: 'sample', label: '样本信息' },
{ key: 'transport', label: '运输信息' },
{ key: 'reception', label: '接收登记' }
] as const
const activeReceiveFormTab = ref('sample')
function scrollReceiveToSection(key: string) {
activeReceiveFormTab.value = key
const el = document.getElementById(`form-section-${key}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewCheckResults = computed((): SampleReceptionCheckResult[] => {
@@ -1079,35 +923,6 @@
return Number.isFinite(n) ? String(n) : '-'
}
function parseEtaLocalDate(eta: unknown): Date | null {
const s = String(eta || '').trim()
if (!s) return null
const normalized = s.replace('T', ' ').slice(0, 19)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!m) return null
const y = Number(m[1])
const mo = Number(m[2]) - 1
const d = Number(m[3])
const dt = new Date(y, mo, d)
if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null
return dt
}
function startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
function isEtaTodayOrEarlier(etaTime: unknown) {
const etaDay = parseEtaLocalDate(etaTime)
if (!etaDay) return false
return etaDay.getTime() <= startOfLocalDay(new Date()).getTime()
}
function sampleReceptionRowClassName({ row }: { row: Record<string, unknown> }) {
if (activeTab.value !== 'pending') return ''
return isEtaTodayOrEarlier(row.etaTime) ? 'row-eta-overdue' : ''
}
function apiFilesToFinanceItems(
files?: Array<{ id?: string; fileUrl?: string; fileName?: string; fileType?: string }>
): FinanceAttachmentItem[] {
@@ -1171,6 +986,7 @@
receiveDialogMode.value = 'create'
editingReceptionId.value = ''
currentRow.value = null
activeReceiveFormTab.value = 'sample'
}
function handleReceiveDialogClosed() {
@@ -1415,8 +1231,17 @@
entry.handlerName = opt?.label || ''
}
function handlePendingSelectionChange(rows: Record<string, unknown>[]) {
selectedPendingRows.value = rows
function handleCardToggleSelect(row: Record<string, unknown>, checked: boolean) {
const id = String(row.protocolSampleId || '')
if (checked) {
if (!selectedPendingRows.value.some((r) => String(r.protocolSampleId) === id)) {
selectedPendingRows.value = [...selectedPendingRows.value, row]
}
} else {
selectedPendingRows.value = selectedPendingRows.value.filter(
(r) => String(r.protocolSampleId) !== id
)
}
}
function buildChecksFromEntry(entry: BatchReceiveEntry) {
@@ -1745,6 +1570,173 @@
<style scoped lang="scss">
.sample-reception-dialog {
.protocol-dialog-body {
overflow: visible;
background: #f5f7fa;
}
.protocol-form {
:deep(.el-form-item) {
margin-bottom: 4px;
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-tab-pane {
padding: 0 28px 4px;
& + .form-tab-pane {
margin-top: 0;
padding-top: 0;
}
}
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
position: relative;
background: #fff;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.section-title-left {
display: flex;
align-items: center;
&::before {
content: '';
width: 3px;
height: 18px;
background: #047857;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
}
.section-title h3 {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.data-grid {
display: grid;
gap: 14px 20px;
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px 20px;
&--col4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.form-col-span-2 {
grid-column: 1 / -1;
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.check-empty-hint {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
/* 接收详情查看弹窗(沿用旧只读栅格) */
.detail-section-title {
margin: 0 0 12px;
font-size: 14px;
@@ -1787,22 +1779,6 @@
color: var(--mint-text-muted, #64748b);
}
}
.readonly-grid.form-grid-3 .readonly-item.readonly-col-span-2 {
grid-column: span 2;
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.check-empty-hint {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
}
.batch-receive-dialog {
@@ -1848,15 +1824,6 @@
}
}
.list-table :deep(tr.row-eta-overdue > td.el-table__cell) {
background-color: #fee2e2 !important;
color: #991b1b;
}
.list-table :deep(tr.row-eta-overdue:hover > td.el-table__cell) {
background-color: #fecaca !important;
}
.sample-reception-dialog .check-list-view,
.batch-receive-dialog .check-list-view {
pointer-events: none;
@@ -198,11 +198,21 @@
<SampleTransportFormSection
v-model:samples="formData.samples"
:sample-type-options="sampleTypeOptions"
:sample-type-options-loading="sampleTypeLoading"
:sample-code-options="sampleCodeOptions"
:sample-code-options-loading="sampleCodeLoading"
:unit-options="unitOptions"
:unit-options-loading="unitLoading"
:site-options="siteOptions"
:site-options-loading="siteLoading"
:express-company-options="expressCompanyOptions"
:express-company-options-loading="expressCompanyLoading"
:show-price="false"
@search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes"
@search-unit="remoteSearchUnits"
@search-site="remoteSearchSites"
@search-express-company="remoteSearchExpressCompanies"
/>
</div>
</div>
@@ -657,6 +667,11 @@
>([])
const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const sampleCodeLoading = ref(false)
const sampleTypeLoading = ref(false)
const unitLoading = ref(false)
const siteLoading = ref(false)
const expressCompanyLoading = ref(false)
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
const siteOptions = ref<Api.Site.SiteItem[]>([])
let sampleRowSeq = 0
@@ -857,6 +872,11 @@
salesSnapshot.value = null
regionOptions.value = []
hospitalOptions.value = []
sampleTypeOptions.value = []
unitOptions.value = []
sampleCodeOptions.value = []
siteOptions.value = []
expressCompanyOptions.value = []
activeFormTab.value = 'basic'
}
@@ -898,7 +918,6 @@
resetForm()
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
formData.donationDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
loadSampleOptions()
void loadEthnicOptions()
void remoteSearchCustomers('')
@@ -1022,8 +1041,7 @@
dialogFormLoading.value = true
try {
const [, , bundle] = await Promise.all([
loadSampleOptions(),
const [, bundle] = await Promise.all([
loadEthnicOptions(),
fetchResearchDonationDetail(String(row.id)).catch(() => null)
])
@@ -1060,7 +1078,7 @@
}
await Promise.all(hydrateTasks)
if (formData.samples.length) {
ensureSampleCodesInOptions(formData.samples)
hydrateSampleLookupOptions()
}
} catch {
/* 保留列表行已填基础字段 */
@@ -1344,6 +1362,65 @@
sampleCodeOptions.value = merged
}
function ensureSampleTypesInOptions(samples: Array<Record<string, any>>) {
const merged = [...sampleTypeOptions.value]
for (const s of samples) {
const id = String(s.sampleTypeId || '')
if (!id || merged.some((x) => x.id === id)) continue
merged.push({
id,
typeCode: '',
typeName: id,
storageYears: s.storageYears ?? null,
storagePrice: null
})
}
sampleTypeOptions.value = merged
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const id = String(s.unitId || '')
if (!id || merged.some((x) => x.id === id)) continue
merged.push({ id, unitName: id, unitCode: '' })
}
unitOptions.value = merged
}
function ensureSitesInOptions(samples: Array<Record<string, any>>) {
const merged = [...siteOptions.value]
for (const s of samples) {
const id = String(s.transport?.destSiteId || '')
if (!id || merged.some((x) => x.id === id)) continue
merged.push({
id,
siteCode: '',
siteName: id,
siteType: '',
status: '',
address: '',
contactName: '',
contactPhone: '',
businessScope: '',
siteImage: '',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
})
}
siteOptions.value = merged
}
function hydrateSampleLookupOptions() {
ensureSampleTypesInOptions(formData.samples)
ensureSampleCodesInOptions(formData.samples)
ensureUnitsInOptions(formData.samples)
ensureSitesInOptions(formData.samples)
}
/* ====== 客户检索(母亲信息) ====== */
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
const parts = [c.customerName || '']
@@ -1476,23 +1553,91 @@
formData.salesUserId = u.id
}
/* ====== 样本类型/单位选项 ====== */
async function loadSampleOptions() {
async function remoteSearchSampleCodes(query: string) {
sampleCodeLoading.value = true
try {
const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchItemUnitList({ page: 1, pageSize: 100 }),
fetchSampleCodeList({ page: 1, pageSize: 100 }),
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
fetchSiteList({ page: 1, pageSize: 100 })
])
sampleTypeOptions.value = (stRes.list || []) as any
unitOptions.value = (uRes.list || []) as any
sampleCodeOptions.value = (scRes.list || []) as any
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
const res = await fetchSampleCodeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
ensureSampleCodesInOptions(formData.samples)
} catch {
/* ignore */
sampleCodeOptions.value = []
ensureSampleCodesInOptions(formData.samples)
} finally {
sampleCodeLoading.value = false
}
}
async function remoteSearchSampleTypes(query: string) {
sampleTypeLoading.value = true
try {
const res = await fetchCellSampleTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleTypeOptions.value = (res.list || []) as any
ensureSampleTypesInOptions(formData.samples)
} catch {
sampleTypeOptions.value = []
ensureSampleTypesInOptions(formData.samples)
} finally {
sampleTypeLoading.value = false
}
}
async function remoteSearchUnits(query: string) {
unitLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
unitOptions.value = (res.list || []) as any
ensureUnitsInOptions(formData.samples)
} catch {
unitOptions.value = []
ensureUnitsInOptions(formData.samples)
} finally {
unitLoading.value = false
}
}
async function remoteSearchSites(query: string) {
siteLoading.value = true
try {
const res = await fetchSiteList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
siteOptions.value = res.list || []
ensureSitesInOptions(formData.samples)
} catch {
siteOptions.value = []
ensureSitesInOptions(formData.samples)
} finally {
siteLoading.value = false
}
}
async function remoteSearchExpressCompanies(query: string) {
expressCompanyLoading.value = true
try {
const res = await fetchExpressCompanyList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
expressCompanyOptions.value = res.list || []
} catch {
expressCompanyOptions.value = []
} finally {
expressCompanyLoading.value = false
}
}