feat(web-admin): gate protocol freight config and unify cancel dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-12 14:07:07 +08:00
1 parent 75bae974e4
commit 8c0cbb8ab8
1 file changed
+211 -50
@@ -17,10 +17,10 @@
<el-button
class="btn-ghost"
:disabled="!canUnlinkFreight"
@click="unlinkSelectedFreight"
@click="openUnlinkFreightDialog"
>
<el-icon><Delete /></el-icon>
取消运费关联
取消运费
</el-button>
</div>
@@ -106,6 +106,9 @@
>
<template #default="{ row }">{{ freightAmountText(row) }}</template>
</el-table-column>
<el-table-column label="预计到达" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.transport?.etaTime || '—' }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="180"
@@ -192,11 +195,10 @@
@change="onSampleCodeChange(sampleDraft)"
>
<el-option
v-for="sc in sampleCodeOptions"
v-for="sc in selectableSampleCodeOptions"
:key="sc.id"
:label="sampleCodeOptionLabel(sc)"
:value="sc.id"
:disabled="isSampleCodeOptionDisabled(sc, sampleDraft)"
/>
</el-select>
</el-form-item>
@@ -233,16 +235,24 @@
<el-option label="已作废" value="yzf" />
</el-select>
</el-form-item>
<el-form-item class="form-col-span-2">
<el-form-item label="预计到达时间">
<el-date-picker
v-model="sampleDraft.transport.etaTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择预计到达时间"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="特殊样本">
<el-checkbox v-model="sampleDraft.isSpecial" @change="onSpecialSampleToggle">
特殊样本
是
</el-checkbox>
</el-form-item>
<el-form-item label="特殊说明" class="form-col-span-2">
<el-form-item label="特殊说明">
<el-input
v-model="sampleDraft.specialRemark"
type="textarea"
:rows="2"
:disabled="!sampleDraft.isSpecial"
maxlength="256"
show-word-limit
@@ -276,7 +286,7 @@
:close-on-click-modal="false"
>
<p class="sample-transport-hint batch-transport-dialog-hint">
将运费应用到已选中的 {{ selectedSamples.length }} 个样本(可跨协议共用一笔)。
将运费应用到已选中的 {{ freightConfigTargets.length }} 个样本(可跨协议共用一笔)。
</p>
<el-radio-group v-model="freightMode" class="freight-mode-radio" style="margin-bottom: 12px">
<el-radio-button value="create">新建运费</el-radio-button>
@@ -345,6 +355,14 @@
</el-button>
</template>
</el-dialog>
<FreightUnlinkDialog
v-model="unlinkDialogVisible"
:items="unlinkDialogItems"
:default-selected-keys="unlinkDefaultKeys"
:submitting="unlinkSubmitting"
@confirm="onUnlinkDialogConfirm"
/>
</div>
</template>
@@ -353,12 +371,22 @@
import AmountInput from '@/components/common/amount-input/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleFreightPickTable from '@/components/business/sample-freight-pick-table/index.vue'
import FreightUnlinkDialog, {
type FreightUnlinkRow
} from '@/components/business/freight-unlink-dialog/index.vue'
import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
partitionFreightConfigSamples,
freightConfigSkipMessage,
freightConfigAllConfiguredMessage,
sampleHasFreight
} from '@/utils/sample-freight-config-guard'
import { fetchDictDataOptions } from '@/api/admin'
import {
fetchCreateSampleFreight,
fetchLinkSampleFreight,
fetchSampleFreightDetail,
fetchSampleFreightList,
fetchUnlinkSampleFreight,
type SampleFreight,
@@ -439,6 +467,13 @@
const existingFreightKeyword = ref('')
const selectedExistingFreight = ref<SampleFreight | null>(null)
const selectedFreightId = ref('')
/** 本次「配置运费」弹窗实际作用的样本(已过滤已配) */
const freightConfigTargets = ref<Array<Record<string, any>>>([])
const unlinkDialogVisible = ref(false)
const unlinkDialogItems = ref<FreightUnlinkRow[]>([])
const unlinkDefaultKeys = ref<string[]>([])
const unlinkSubmitting = ref(false)
const sampleDialogVisible = ref(false)
const sampleDialogMode = ref<'add' | 'edit'>('add')
@@ -446,7 +481,7 @@
const sampleDraft = reactive<Record<string, any>>({})
const canUnlinkFreight = computed(() =>
selectedSamples.value.some((r) => r.freight?.id || r.freightDraft || r.freightId)
selectedSamples.value.some((r) => sampleHasFreight(r))
)
function fmtMoney(v: unknown): string {
@@ -483,12 +518,23 @@
: null
const freightDraft =
raw.freightDraft && typeof raw.freightDraft === 'object' ? { ...raw.freightDraft } : undefined
const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
return {
...raw,
sampleStatus,
isSpecial: !!raw.isSpecial,
specialRemark: String(raw.specialRemark || '').trim(),
_rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
transport: {
transportType: transportRaw.transportType || '',
expressCompanyId: transportRaw.expressCompanyId || '',
waybillNo: transportRaw.waybillNo || '',
destSiteId: transportRaw.destSiteId || '',
expressFee: transportRaw.expressFee ?? null,
etaTime: transportRaw.etaTime || '',
carrierName: transportRaw.carrierName || '',
carrierPhone: transportRaw.carrierPhone || ''
},
freight,
freightDraft,
freightId: freight?.id || raw.freightId || ''
@@ -535,18 +581,26 @@
}
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
const currentId = String(sampleDraft.sampleCodeId || '')
// 当前编辑行自己的编码仅展示编码本身,避免「已占用」干扰回显
if (currentId && sc.id === currentId) return sc.sampleCode
if (sc.issued) return `${sc.sampleCode}(已下发)`
return sc.sampleCode
}
function isSampleCodeOptionDisabled(
sc: Api.BizSampleCode.SampleCodeItem,
row: Record<string, any>
) {
if (sc.usedInProtocol && String(row.sampleCodeId || '') !== sc.id) return true
return props.samples.some((s) => s !== row && String(s.sampleCodeId || '') === sc.id)
}
/** 已占用 / 本单其它样本已选:不展示;当前编辑行编码始终保留以便回显 */
const selectableSampleCodeOptions = computed(() => {
const currentId = String(sampleDraft.sampleCodeId || '')
const editingIndex = sampleDialogIndex.value
return props.sampleCodeOptions.filter((sc) => {
if (currentId && sc.id === currentId) return true
if (sc.usedInProtocol) return false
return !props.samples.some(
(s, i) =>
(editingIndex < 0 || i !== editingIndex) && String(s.sampleCodeId || '') === sc.id
)
})
})
function sampleCodeFilterForRow(row: Record<string, any>): string | undefined {
if (!row.sampleTypeId) return undefined
@@ -716,6 +770,15 @@
ElMessage.warning('请先勾选需要配置运费的样本')
return
}
const { configurable, skipped } = partitionFreightConfigSamples(selectedSamples.value)
if (!configurable.length) {
ElMessage.warning(freightConfigAllConfiguredMessage())
return
}
if (skipped.length) {
ElMessage.info(freightConfigSkipMessage(skipped.length))
}
freightConfigTargets.value = configurable
freightMode.value = 'create'
freightForm.transportMode = ''
freightForm.freightAmount = null
@@ -733,7 +796,9 @@
}
async function confirmFreight() {
const selected = selectedSamples.value
const selected = freightConfigTargets.value.length
? freightConfigTargets.value
: selectedSamples.value
if (!selected.length) return
const persisted = selected.filter((r) => r.id)
const drafts = selected.filter((r) => !r.id)
@@ -805,41 +870,137 @@
}
}
async function unlinkSelectedFreight() {
const selected = selectedSamples.value.filter(
(r) => r.freight?.id || r.freightDraft || r.freightId
)
if (!selected.length) {
ElMessage.warning('请勾选已关联运费的样本')
return
}
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)
function unlinkRowKey(it: { sampleSource: string; sampleId: string }) {
return `${it.sampleSource}:${it.sampleId}`
}
function clearLocalFreight(rows: Array<Record<string, any>>) {
updateSamples(
props.samples.map((row) => {
if (!selectedSet.has(row)) return row
const hit = rows.some(
(r) =>
(r.id && row.id && r.id === row.id) ||
(r._rowKey && row._rowKey && r._rowKey === row._rowKey)
)
if (!hit) return row
return { ...row, freight: null, freightId: '', freightDraft: undefined }
})
)
ElMessage.success('已取消运费关联')
}
async function openUnlinkFreightDialog() {
const seeds = selectedSamples.value.filter((r) => sampleHasFreight(r))
if (!seeds.length) {
ElMessage.warning('请先选择已配置运费的样本')
return
}
const draftOnly = seeds.filter((s) => !s.id && s.freightDraft)
const persistedSeeds = seeds.filter((s) => s.id)
if (!persistedSeeds.length && draftOnly.length) {
try {
await ElMessageBox.confirm(
'确定取消所选样本的运费吗?取消后可再次配置运费。',
'取消运费',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
} catch {
return
}
clearLocalFreight(draftOnly)
ElMessage.success('已取消运费')
return
}
const freightIds = [
...new Set(
persistedSeeds
.map((r) => String(r.freight?.id || r.freightId || '').trim())
.filter(Boolean)
)
]
const byKey = new Map<string, FreightUnlinkRow>()
for (const fid of freightIds) {
try {
const res = await fetchSampleFreightDetail(fid)
const data = ((res as any)?.data || res) as SampleFreight
for (const it of data.items || []) {
const row: FreightUnlinkRow = {
...it,
freightId: fid,
rowKey: unlinkRowKey(it)
}
byKey.set(row.rowKey, row)
}
} catch {
/* 单批失败时用种子兜底 */
}
}
for (const s of persistedSeeds) {
const sampleId = String(s.id)
const key = unlinkRowKey({ sampleSource: props.sampleSource, sampleId })
if (!byKey.has(key)) {
byKey.set(key, {
sampleSource: props.sampleSource,
sampleId,
protocolId: props.protocolId || undefined,
sampleCode: s.sampleCode,
sampleTypeName: sampleTypeName(s.sampleTypeId),
freightId: String(s.freight?.id || s.freightId || ''),
rowKey: key
})
}
}
unlinkDialogItems.value = [...byKey.values()]
unlinkDefaultKeys.value = persistedSeeds.map((s) =>
unlinkRowKey({ sampleSource: props.sampleSource, sampleId: String(s.id) })
)
if (!unlinkDialogItems.value.length) {
ElMessage.warning('没有可取消的运费样本')
return
}
unlinkDialogVisible.value = true
}
async function onUnlinkDialogConfirm(rows: FreightUnlinkRow[]) {
unlinkSubmitting.value = true
try {
const byFreight = new Map<string, FreightUnlinkRow[]>()
for (const row of rows) {
const fid = String(row.freightId || '').trim()
if (!fid) continue
if (!byFreight.has(fid)) byFreight.set(fid, [])
byFreight.get(fid)!.push(row)
}
for (const [fid, list] of byFreight) {
await fetchUnlinkSampleFreight(
fid,
list.map((x) => ({
sampleSource: x.sampleSource,
sampleId: x.sampleId,
protocolId: x.protocolId,
sampleCode: x.sampleCode
}))
)
}
const idSet = new Set(rows.map((r) => r.sampleId))
updateSamples(
props.samples.map((row) => {
if (!row.id || !idSet.has(String(row.id))) return row
return { ...row, freight: null, freightId: '', freightDraft: undefined }
})
)
const draftSeeds = selectedSamples.value.filter((r) => !r.id && r.freightDraft)
if (draftSeeds.length) clearLocalFreight(draftSeeds)
unlinkDialogVisible.value = false
ElMessage.success('已取消运费')
} catch (e: any) {
ElMessage.error(e?.message || '取消运费失败')
} finally {
unlinkSubmitting.value = false
}
}
function onSpecialSampleToggle(checked: boolean | string | number) {