提交
This commit is contained in:
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
+1
-1
@@ -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,
|
||||
|
||||
+59
-7
@@ -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>
|
||||
|
||||
|
||||
+63
-10
@@ -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>
|
||||
+60
-20
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+123
@@ -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>
|
||||
|
||||
+343
@@ -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]
|
||||
|
||||
+14
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user