feat(web-admin): wire protocol sample freight batch UI

Replace per-sample transport fee entry with shared freight batch config in storage and donation protocol dialogs, and show mode/amount in view dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-08 07:51:56 +08:00
1 parent 468a7c5e12
commit 5dd545ba5c
6 files changed
+551 -293

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import request from '@/utils/http'
export type SampleFreightItem = {
sampleSource: 'cell_storage' | 'research_donation'
sampleId: string
protocolId?: string
sampleCode?: string
}
export type SampleFreight = {
id: string
transportMode: string
transportModeLabel?: string
freightAmount: number
linkedSampleCount?: number
createTime?: string
items?: Array<SampleFreightItem & { protocolNo?: string }>
deleted?: boolean
}
type PageList<T> = { list: T[]; total: number; page: number; pageSize: number }
export function fetchSampleFreightList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<PageList<SampleFreight>>({
url: '/web/admin/sample-freights',
params
})
}
export function fetchSampleFreightDetail(id: string) {
return request.get<SampleFreight>({ url: `/web/admin/sample-freights/${id}` })
}
export function fetchCreateSampleFreight(data: {
transportMode: string
freightAmount: number
items: SampleFreightItem[]
}) {
return request.post<SampleFreight>({
url: '/web/admin/sample-freights',
data,
showSuccessMessage: true
})
}
export function fetchUpdateSampleFreight(
id: string,
data: { transportMode?: string; freightAmount?: number }
) {
return request.put<SampleFreight>({
url: `/web/admin/sample-freights/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchLinkSampleFreight(id: string, items: SampleFreightItem[]) {
return request.post<SampleFreight>({
url: `/web/admin/sample-freights/${id}/link`,
data: { items },
showSuccessMessage: true
})
}
export function fetchUnlinkSampleFreight(id: string, items: SampleFreightItem[]) {
return request.post<SampleFreight>({
url: `/web/admin/sample-freights/${id}/unlink`,
data: { items },
showSuccessMessage: true
})
}
@@ -9,9 +9,18 @@
<el-button
class="btn-ghost"
:disabled="!selectedSamples.length"
@click="openBatchTransportDialog"
@click="openFreightDialog"
>
配置运输信息
<el-icon><Van /></el-icon>
配置运费
</el-button>
<el-button
class="btn-ghost"
:disabled="!canUnlinkFreight"
@click="unlinkSelectedFreight"
>
<el-icon><Delete /></el-icon>
取消运费关联
</el-button>
</div>
@@ -86,25 +95,17 @@
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="运输类型" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
{{
row.transport?.transportType
? transportTypeLabel(String(row.transport.transportType))
: '—'
}}
</template>
<el-table-column label="运输方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ freightModeLabel(row) }}</template>
</el-table-column>
<el-table-column label="目的站点" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{
siteName(String(row.transport?.destSiteId || ''), row.transport)
}}</template>
</el-table-column>
<el-table-column label="预计到达" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.transport?.etaTime || '—' }}</template>
</el-table-column>
<el-table-column label="快递/携带" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ sampleTransportExtra(row.transport) }}</template>
<el-table-column
label="运费(元)"
min-width="120"
align="right"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ freightAmountText(row) }}</template>
</el-table-column>
<el-table-column
label="操作"
@@ -263,11 +264,11 @@
</template>
</el-dialog>
<!-- 配置运输信息 -->
<!-- 配置运费 -->
<el-dialog
v-model="batchTransportVisible"
title="配置运输信息"
width="1200px"
v-model="freightDialogVisible"
title="配置运费"
width="720px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
@@ -276,109 +277,72 @@
:close-on-click-modal="false"
>
<p class="sample-transport-hint batch-transport-dialog-hint">
将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。
将运费应用到已选中的 {{ selectedSamples.length }} 个样本(可跨协议共用一笔)。
</p>
<el-form label-position="top" class="biz-dialog-form">
<div
class="sample-transport-form-row"
:class="{
'sample-transport-form-row--ryxd': batchTransport.transportType === 'ryxd'
}"
>
<el-form-item label="运输类型">
<el-radio-group v-model="freightMode" class="freight-mode-radio" style="margin-bottom: 12px">
<el-radio-button value="create">新建运费</el-radio-button>
<el-radio-button value="link">关联已有</el-radio-button>
</el-radio-group>
<el-form v-if="freightMode === 'create'" label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<el-form-item label="运输方式">
<el-select
v-model="batchTransport.transportType"
placeholder="请选择"
clearable
style="width: 100%"
>
<el-option label="快递运输" value="kdys" />
<el-option label="人员携带" value="ryxd" />
</el-select>
</el-form-item>
<el-form-item label="目的站点">
<el-select
v-model="batchTransport.destSiteId"
placeholder="输入站点检索"
v-model="freightForm.transportMode"
placeholder="选择或输入运输方式"
filterable
remote
reserve-keyword
allow-create
default-first-option
clearable
:loading="siteOptionsLoading"
:remote-method="handleRemoteSearchSite"
style="width: 100%"
@visible-change="onSiteSelectVisible"
>
<el-option
v-for="site in siteOptions"
:key="site.id"
:label="site.siteName"
:value="site.id"
v-for="opt in transportModeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="预计到达时间">
<el-date-picker
v-model="batchTransport.etaTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm"
placeholder="选择预计到达时间"
clearable
style="width: 100%"
/>
<el-form-item label="运费(元)">
<AmountInput v-model="freightForm.freightAmount" :min="0" placeholder="请输入运费" />
</el-form-item>
<template v-if="batchTransport.transportType === 'kdys'">
<el-form-item label="快递公司">
<el-select
v-model="batchTransport.expressCompanyId"
placeholder="输入快递公司检索"
filterable
remote
reserve-keyword
clearable
:loading="expressCompanyOptionsLoading"
:remote-method="handleRemoteSearchExpressCompany"
style="width: 100%"
@visible-change="onExpressCompanySelectVisible"
>
<el-option
v-for="ec in expressCompanyOptions"
:key="ec.id"
:label="ec.expressName"
:value="ec.id"
/>
</el-select>
</el-form-item>
<el-form-item label="快递单号">
<el-input v-model="batchTransport.waybillNo" placeholder="请输入快递单号" />
</el-form-item>
<el-form-item label="快递费用(元)">
<el-input
v-model="batchTransport.expressFee"
type="number"
step="any"
:min="0"
style="width: 100%"
/>
</el-form-item>
</template>
<template v-if="batchTransport.transportType === 'ryxd'">
<el-form-item label="携带人员姓名">
<el-input v-model="batchTransport.carrierName" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="携带人员电话">
<el-input v-model="batchTransport.carrierPhone" placeholder="请输入电话" />
</el-form-item>
</template>
</div>
</el-form>
<div v-else class="list-body list-wrap">
<el-table
:data="existingFreightList"
stripe
class="list-table inner-table"
highlight-current-row
@current-change="onExistingFreightSelect"
>
<el-table-column label="运输方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.transportModeLabel || row.transportMode || '—' }}
</template>
</el-table-column>
<el-table-column label="运费(元)" min-width="100" align="right" header-align="center">
<template #default="{ row }">{{ fmtMoney(row.freightAmount) }}</template>
</el-table-column>
<el-table-column
label="关联样本数"
min-width="96"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.linkedSampleCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.createTime || '—' }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button class="btn-ghost" @click="batchTransportVisible = false">
<el-button class="btn-ghost" @click="freightDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="confirmBatchTransportApply">
<el-button class="btn-main" type="primary" :loading="freightSubmitting" @click="confirmFreight">
<el-icon><Check /></el-icon>
确定应用
</el-button>
@@ -388,21 +352,28 @@
</template>
<script setup lang="ts">
import { Plus, Edit, Delete, Close, Check } from '@element-plus/icons-vue'
import { Plus, Edit, Delete, Close, Check, Van } from '@element-plus/icons-vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import {
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import { fetchDictDataOptions } from '@/api/admin'
import {
fetchCreateSampleFreight,
fetchLinkSampleFreight,
fetchSampleFreightList,
fetchUnlinkSampleFreight,
type SampleFreight,
type SampleFreightItem
} from '@/api/sample-freight'
defineOptions({ name: 'SampleTransportFormSection' })
const props = withDefaults(
defineProps<{
samples: Array<Record<string, any>>
sampleSource: 'cell_storage' | 'research_donation'
protocolId?: string
sampleTypeOptions: Array<{
id: string
typeName: string
@@ -413,8 +384,8 @@
}>
sampleCodeOptions: Api.BizSampleCode.SampleCodeItem[]
unitOptions: Array<{ id: string; unitName: string }>
siteOptions: Api.Site.SiteItem[]
expressCompanyOptions: Api.ExpressCompany.ExpressCompanyItem[]
siteOptions?: Api.Site.SiteItem[]
expressCompanyOptions?: Api.ExpressCompany.ExpressCompanyItem[]
showPrice?: boolean
hideCollectDetailFields?: boolean
/** 添加/编辑样本弹窗内隐藏采集量、单位(不影响列表列与存储年限) */
@@ -429,6 +400,7 @@
expressCompanyOptionsLoading?: boolean
}>(),
{
protocolId: '',
showPrice: false,
hideCollectDetailFields: false,
hideDialogCollectAmountUnit: false,
@@ -452,70 +424,73 @@
(e: 'search-express-company', query: string): void
}>()
type SampleTransportForm = {
transportType: string
expressCompanyId: string
waybillNo: string
destSiteId: string
expressFee: number | null
etaTime: string
carrierName: string
carrierPhone: string
}
const sampleTableRef = ref()
const selectedSamples = ref<Array<Record<string, any>>>([])
let sampleRowSeq = 0
const batchTransport = reactive<SampleTransportForm>(createEmptyTransport())
const batchTransportVisible = ref(false)
const freightDialogVisible = ref(false)
const freightMode = ref<'create' | 'link'>('create')
const freightSubmitting = ref(false)
const freightForm = reactive({
transportMode: '' as string,
freightAmount: null as number | null
})
const transportModeOptions = ref<Array<{ label: string; value: string }>>([])
const existingFreightList = ref<SampleFreight[]>([])
const selectedExistingFreight = ref<SampleFreight | null>(null)
const sampleDialogVisible = ref(false)
const sampleDialogMode = ref<'add' | 'edit'>('add')
const sampleDialogIndex = ref(-1)
const sampleDraft = reactive<Record<string, any>>({})
const canUnlinkFreight = computed(() =>
selectedSamples.value.some((r) => r.freight?.id || r.freightDraft || r.freightId)
)
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function createEmptyTransport(): SampleTransportForm {
return {
transportType: '',
expressCompanyId: '',
waybillNo: '',
destSiteId: '',
expressFee: null,
etaTime: '',
carrierName: '',
carrierPhone: ''
}
function freightModeLabel(row: Record<string, any>) {
return (
row.freight?.transportModeLabel ||
row.freightDraft?.transportMode ||
row.freight?.transportMode ||
'—'
)
}
function freightAmountText(row: Record<string, any>) {
const amount = row.freight?.freightAmount ?? row.freightDraft?.freightAmount
if (amount == null || amount === '') return '—'
const base = fmtMoney(amount)
const n = Number(row.freight?.linkedSampleCount || 0)
return n > 1 ? `${base}(共用)` : base
}
function normalizeSampleRow(raw: Record<string, any>, _index: number) {
const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
const statusRaw = raw.sampleStatus
const sampleStatus =
statusRaw != null && String(statusRaw).trim() !== '' ? String(statusRaw).trim() : ''
const freight =
raw.freight && typeof raw.freight === 'object'
? { ...raw.freight }
: raw.freightId
? { id: raw.freightId }
: null
const freightDraft =
raw.freightDraft && typeof raw.freightDraft === 'object' ? { ...raw.freightDraft } : undefined
return {
...raw,
sampleStatus,
isSpecial: !!raw.isSpecial,
specialRemark: String(raw.specialRemark || '').trim(),
_rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
transport: {
...createEmptyTransport(),
transportType: transportRaw.transportType || '',
expressCompanyId: transportRaw.expressCompanyId || '',
waybillNo: transportRaw.waybillNo || '',
destSiteId: transportRaw.destSiteId || '',
destSiteName: transportRaw.destSiteName || '',
expressFee: transportRaw.expressFee ?? null,
etaTime: transportRaw.etaTime || '',
carrierName: transportRaw.carrierName || '',
carrierPhone: transportRaw.carrierPhone || ''
}
freight,
freightDraft,
freightId: freight?.id || raw.freightId || ''
}
}
@@ -558,41 +533,6 @@
return name
}
function siteName(id: string, transport?: Record<string, any>) {
if (!id) return '—'
const fromOpt = props.siteOptions.find((x) => x.id === id)?.siteName
if (fromOpt && fromOpt !== id) return fromOpt
const fromTransport = String(transport?.destSiteName || '').trim()
if (fromTransport && fromTransport !== id) return fromTransport
return '—'
}
function expressName(id: string) {
if (!id) return '—'
return props.expressCompanyOptions.find((x) => x.id === id)?.expressName || '—'
}
function sampleTransportExtra(transport: Record<string, any> | undefined) {
if (!transport?.transportType) return '—'
if (transport.transportType === 'kdys') {
const parts: string[] = []
const ec = expressName(String(transport.expressCompanyId || ''))
if (ec !== '—') parts.push(ec)
if (transport.waybillNo) parts.push(String(transport.waybillNo))
if (transport.expressFee != null && transport.expressFee !== '') {
parts.push(`¥${transport.expressFee}`)
}
return parts.length ? parts.join(' · ') : '—'
}
if (transport.transportType === 'ryxd') {
const parts: string[] = []
if (transport.carrierName) parts.push(String(transport.carrierName))
if (transport.carrierPhone) parts.push(String(transport.carrierPhone))
return parts.length ? parts.join(' · ') : '—'
}
return '—'
}
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
if (sc.issued) return `${sc.sampleCode}(已下发)`
@@ -638,22 +578,6 @@
if (visible) emit('search-unit', '')
}
function handleRemoteSearchSite(query: string) {
emit('search-site', query)
}
function onSiteSelectVisible(visible: boolean) {
if (visible) emit('search-site', '')
}
function handleRemoteSearchExpressCompany(query: string) {
emit('search-express-company', query)
}
function onExpressCompanySelectVisible(visible: boolean) {
if (visible) emit('search-express-company', '')
}
function refreshSampleDialogOptions() {
emit('search-sample-type', '')
if (!props.autoGenerateSampleCode) {
@@ -662,9 +586,68 @@
emit('search-unit', '')
}
function refreshBatchTransportOptions() {
emit('search-site', '')
emit('search-express-company', '')
async function loadTransportModeOptions() {
try {
const res = await fetchDictDataOptions('sample_transport_mode')
const list = Array.isArray(res) ? res : (res as any)?.data || []
transportModeOptions.value = list.map((x: any) => ({
label: x.label || x.dictLabel || x.value || x.dictValue,
value: x.value || x.dictValue
}))
} catch {
transportModeOptions.value = [
{ label: '顺丰快递', value: 'sfkd' },
{ label: '专线车', value: 'zxc' }
]
}
}
function transportModeLabelOf(mode: string) {
const hit = transportModeOptions.value.find((x) => x.value === mode || x.label === mode)
return hit?.label || mode
}
function toFreightItems(rows: Array<Record<string, any>>): SampleFreightItem[] {
return rows
.filter((r) => r.id)
.map((r) => ({
sampleSource: props.sampleSource,
sampleId: String(r.id),
protocolId: props.protocolId || undefined,
sampleCode: r.sampleCode || undefined
}))
}
function applyFreightToRows(
rows: Array<Record<string, any>>,
freight: Partial<SampleFreight> & { freightDraft?: any }
) {
const selectedSet = new Set(rows)
const next = props.samples.map((row) => {
if (!selectedSet.has(row)) return row
return {
...row,
freight: freight.id
? {
id: freight.id,
transportMode: freight.transportMode,
transportModeLabel: freight.transportModeLabel || transportModeLabelOf(String(freight.transportMode || '')),
freightAmount: freight.freightAmount,
linkedSampleCount: freight.linkedSampleCount
}
: {
id: '',
transportMode: freight.transportMode,
transportModeLabel:
freight.transportModeLabel || transportModeLabelOf(String(freight.transportMode || '')),
freightAmount: freight.freightAmount,
linkedSampleCount: freight.linkedSampleCount
},
freightId: freight.id || '',
freightDraft: freight.freightDraft
}
})
updateSamples(next)
}
function onSampleCodeChange(row: Record<string, any>) {
@@ -709,35 +692,138 @@
selectedSamples.value = rows
}
function openBatchTransportDialog() {
async function openFreightDialog() {
if (!selectedSamples.value.length) {
ElMessage.warning('请先勾选需要应用运输信息的样本')
ElMessage.warning('请先勾选需要配置运费的样本')
return
}
Object.assign(batchTransport, createEmptyTransport())
const firstWithTransport = selectedSamples.value.find((row) => row.transport?.destSiteId)
if (firstWithTransport?.transport) {
Object.assign(batchTransport, { ...firstWithTransport.transport })
freightMode.value = 'create'
freightForm.transportMode = ''
freightForm.freightAmount = null
selectedExistingFreight.value = null
await loadTransportModeOptions()
try {
const res = await fetchSampleFreightList({ page: 1, pageSize: 50 })
const data = (res as any)?.data || res
existingFreightList.value = data?.list || []
} catch {
existingFreightList.value = []
}
batchTransportVisible.value = true
refreshBatchTransportOptions()
freightDialogVisible.value = true
}
function confirmBatchTransportApply() {
if (!batchTransport.transportType) {
ElMessage.warning('请选择运输类型')
function onExistingFreightSelect(row: SampleFreight | null) {
selectedExistingFreight.value = row
}
async function confirmFreight() {
const selected = selectedSamples.value
if (!selected.length) return
const persisted = selected.filter((r) => r.id)
const drafts = selected.filter((r) => !r.id)
freightSubmitting.value = true
try {
if (freightMode.value === 'create') {
if (!freightForm.transportMode || freightForm.freightAmount == null) {
ElMessage.warning('请填写运输方式与运费')
return
}
const amount = Number(freightForm.freightAmount)
if (Number.isNaN(amount) || amount < 0) {
ElMessage.warning('运费须为不小于 0 的数字')
return
}
let created: SampleFreight | null = null
if (persisted.length) {
const res = await fetchCreateSampleFreight({
transportMode: freightForm.transportMode,
freightAmount: amount,
items: toFreightItems(persisted)
})
created = ((res as any)?.data || res) as SampleFreight
applyFreightToRows(persisted, created)
}
if (drafts.length) {
const clientKey = `fd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
applyFreightToRows(drafts, {
id: created?.id || '',
transportMode: freightForm.transportMode,
transportModeLabel: transportModeLabelOf(freightForm.transportMode),
freightAmount: amount,
linkedSampleCount: (created?.linkedSampleCount || 0) + drafts.length,
freightDraft: created?.id
? undefined
: {
clientKey,
transportMode: freightForm.transportMode,
freightAmount: amount
}
})
}
} else {
if (!selectedExistingFreight.value?.id) {
ElMessage.warning('请选择一笔已有运费')
return
}
const target = selectedExistingFreight.value
if (persisted.length) {
const res = await fetchLinkSampleFreight(target.id, toFreightItems(persisted))
const linked = ((res as any)?.data || res) as SampleFreight
applyFreightToRows(persisted, linked)
}
if (drafts.length) {
applyFreightToRows(drafts, {
...target,
freightId: target.id,
freightDraft: undefined
} as any)
}
}
freightDialogVisible.value = false
ElMessage.success(`已为 ${selected.length} 个样本配置运费`)
} catch (e: any) {
ElMessage.error(e?.message || '配置运费失败')
} finally {
freightSubmitting.value = false
}
}
async function unlinkSelectedFreight() {
const selected = selectedSamples.value.filter(
(r) => r.freight?.id || r.freightDraft || r.freightId
)
if (!selected.length) {
ElMessage.warning('请勾选已关联运费的样本')
return
}
const payload = { ...batchTransport }
const selectedSet = new Set(selectedSamples.value)
const next = props.samples.map((row) => {
if (!selectedSet.has(row)) return row
const transport = { ...(row.transport || createEmptyTransport()), ...payload }
return { ...row, transport }
})
updateSamples(next)
batchTransportVisible.value = false
ElMessage.success(`已为 ${selectedSamples.value.length} 个样本应用运输信息`)
try {
await ElMessageBox.confirm('确定取消所选样本的运费关联吗?', '取消运费关联', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
} catch {
return
}
const byFreight = new Map<string, Array<Record<string, any>>>()
for (const row of selected) {
const fid = String(row.freight?.id || row.freightId || '')
if (!fid) continue
if (!byFreight.has(fid)) byFreight.set(fid, [])
byFreight.get(fid)!.push(row)
}
for (const [fid, rows] of byFreight) {
await fetchUnlinkSampleFreight(fid, toFreightItems(rows))
}
const selectedSet = new Set(selected)
updateSamples(
props.samples.map((row) => {
if (!selectedSet.has(row)) return row
return { ...row, freight: null, freightId: '', freightDraft: undefined }
})
)
ElMessage.success('已取消运费关联')
}
function onSpecialSampleToggle(checked: boolean | string | number) {
@@ -469,6 +469,8 @@
</div>
<SampleTransportFormSection
v-model:samples="formData.samples"
sample-source="cell_storage"
:protocol-id="formData.id || ''"
hide-dialog-collect-amount-unit
:sample-type-options="sampleTypeOptions"
:sample-type-options-loading="sampleTypeLoading"
@@ -476,17 +478,11 @@
: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>
@@ -850,7 +846,8 @@
class="detail-badge"
:class="{
'badge-green': detail.payStatus === 'yjf',
'badge-amber': detail.payStatus === 'dfj' || detail.payStatus === 'jfqrz',
'badge-primary': detail.payStatus === 'dfj',
'badge-amber': detail.payStatus === 'jfqrz',
'badge-gray': detail.payStatus === 'tfqrz' || detail.payStatus === 'ytf'
}"
>
@@ -908,6 +905,7 @@
<el-date-picker
v-model="detail.transferTime"
type="datetime"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择时间"
clearable
@@ -917,20 +915,11 @@
</el-form-item>
<el-form-item label="缴费状态">
<el-tag
v-if="isPayDetailLocked(detail)"
:type="payStatusTagType(String(detail.payStatus))"
:type="payStatusTagType(String(detail.payStatus || 'dfj'))"
size="small"
>
{{ payStatusLabel(String(detail.payStatus)) }}
{{ payStatusLabel(String(detail.payStatus || 'dfj')) }}
</el-tag>
<el-select
v-else
v-model="detail.payStatus"
placeholder="状态"
style="width: 100%"
>
<el-option label="待缴费" value="dfj" />
</el-select>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
@@ -2367,13 +2356,14 @@
sampleCollectTime: formData.sampleCollectTime || undefined,
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
},
samples: formData.samples.map(({ _rowKey, transport, ...s }) => ({
samples: formData.samples.map(({ _rowKey, transport, freight, freightDraft, ...s }) => ({
...s,
sampleStatus:
s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
? String(s.sampleStatus).trim()
: null,
transport: transport?.transportType ? { ...transport } : undefined
freightId: freight?.id || s.freightId || undefined,
freightDraft: freightDraft || undefined
}))
}
@@ -4238,6 +4228,11 @@
color: #065f46;
}
&.badge-primary {
background: #ecfdf5;
color: #047857;
}
&.badge-amber {
background: #fffbeb;
color: #d97706;
@@ -307,7 +307,16 @@
>
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards">
<div v-for="(row, sIdx) in samples" :key="row.id || sIdx" class="sample-card">
<div
v-for="(row, sIdx) in samples"
:key="row.id || sIdx"
class="sample-card"
:class="{
'is-highlight':
!!highlightSampleCode && String(row.sampleCode || '') === highlightSampleCode
}"
:id="row.sampleCode ? `view-sample-${row.sampleCode}` : undefined"
>
<div class="sample-card__header">
<div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
@@ -355,31 +364,27 @@
</div>
</div>
<div
v-if="row.transport?.transportType"
v-if="row.freight?.id || row.freight?.transportMode"
class="sample-card__section sample-card__section--transport"
>
<div class="sample-card__section-label">运输信息</div>
<div class="sample-card__section-label">运费信息</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">运输类型</div>
<div class="data-label">运输方式</div>
<div class="data-value">{{
transportTypeLabel(String(row.transport?.transportType || ''))
dash(row.freight?.transportModeLabel || row.freight?.transportMode)
}}</div>
</div>
<div class="data-item">
<div class="data-label">目的站点</div>
<div class="data-label">运费(元)</div>
<div class="data-value">{{
siteName(String(row.transport?.destSiteId || ''))
row.freight?.freightAmount != null
? '¥' + formatMoneyDisplay(row.freight.freightAmount)
: '—'
}}{{
Number(row.freight?.linkedSampleCount || 0) > 1 ? '(共用)' : ''
}}</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ dash(row.transport?.etaTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">快递/携带</div>
<div class="data-value">{{ sampleTransportExtra(row.transport) }}</div>
</div>
</div>
</div>
<div class="sample-card__section sample-card__section--reception">
@@ -419,6 +424,35 @@
<el-empty v-else description="暂无样本" :image-size="48" />
</div>
<div
v-if="showHandlePanels"
id="view-section-storage-cert-handle"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>储存证办理</h3></div>
<ProtocolHandlePanel
v-if="protocolId"
mode="storage-cert"
:protocol-id="protocolId"
:initial="storageCertInfo"
@saved="loadDetail"
/>
</div>
<div
v-if="showHandlePanels"
id="view-section-insurance-handle"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>保险办理</h3></div>
<ProtocolHandlePanel
v-if="protocolId"
mode="insurance"
:protocol-id="protocolId"
:initial="fullmoonInsuranceInfo"
@saved="loadDetail"
/>
</div>
<!-- 客户信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title section-title--banner"><h3>客户信息</h3></div>
@@ -926,7 +960,7 @@
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value"
><el-tag type="success" size="small">已缴费</el-tag></span
><el-tag type="success" size="small">缴费成功</el-tag></span
>
</div>
<div v-if="detail.transferTime" class="flow-detail-card__kv">
@@ -1225,6 +1259,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import ProtocolHandlePanel from '@/components/business/protocol-handle-panel/index.vue'
import {
fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail,
@@ -1271,8 +1306,17 @@
showStorageCert?: boolean
/** 满月待办查看:客户信息下方独立展示新生儿保险信息 */
showFullmoonInsurance?: boolean
/** 样本信息管理:储存证/保险轻量办理 */
showHandlePanels?: boolean
highlightSampleCode?: string
}>(),
{ showAuditHistory: false, showStorageCert: false, showFullmoonInsurance: false }
{
showAuditHistory: false,
showStorageCert: false,
showFullmoonInsurance: false,
showHandlePanels: false,
highlightSampleCode: ''
}
)
const emit = defineEmits<{
@@ -1289,6 +1333,11 @@
const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
if (props.showHandlePanels) {
const sampleIdx = tabs.findIndex((t) => t.key === 'sample')
tabs.splice(sampleIdx + 1, 0, { key: 'storage-cert-handle', label: '储存证办理' })
tabs.splice(sampleIdx + 2, 0, { key: 'insurance-handle', label: '保险办理' })
}
if (props.showFullmoonInsurance) {
const partyIdx = tabs.findIndex((t) => t.key === 'party')
tabs.splice(partyIdx + 1, 0, { key: 'fullmoon-insurance', label: '新生儿保险' })
@@ -1705,7 +1754,7 @@
return {
key: `ov-${i}`,
label: name,
subLabel: '已缴费',
subLabel: '缴费成功',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
@@ -1726,7 +1775,7 @@
return {
key: `ov-${i}`,
label: name,
subLabel: '待缴费',
subLabel: '已缴费',
dotClass: 'pending',
dotIcon: '○',
labelClass: ''
@@ -1993,6 +2042,13 @@
void finishLookups(data, baseLookupsPromise)
void auditPromise
if (props.highlightSampleCode) {
nextTick(() => {
document
.getElementById(`view-sample-${props.highlightSampleCode}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
}
function scrollToSection(key: string) {
@@ -2024,6 +2080,19 @@
if (visible && id) loadDetail()
}
)
watch(
() => [props.modelValue, props.highlightSampleCode, bundle.value] as const,
([visible, code]) => {
if (!visible || !code || !bundle.value) return
nextTick(() => {
document.getElementById(`view-sample-${code}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
})
}
)
</script>
<style scoped lang="scss">
@@ -2394,6 +2463,11 @@
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
&.is-highlight {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
}
}
.sample-card__header {
@@ -255,6 +255,8 @@
</div>
<SampleTransportFormSection
v-model:samples="formData.samples"
sample-source="research_donation"
:protocol-id="formData.id || ''"
hide-collect-detail-fields
auto-generate-sample-code
:reserve-sample-code="reserveDonationSampleCode"
@@ -264,16 +266,10 @@
: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>
@@ -1411,13 +1407,14 @@
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
samples: formData.samples.map(({ _rowKey: _, transport, ...s }) => ({
samples: formData.samples.map(({ _rowKey: _, transport, freight, freightDraft, ...s }) => ({
...s,
sampleStatus:
s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
? String(s.sampleStatus).trim()
: null,
transport: transport?.transportType ? { ...transport } : undefined
freightId: freight?.id || s.freightId || undefined,
freightDraft: freightDraft || undefined
}))
}
@@ -225,7 +225,16 @@
<div id="view-section-sample" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards">
<div v-for="(row, sIdx) in samples" :key="row.id || sIdx" class="sample-card">
<div
v-for="(row, sIdx) in samples"
:key="row.id || sIdx"
class="sample-card"
:class="{
'is-highlight':
!!highlightSampleCode && String(row.sampleCode || '') === highlightSampleCode
}"
:id="row.sampleCode ? `view-sample-${row.sampleCode}` : undefined"
>
<div class="sample-card__header">
<div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
@@ -261,31 +270,27 @@
</div>
</div>
<div
v-if="row.transport?.transportType"
v-if="row.freight?.id || row.freight?.transportMode"
class="sample-card__section sample-card__section--transport"
>
<div class="sample-card__section-label">运输信息</div>
<div class="sample-card__section-label">运费信息</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">运输类型</div>
<div class="data-label">运输方式</div>
<div class="data-value">{{
transportTypeLabel(String(row.transport?.transportType || ''))
dash(row.freight?.transportModeLabel || row.freight?.transportMode)
}}</div>
</div>
<div class="data-item">
<div class="data-label">目的站点</div>
<div class="data-label">运费(元)</div>
<div class="data-value">{{
siteName(String(row.transport?.destSiteId || ''))
row.freight?.freightAmount != null
? '¥' + formatMoneyDisplay(row.freight.freightAmount)
: '—'
}}{{
Number(row.freight?.linkedSampleCount || 0) > 1 ? '(共用)' : ''
}}</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ dash(row.transport?.etaTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">快递/携带</div>
<div class="data-value">{{ sampleTransportExtra(row.transport) }}</div>
</div>
</div>
</div>
<div class="sample-card__section sample-card__section--reception">
@@ -596,8 +601,9 @@
listRow?: ResearchDonationRow | null
/** 展示审核记录 Tab(科研捐赠审核页) */
showAuditHistory?: boolean
highlightSampleCode?: string
}>(),
{ showAuditHistory: false }
{ showAuditHistory: false, highlightSampleCode: '' }
)
const emit = defineEmits<{
@@ -953,6 +959,13 @@
bundle.value = data as Record<string, any>
await loadLookups(data as Record<string, any>)
await loadAuditRecords()
if (props.highlightSampleCode) {
nextTick(() => {
document
.getElementById(`view-sample-${props.highlightSampleCode}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
} finally {
loading.value = false
}
@@ -982,6 +995,19 @@
if (visible && id) loadDetail()
}
)
watch(
() => [props.modelValue, props.highlightSampleCode, bundle.value] as const,
([visible, code]) => {
if (!visible || !code || !bundle.value) return
nextTick(() => {
document.getElementById(`view-sample-${code}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
})
}
)
</script>
<style scoped lang="scss">
@@ -1311,6 +1337,11 @@
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
&.is-highlight {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
}
}
.sample-card__header {