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 <el-button
class="btn-ghost" class="btn-ghost"
:disabled="!selectedSamples.length" :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> </el-button>
</div> </div>
@@ -86,25 +95,17 @@
<span v-else>—</span> <span v-else>—</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="运输类型" min-width="100" show-overflow-tooltip> <el-table-column label="运输方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">{{ freightModeLabel(row) }}</template>
{{
row.transport?.transportType
? transportTypeLabel(String(row.transport.transportType))
: '—'
}}
</template>
</el-table-column> </el-table-column>
<el-table-column label="目的站点" min-width="120" show-overflow-tooltip> <el-table-column
<template #default="{ row }">{{ label="运费(元)"
siteName(String(row.transport?.destSiteId || ''), row.transport) min-width="120"
}}</template> align="right"
</el-table-column> header-align="center"
<el-table-column label="预计到达" min-width="150" show-overflow-tooltip> show-overflow-tooltip
<template #default="{ row }">{{ row.transport?.etaTime || '—' }}</template> >
</el-table-column> <template #default="{ row }">{{ freightAmountText(row) }}</template>
<el-table-column label="快递/携带" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ sampleTransportExtra(row.transport) }}</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
label="操作" label="操作"
@@ -263,11 +264,11 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 配置运输信息 --> <!-- 配置运费 -->
<el-dialog <el-dialog
v-model="batchTransportVisible" v-model="freightDialogVisible"
title="配置运输信息" title="配置运费"
width="1200px" width="720px"
class="dialog-form-layout" class="dialog-form-layout"
align-center align-center
modal-class="protocol-dialog-mask" modal-class="protocol-dialog-mask"
@@ -276,109 +277,72 @@
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<p class="sample-transport-hint batch-transport-dialog-hint"> <p class="sample-transport-hint batch-transport-dialog-hint">
将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。 将运费应用到已选中的 {{ selectedSamples.length }} 个样本(可跨协议共用一笔)。
</p> </p>
<el-form label-position="top" class="biz-dialog-form"> <el-radio-group v-model="freightMode" class="freight-mode-radio" style="margin-bottom: 12px">
<div <el-radio-button value="create">新建运费</el-radio-button>
class="sample-transport-form-row" <el-radio-button value="link">关联已有</el-radio-button>
:class="{ </el-radio-group>
'sample-transport-form-row--ryxd': batchTransport.transportType === 'ryxd' <el-form v-if="freightMode === 'create'" label-position="top" class="biz-dialog-form">
}" <div class="form-grid-2">
> <el-form-item label="运输方式">
<el-form-item label="运输类型">
<el-select <el-select
v-model="batchTransport.transportType" v-model="freightForm.transportMode"
placeholder="请选择" 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="输入站点检索"
filterable filterable
remote allow-create
reserve-keyword default-first-option
clearable clearable
:loading="siteOptionsLoading"
:remote-method="handleRemoteSearchSite"
style="width: 100%" style="width: 100%"
@visible-change="onSiteSelectVisible"
> >
<el-option <el-option
v-for="site in siteOptions" v-for="opt in transportModeOptions"
:key="site.id" :key="opt.value"
:label="site.siteName" :label="opt.label"
:value="site.id" :value="opt.value"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="预计到达时间"> <el-form-item label="运费(元)">
<el-date-picker <AmountInput v-model="freightForm.freightAmount" :min="0" placeholder="请输入运费" />
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> </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> </div>
</el-form> </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> <template #footer>
<el-button class="btn-ghost" @click="batchTransportVisible = false"> <el-button class="btn-ghost" @click="freightDialogVisible = false">
<el-icon><Close /></el-icon> <el-icon><Close /></el-icon>
取消 取消
</el-button> </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-icon><Check /></el-icon>
确定应用 确定应用
</el-button> </el-button>
@@ -388,21 +352,28 @@
</template> </template>
<script setup lang="ts"> <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 AmountInput from '@/components/common/amount-input/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue' import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import { import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format' 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' }) defineOptions({ name: 'SampleTransportFormSection' })
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
samples: Array<Record<string, any>> samples: Array<Record<string, any>>
sampleSource: 'cell_storage' | 'research_donation'
protocolId?: string
sampleTypeOptions: Array<{ sampleTypeOptions: Array<{
id: string id: string
typeName: string typeName: string
@@ -413,8 +384,8 @@
}> }>
sampleCodeOptions: Api.BizSampleCode.SampleCodeItem[] sampleCodeOptions: Api.BizSampleCode.SampleCodeItem[]
unitOptions: Array<{ id: string; unitName: string }> unitOptions: Array<{ id: string; unitName: string }>
siteOptions: Api.Site.SiteItem[] siteOptions?: Api.Site.SiteItem[]
expressCompanyOptions: Api.ExpressCompany.ExpressCompanyItem[] expressCompanyOptions?: Api.ExpressCompany.ExpressCompanyItem[]
showPrice?: boolean showPrice?: boolean
hideCollectDetailFields?: boolean hideCollectDetailFields?: boolean
/** 添加/编辑样本弹窗内隐藏采集量、单位(不影响列表列与存储年限) */ /** 添加/编辑样本弹窗内隐藏采集量、单位(不影响列表列与存储年限) */
@@ -429,6 +400,7 @@
expressCompanyOptionsLoading?: boolean expressCompanyOptionsLoading?: boolean
}>(), }>(),
{ {
protocolId: '',
showPrice: false, showPrice: false,
hideCollectDetailFields: false, hideCollectDetailFields: false,
hideDialogCollectAmountUnit: false, hideDialogCollectAmountUnit: false,
@@ -452,70 +424,73 @@
(e: 'search-express-company', query: string): void (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 sampleTableRef = ref()
const selectedSamples = ref<Array<Record<string, any>>>([]) const selectedSamples = ref<Array<Record<string, any>>>([])
let sampleRowSeq = 0 let sampleRowSeq = 0
const batchTransport = reactive<SampleTransportForm>(createEmptyTransport()) const freightDialogVisible = ref(false)
const batchTransportVisible = 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 sampleDialogVisible = ref(false)
const sampleDialogMode = ref<'add' | 'edit'>('add') const sampleDialogMode = ref<'add' | 'edit'>('add')
const sampleDialogIndex = ref(-1) const sampleDialogIndex = ref(-1)
const sampleDraft = reactive<Record<string, any>>({}) 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 { function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—' if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v) return '¥' + formatMoneyDisplay(v)
} }
function createEmptyTransport(): SampleTransportForm { function freightModeLabel(row: Record<string, any>) {
return { return (
transportType: '', row.freight?.transportModeLabel ||
expressCompanyId: '', row.freightDraft?.transportMode ||
waybillNo: '', row.freight?.transportMode ||
destSiteId: '', '—'
expressFee: null, )
etaTime: '', }
carrierName: '',
carrierPhone: '' 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) { function normalizeSampleRow(raw: Record<string, any>, _index: number) {
const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
const statusRaw = raw.sampleStatus const statusRaw = raw.sampleStatus
const sampleStatus = const sampleStatus =
statusRaw != null && String(statusRaw).trim() !== '' ? String(statusRaw).trim() : '' 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 { return {
...raw, ...raw,
sampleStatus, sampleStatus,
isSpecial: !!raw.isSpecial, isSpecial: !!raw.isSpecial,
specialRemark: String(raw.specialRemark || '').trim(), specialRemark: String(raw.specialRemark || '').trim(),
_rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`, _rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
transport: { freight,
...createEmptyTransport(), freightDraft,
transportType: transportRaw.transportType || '', freightId: freight?.id || raw.freightId || ''
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 || ''
}
} }
} }
@@ -558,41 +533,6 @@
return name 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) { function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)` if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
if (sc.issued) return `${sc.sampleCode}(已下发)` if (sc.issued) return `${sc.sampleCode}(已下发)`
@@ -638,22 +578,6 @@
if (visible) emit('search-unit', '') 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() { function refreshSampleDialogOptions() {
emit('search-sample-type', '') emit('search-sample-type', '')
if (!props.autoGenerateSampleCode) { if (!props.autoGenerateSampleCode) {
@@ -662,9 +586,68 @@
emit('search-unit', '') emit('search-unit', '')
} }
function refreshBatchTransportOptions() { async function loadTransportModeOptions() {
emit('search-site', '') try {
emit('search-express-company', '') 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>) { function onSampleCodeChange(row: Record<string, any>) {
@@ -709,35 +692,138 @@
selectedSamples.value = rows selectedSamples.value = rows
} }
function openBatchTransportDialog() { async function openFreightDialog() {
if (!selectedSamples.value.length) { if (!selectedSamples.value.length) {
ElMessage.warning('请先勾选需要应用运输信息的样本') ElMessage.warning('请先勾选需要配置运费的样本')
return return
} }
Object.assign(batchTransport, createEmptyTransport()) freightMode.value = 'create'
const firstWithTransport = selectedSamples.value.find((row) => row.transport?.destSiteId) freightForm.transportMode = ''
if (firstWithTransport?.transport) { freightForm.freightAmount = null
Object.assign(batchTransport, { ...firstWithTransport.transport }) 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 freightDialogVisible.value = true
refreshBatchTransportOptions()
} }
function confirmBatchTransportApply() { function onExistingFreightSelect(row: SampleFreight | null) {
if (!batchTransport.transportType) { selectedExistingFreight.value = row
ElMessage.warning('请选择运输类型') }
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 return
} }
const payload = { ...batchTransport } try {
const selectedSet = new Set(selectedSamples.value) await ElMessageBox.confirm('确定取消所选样本的运费关联吗?', '取消运费关联', {
const next = props.samples.map((row) => { type: 'warning',
if (!selectedSet.has(row)) return row confirmButtonText: '确定',
const transport = { ...(row.transport || createEmptyTransport()), ...payload } cancelButtonText: '取消'
return { ...row, transport } })
}) } catch {
updateSamples(next) return
batchTransportVisible.value = false }
ElMessage.success(`已为 ${selectedSamples.value.length} 个样本应用运输信息`) 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) { function onSpecialSampleToggle(checked: boolean | string | number) {
@@ -469,6 +469,8 @@
</div> </div>
<SampleTransportFormSection <SampleTransportFormSection
v-model:samples="formData.samples" v-model:samples="formData.samples"
sample-source="cell_storage"
:protocol-id="formData.id || ''"
hide-dialog-collect-amount-unit hide-dialog-collect-amount-unit
:sample-type-options="sampleTypeOptions" :sample-type-options="sampleTypeOptions"
:sample-type-options-loading="sampleTypeLoading" :sample-type-options-loading="sampleTypeLoading"
@@ -476,17 +478,11 @@
:sample-code-options-loading="sampleCodeLoading" :sample-code-options-loading="sampleCodeLoading"
:unit-options="unitOptions" :unit-options="unitOptions"
:unit-options-loading="unitLoading" :unit-options-loading="unitLoading"
:site-options="siteOptions"
:site-options-loading="siteLoading"
:express-company-options="expressCompanyOptions"
:express-company-options-loading="expressCompanyLoading"
:show-price="true" :show-price="true"
@price-change="onSamplePriceChange" @price-change="onSamplePriceChange"
@search-sample-code="remoteSearchSampleCodes" @search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes" @search-sample-type="remoteSearchSampleTypes"
@search-unit="remoteSearchUnits" @search-unit="remoteSearchUnits"
@search-site="remoteSearchSites"
@search-express-company="remoteSearchExpressCompanies"
/> />
</div> </div>
</div> </div>
@@ -850,7 +846,8 @@
class="detail-badge" class="detail-badge"
:class="{ :class="{
'badge-green': detail.payStatus === 'yjf', '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' 'badge-gray': detail.payStatus === 'tfqrz' || detail.payStatus === 'ytf'
}" }"
> >
@@ -908,6 +905,7 @@
<el-date-picker <el-date-picker
v-model="detail.transferTime" v-model="detail.transferTime"
type="datetime" type="datetime"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择时间" placeholder="选择时间"
clearable clearable
@@ -917,20 +915,11 @@
</el-form-item> </el-form-item>
<el-form-item label="缴费状态"> <el-form-item label="缴费状态">
<el-tag <el-tag
v-if="isPayDetailLocked(detail)" :type="payStatusTagType(String(detail.payStatus || 'dfj'))"
:type="payStatusTagType(String(detail.payStatus))"
size="small" size="small"
> >
{{ payStatusLabel(String(detail.payStatus)) }} {{ payStatusLabel(String(detail.payStatus || 'dfj')) }}
</el-tag> </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>
<el-form-item label="应缴费时间"> <el-form-item label="应缴费时间">
<el-date-picker <el-date-picker
@@ -2367,13 +2356,14 @@
sampleCollectTime: formData.sampleCollectTime || undefined, sampleCollectTime: formData.sampleCollectTime || undefined,
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
}, },
samples: formData.samples.map(({ _rowKey, transport, ...s }) => ({ samples: formData.samples.map(({ _rowKey, transport, freight, freightDraft, ...s }) => ({
...s, ...s,
sampleStatus: sampleStatus:
s.sampleStatus != null && String(s.sampleStatus).trim() !== '' s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
? String(s.sampleStatus).trim() ? String(s.sampleStatus).trim()
: null, : null,
transport: transport?.transportType ? { ...transport } : undefined freightId: freight?.id || s.freightId || undefined,
freightDraft: freightDraft || undefined
})) }))
} }
@@ -4238,6 +4228,11 @@
color: #065f46; color: #065f46;
} }
&.badge-primary {
background: #ecfdf5;
color: #047857;
}
&.badge-amber { &.badge-amber {
background: #fffbeb; background: #fffbeb;
color: #d97706; color: #d97706;
@@ -307,7 +307,16 @@
> >
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div> <div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards"> <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__header">
<div class="sample-card__title"> <div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span> <span class="sample-card__seq">{{ sIdx + 1 }}</span>
@@ -355,31 +364,27 @@
</div> </div>
</div> </div>
<div <div
v-if="row.transport?.transportType" v-if="row.freight?.id || row.freight?.transportMode"
class="sample-card__section sample-card__section--transport" 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-grid data-grid--4">
<div class="data-item"> <div class="data-item">
<div class="data-label">运输类型</div> <div class="data-label">运输方式</div>
<div class="data-value">{{ <div class="data-value">{{
transportTypeLabel(String(row.transport?.transportType || '')) dash(row.freight?.transportModeLabel || row.freight?.transportMode)
}}</div> }}</div>
</div> </div>
<div class="data-item"> <div class="data-item">
<div class="data-label">目的站点</div> <div class="data-label">运费(元)</div>
<div class="data-value">{{ <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> </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> </div>
<div class="sample-card__section sample-card__section--reception"> <div class="sample-card__section sample-card__section--reception">
@@ -419,6 +424,35 @@
<el-empty v-else description="暂无样本" :image-size="48" /> <el-empty v-else description="暂无样本" :image-size="48" />
</div> </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 id="view-section-party" class="section-card section-card--green">
<div class="section-title section-title--banner"><h3>客户信息</h3></div> <div class="section-title section-title--banner"><h3>客户信息</h3></div>
@@ -926,7 +960,7 @@
<div class="flow-detail-card__kv"> <div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span> <span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value" <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>
<div v-if="detail.transferTime" class="flow-detail-card__kv"> <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 SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/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 SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import ProtocolHandlePanel from '@/components/business/protocol-handle-panel/index.vue'
import { import {
fetchCollectionProtocolAuditList, fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail, fetchCollectionProtocolDetail,
@@ -1271,8 +1306,17 @@
showStorageCert?: boolean showStorageCert?: boolean
/** 满月待办查看:客户信息下方独立展示新生儿保险信息 */ /** 满月待办查看:客户信息下方独立展示新生儿保险信息 */
showFullmoonInsurance?: 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<{ const emit = defineEmits<{
@@ -1289,6 +1333,11 @@
const formTabs = computed(() => { const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs] 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) { if (props.showFullmoonInsurance) {
const partyIdx = tabs.findIndex((t) => t.key === 'party') const partyIdx = tabs.findIndex((t) => t.key === 'party')
tabs.splice(partyIdx + 1, 0, { key: 'fullmoon-insurance', label: '新生儿保险' }) tabs.splice(partyIdx + 1, 0, { key: 'fullmoon-insurance', label: '新生儿保险' })
@@ -1705,7 +1754,7 @@
return { return {
key: `ov-${i}`, key: `ov-${i}`,
label: name, label: name,
subLabel: '已缴费', subLabel: '缴费成功',
dotClass: 'done', dotClass: 'done',
dotIcon: '✓', dotIcon: '✓',
labelClass: 'done', labelClass: 'done',
@@ -1726,7 +1775,7 @@
return { return {
key: `ov-${i}`, key: `ov-${i}`,
label: name, label: name,
subLabel: '待缴费', subLabel: '已缴费',
dotClass: 'pending', dotClass: 'pending',
dotIcon: '○', dotIcon: '○',
labelClass: '' labelClass: ''
@@ -1993,6 +2042,13 @@
void finishLookups(data, baseLookupsPromise) void finishLookups(data, baseLookupsPromise)
void auditPromise void auditPromise
if (props.highlightSampleCode) {
nextTick(() => {
document
.getElementById(`view-sample-${props.highlightSampleCode}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
} }
function scrollToSection(key: string) { function scrollToSection(key: string) {
@@ -2024,6 +2080,19 @@
if (visible && id) loadDetail() 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -2394,6 +2463,11 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); 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 { .sample-card__header {
@@ -255,6 +255,8 @@
</div> </div>
<SampleTransportFormSection <SampleTransportFormSection
v-model:samples="formData.samples" v-model:samples="formData.samples"
sample-source="research_donation"
:protocol-id="formData.id || ''"
hide-collect-detail-fields hide-collect-detail-fields
auto-generate-sample-code auto-generate-sample-code
:reserve-sample-code="reserveDonationSampleCode" :reserve-sample-code="reserveDonationSampleCode"
@@ -264,16 +266,10 @@
:sample-code-options-loading="sampleCodeLoading" :sample-code-options-loading="sampleCodeLoading"
:unit-options="unitOptions" :unit-options="unitOptions"
:unit-options-loading="unitLoading" :unit-options-loading="unitLoading"
:site-options="siteOptions"
:site-options-loading="siteLoading"
:express-company-options="expressCompanyOptions"
:express-company-options-loading="expressCompanyLoading"
:show-price="false" :show-price="false"
@search-sample-code="remoteSearchSampleCodes" @search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes" @search-sample-type="remoteSearchSampleTypes"
@search-unit="remoteSearchUnits" @search-unit="remoteSearchUnits"
@search-site="remoteSearchSites"
@search-express-company="remoteSearchExpressCompanies"
/> />
</div> </div>
</div> </div>
@@ -1411,13 +1407,14 @@
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
}, },
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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, ...s,
sampleStatus: sampleStatus:
s.sampleStatus != null && String(s.sampleStatus).trim() !== '' s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
? String(s.sampleStatus).trim() ? String(s.sampleStatus).trim()
: null, : 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 id="view-section-sample" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div> <div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards"> <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__header">
<div class="sample-card__title"> <div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span> <span class="sample-card__seq">{{ sIdx + 1 }}</span>
@@ -261,31 +270,27 @@
</div> </div>
</div> </div>
<div <div
v-if="row.transport?.transportType" v-if="row.freight?.id || row.freight?.transportMode"
class="sample-card__section sample-card__section--transport" 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-grid data-grid--4">
<div class="data-item"> <div class="data-item">
<div class="data-label">运输类型</div> <div class="data-label">运输方式</div>
<div class="data-value">{{ <div class="data-value">{{
transportTypeLabel(String(row.transport?.transportType || '')) dash(row.freight?.transportModeLabel || row.freight?.transportMode)
}}</div> }}</div>
</div> </div>
<div class="data-item"> <div class="data-item">
<div class="data-label">目的站点</div> <div class="data-label">运费(元)</div>
<div class="data-value">{{ <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> </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> </div>
<div class="sample-card__section sample-card__section--reception"> <div class="sample-card__section sample-card__section--reception">
@@ -596,8 +601,9 @@
listRow?: ResearchDonationRow | null listRow?: ResearchDonationRow | null
/** 展示审核记录 Tab(科研捐赠审核页) */ /** 展示审核记录 Tab(科研捐赠审核页) */
showAuditHistory?: boolean showAuditHistory?: boolean
highlightSampleCode?: string
}>(), }>(),
{ showAuditHistory: false } { showAuditHistory: false, highlightSampleCode: '' }
) )
const emit = defineEmits<{ const emit = defineEmits<{
@@ -953,6 +959,13 @@
bundle.value = data as Record<string, any> bundle.value = data as Record<string, any>
await loadLookups(data as Record<string, any>) await loadLookups(data as Record<string, any>)
await loadAuditRecords() await loadAuditRecords()
if (props.highlightSampleCode) {
nextTick(() => {
document
.getElementById(`view-sample-${props.highlightSampleCode}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -982,6 +995,19 @@
if (visible && id) loadDetail() 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -1311,6 +1337,11 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); 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 { .sample-card__header {