For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将「采集协议单」「捐赠协议单」新增/编辑弹窗中 Tab「样本及运输信息」从卡片行内编辑改为列表摘要展示,工具栏提供「添加样本」「配置运输信息」,操作列提供「编辑」「删除」。
Architecture: 抽取公共业务组件 SampleTransportFormSection,内嵌 el-table(只读摘要列 + 多选列 + 操作列)及两个子弹窗(样本表单、运输配置)。父弹窗通过 v-model:samples 绑定 formData.samples,采集协议单额外传入 show-price 与 @price-change 以保留合同金额联动。后端 payload 与 normalizeSampleRow / 提交结构不变。
Tech Stack: Vue 3 + Element Plus + SCSS;样式对齐 list-table inner-table(web-admin/src/styles/_route-config.scss);状态 Tag 用 @/utils/status-tag-registry。
关键约束:
ProtocolViewDialog / DonationViewDialog 查看态卡片暂不动<el-icon>(Close/Check/Plus/Edit/Delete 等)min-width,操作列 fixed="right",min-width ≥ 160label-position="top"、dialog-form-layout、无 backdrop blurprice 字段及 syncContractAmount 联动┌─ 样本及运输信息 ─────────────────────────────────────┐
│ [添加样本] [配置运输信息] ← 后者无勾选时 disabled │
│ ┌─ el-table(stripe, inner-table)─────────────────┐ │
│ │ □ │序号│样本类型│编码│采集量│单位│价格*│年限│状态│ │
│ │ │ │运输类型│目的站点│预计到达│快递/携带摘要 │ │
│ │ │ │编辑│删除│ │
│ └──────────────────────────────────────────────────┘ │
│ el-empty(无数据) │
└──────────────────────────────────────────────────────┘
* 价格列仅采集协议单
| 项 | 说明 |
|---|---|
| 标题 | 添加样本 / 编辑样本 |
| 宽度 | 840px |
| 布局 | form-grid-2(窄屏自动折行,沿用全局样式) |
| 字段 | 与现卡片内字段一致(见下表) |
| 确定 | 校验必填 → 写入/更新 samples 对应行 → 关闭 |
| 取消 | 丢弃草稿,关闭 |
采集协议单样本字段: 样本类型*、样本编码*、采集量、单位、价格(元)、存储年限(年)、样本状态
捐赠协议单样本字段: 同上,无价格
必填规则(与现逻辑一致):
sampleTypeId、sampleCodeId 提交前由父级整体校验;子弹窗确定时做同样校验并 ElMessage.warning联动(确定写入前执行,逻辑从父组件迁入组件或通过 callback):
onSampleTypeChange:协议单同步 price+storageYears;捐赠单仅 storageYearsonSampleCodeChange:回填 sampleCodeonSampleStatusChange:协议单触发 syncContractAmount| 项 | 说明 |
|---|---|
| 标题 | 配置运输信息(原「批量运输信息」) |
| 宽度 | 1200px |
| 触发 | 工具栏按钮;无勾选样本时 disabled + 点击提示 |
| 内容 | 沿用现有 batchTransport 表单(运输类型 / 目的站点 / 预计到达 / 快递或携带字段) |
| 提示 | 「将以下运输信息应用到已选中的 N 个样本。」 |
| 确定 | 校验运输类型 → Object.assign 到选中行 transport → 成功提示 |
| 列 | 渲染 |
|---|---|
| 序号 | type="index" |
| 样本类型 | sampleTypeOptions 查 typeName,空则 — |
| 样本编码 | row.sampleCode 或 — |
| 采集量 | 数值或 — |
| 单位 | unitOptions 查 unitName |
| 价格(元) | fmtMoney(row.price),仅 showPrice |
| 存储年限 | 数值 + 年 或 — |
| 样本状态 | <el-tag :type="sampleStatusTagType(...)" size="small"> |
| 运输类型 | transportTypeLabel 或 — |
| 目的站点 | siteName(destSiteId) |
| 预计到达 | row.transport.etaTime 或 — |
| 快递/携带 | sampleTransportExtra(transport)(协议单已有实现,捐赠单补齐) |
| 文件 | 操作 | 说明 |
|---|---|---|
web-admin/src/components/business/sample-transport-form-section/index.vue |
新建 | 列表区 + 样本子弹窗 + 运输子弹窗 |
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue |
修改 | Tab3 替换为组件;删除卡片模板/样式;保留 syncContractAmount |
web-admin/src/views/research-donation/list/DonationFormDialog.vue |
修改 | 同上,show-price=false |
不修改: 后端 API、查看弹窗、Demo HTML、docs/ 规范(除非评审要求补 OpenSpec)。
SampleTransportFormSection<SampleTransportFormSection
v-model:samples="formData.samples"
:sample-type-options="sampleTypeOptions"
:sample-code-options="sampleCodeOptions"
:unit-options="unitOptions"
:site-options="siteOptions"
:express-company-options="expressCompanyOptions"
:show-price="true | false"
sample-code-used-key="usedInProtocol"
@price-change="onSamplePriceChange"
/>
| Prop | 类型 | 说明 |
|---|---|---|
samples |
Array<Record<string, any>> |
v-model,含 _rowKey 与 transport |
sampleTypeOptions |
数组 | 父级已加载 |
sampleCodeOptions |
数组 | 父级已加载 |
unitOptions |
数组 | 父级已加载 |
siteOptions |
数组 | 父级已加载 |
expressCompanyOptions |
数组 | 父级已加载 |
showPrice |
boolean |
采集协议单 true,捐赠 false |
sampleCodeUsedKey |
'usedInProtocol' |
编码禁用判断字段名(两模块相同) |
| Emit | 说明 |
|---|---|
update:samples |
增删改样本后 |
price-change |
价格/状态/删除样本后(仅协议单监听) |
组件内部自持:
selectedSamples、batchTransport、batchTransportVisiblesampleDialogVisible、sampleDialogMode('add'|'edit')、sampleDialogIndex、sampleDraft(编辑草稿,避免取消污染列表)normalizeSampleRow / createEmptyTransport(从父组件复制,保持结构一致)sampleTypeName、unitName、siteName、expressName、sampleTransportExtra、sampleRowLabelFiles:
Create: web-admin/src/components/business/sample-transport-form-section/index.vue
[ ] Step 1: 创建组件文件,定义 props / emits / 内部 state
<script setup lang="ts">
import { Plus, Edit, Delete, Close, Check } from '@element-plus/icons-vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import {
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
const props = withDefaults(
defineProps<{
samples: Array<Record<string, any>>
sampleTypeOptions: Array<{ id: string; typeName: string; typeCode?: string; storagePrice?: number | null; storageYears?: number | null }>
sampleCodeOptions: Api.BizSampleCode.SampleCodeItem[]
unitOptions: Array<{ id: string; unitName: string }>
siteOptions: Api.Site.SiteItem[]
expressCompanyOptions: Api.ExpressCompany.ExpressCompanyItem[]
showPrice?: boolean
sampleCodeUsedKey?: 'usedInProtocol'
}>(),
{ showPrice: false, sampleCodeUsedKey: 'usedInProtocol' }
)
const emit = defineEmits<{
(e: 'update:samples', val: Array<Record<string, any>>): void
(e: 'price-change'): void
}>()
</script>
ProtocolFormDialog.vue 复制并精简以下函数到组件内来源行号参考(2026-06-10):
createEmptyTransport ~L1282normalizeSampleRow ~L1588(去掉 _transportExpanded)sampleCodeOptionLabel / isSampleCodeOptionDisabled / onSampleCodeChangeonSampleTypeChange(分支:showPrice 时写 price)onSampleStatusChangesampleTransportExtra / siteName / expressNameopenBatchTransportDialog / confirmBatchTransportApply
[ ] Step 3: 实现 updateSamples 辅助
function updateSamples(next: Array<Record<string, any>>) {
emit('update:samples', next)
}
function removeSample(index: number) {
const next = [...props.samples]
next.splice(index, 1)
updateSamples(next)
if (props.showPrice) emit('price-change')
}
Files:
Modify: web-admin/src/components/business/sample-transport-form-section/index.vue
[ ] Step 1: 替换卡片为表格 + 工具栏
<div class="sample-transport-section">
<div class="sample-toolbar">
<el-button type="primary" @click="openAddSampleDialog">
<el-icon><Plus /></el-icon>
添加样本
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedSamples.length"
@click="openBatchTransportDialog"
>
配置运输信息
</el-button>
</div>
<div v-if="samples.length" class="list-body list-wrap sample-transport-table-wrap">
<el-table
ref="sampleTableRef"
:data="samples"
stripe
class="list-table inner-table"
row-key="_rowKey"
@selection-change="onSampleSelectionChange"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="64" align="center" header-align="center" />
<el-table-column label="样本类型" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ sampleTypeName(row.sampleTypeId) }}</template>
</el-table-column>
<el-table-column label="样本编码" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.sampleCode || '—' }}</template>
</el-table-column>
<el-table-column label="采集量" min-width="88" align="center" header-align="center">
<template #default="{ row }">{{ row.collectAmount ?? '—' }}</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center" header-align="center">
<template #default="{ row }">{{ unitName(row.unitId) }}</template>
</el-table-column>
<el-table-column v-if="showPrice" label="价格(元)" min-width="100" align="right" header-align="center">
<template #default="{ row }">{{ fmtMoney(row.price) }}</template>
</el-table-column>
<el-table-column label="存储年限" min-width="96" align="center" header-align="center">
<template #default="{ row }">{{ row.storageYears != null ? row.storageYears + '年' : '—' }}</template>
</el-table-column>
<el-table-column label="样本状态" min-width="96" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.sampleStatus"
:type="sampleStatusTagType(String(row.sampleStatus))"
size="small"
>
{{ sampleStatusLabel(String(row.sampleStatus)) }}
</el-tag>
<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>
<el-table-column label="目的站点" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ siteName(String(row.transport?.destSiteId || '')) }}</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>
<el-table-column label="操作" min-width="160" fixed="right" align="center" header-align="center">
<template #default="{ row, $index }">
<el-button class="btn-op-item" @click="openEditSampleDialog(row, $index)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click="removeSample($index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无样本,点击上方按钮添加" :image-size="48" />
</div>
.sample-transport-section {
.sample-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.sample-transport-table-wrap {
margin-top: 0;
}
}
Files:
Modify: web-admin/src/components/business/sample-transport-form-section/index.vue
[ ] Step 1: 样本弹窗 template(字段从现 ProtocolFormDialog L311-393 迁移)
<el-dialog
v-model="sampleDialogVisible"
:title="sampleDialogMode === 'add' ? '添加样本' : '编辑样本'"
width="840px"
class="dialog-form-layout"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
>
<el-form
ref="sampleFormRef"
:model="sampleDraft"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<!-- sampleTypeId, sampleCodeId, collectAmount, unitId -->
<!-- price (v-if showPrice), storageYears, sampleStatus -->
</div>
</el-form>
<template #footer>
<el-button @click="sampleDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" @click="confirmSampleDialog">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
const sampleDialogVisible = ref(false)
const sampleDialogMode = ref<'add' | 'edit'>('add')
const sampleDialogIndex = ref(-1)
const sampleDraft = reactive<Record<string, any>>({})
function openAddSampleDialog() {
sampleDialogMode.value = 'add'
sampleDialogIndex.value = -1
Object.assign(
sampleDraft,
normalizeSampleRow(
{
sampleTypeId: '',
sampleCode: '',
sampleCodeId: '',
collectAmount: null,
unitId: '',
price: props.showPrice ? null : undefined,
storageYears: null,
sampleStatus: 'ycj'
},
props.samples.length
)
)
sampleDialogVisible.value = true
}
function openEditSampleDialog(row: Record<string, any>, index: number) {
sampleDialogMode.value = 'edit'
sampleDialogIndex.value = index
Object.assign(sampleDraft, normalizeSampleRow({ ...row, transport: { ...row.transport } }, index))
sampleDialogVisible.value = true
}
function confirmSampleDialog() {
if (!sampleDraft.sampleTypeId) {
ElMessage.warning('请选择样本类型')
return
}
if (!sampleDraft.sampleCodeId) {
ElMessage.warning('请选择样本编码')
return
}
const next = [...props.samples]
const normalized = normalizeSampleRow({ ...sampleDraft }, sampleDialogIndex.value >= 0 ? sampleDialogIndex.value : next.length)
if (sampleDialogMode.value === 'add') {
next.push(normalized)
} else {
next[sampleDialogIndex.value] = normalized
}
updateSamples(next)
if (props.showPrice) emit('price-change')
sampleDialogVisible.value = false
}
Files:
Modify: web-admin/src/components/business/sample-transport-form-section/index.vue
[ ] Step 1: 从 ProtocolFormDialog.vue L1032-1130 迁移弹窗,改 title
<el-dialog
v-model="batchTransportVisible"
title="配置运输信息"
width="1200px"
class="dialog-form-layout"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
>
<p class="sample-transport-hint batch-transport-dialog-hint">
将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。
</p>
<!-- 原 sample-transport-form-row 表单 -->
<template #footer>
<el-button @click="batchTransportVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" @click="confirmBatchTransportApply">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
Object.assign(batchTransport, createEmptyTransport()) 重置表单Files:
Modify: web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue
[ ] Step 1: 在 script 中 import 组件
import SampleTransportFormSection from '@/components/business/sample-transport-form-section/index.vue'
<div id="form-section-sample" class="form-tab-pane">
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>样本及运输信息</h3></div>
</div>
<SampleTransportFormSection
v-model:samples="formData.samples"
:sample-type-options="sampleTypeOptions"
:sample-code-options="sampleCodeOptions"
:unit-options="unitOptions"
:site-options="siteOptions"
:express-company-options="expressCompanyOptions"
:show-price="true"
@price-change="onSamplePriceChange"
/>
</div>
</div>
删除项:
selectedSamples、batchTransport、batchTransportVisiblecreateEmptyTransport、openBatchTransportDialog、confirmBatchTransportApplyaddSample、removeSample(若仅样本 Tab 使用)onSampleSelectionChangesampleRowLabel、sampleTransportExtra(若仅样本 Tab 使用)SAMPLE CARDS(L3147-3280 附近)及 .batch-transport-dialog-hint保留项:
normalizeSampleRow — 若 applyBundleToForm 仍需要,可保留在父组件;或改为从组件 export 工具函数。推荐父组件保留 normalizeSampleRow 用于编辑回填,addSample 改为不再直接 push(由组件负责新增)。onSampleTypeChange / onSampleCodeChange / isSampleCodeOptionDisabled — 迁入组件后可删syncContractAmount / onSamplePriceChange / onSampleStatusChange — 保留在父组件,由 @price-change 触发
[ ] Step 4: 编辑回填时仍用 normalizeSampleRow
applyBundleToForm 内 formData.samples = samples.map(...) 逻辑不变,确保每行有 transport 与 _rowKey。
Files:
Modify: web-admin/src/views/research-donation/list/DonationFormDialog.vue
[ ] Step 1: 与 Task 5 相同,但 :show-price="false",无 @price-change
[ ] Step 2: 删除捐赠弹窗中对应的卡片 template、批量运输弹窗、重复逻辑与样式(L199-427、L815-900 附近、L2378-2490 附近)
cd web-admin && pnpm lint
Expected: 无 ESLint 错误(或仅既有无关 warning)
| # | 场景 | 预期 |
|---|---|---|
| 1 | 采集协议单 - 新增 - 添加样本 | 弹窗填写 → 列表出现一行摘要 |
| 2 | 采集协议单 - 编辑样本 | 修改价格 → 合同金额联动更新 |
| 3 | 采集协议单 - 勾选多行 - 配置运输信息 | 选中行 transport 批量更新,列表运输列刷新 |
| 4 | 采集协议单 - 删除样本 | 行移除,合同金额重算 |
| 5 | 采集协议单 - 保存 | payload.samples 结构与改前一致 |
| 6 | 捐赠协议单 - 同上 1/3/4/5 | 无价格列,无合同金额联动 |
| 7 | 样本编码不可重复选择 | 编辑弹窗下拉禁用已占用编码 |
| 8 | 无样本时空态 | 显示 el-empty |
./scripts/update-graph.sh frontend
| 风险 | 对策 |
|---|---|
| 表格列过多横向溢出 | 区域在 Tab 内可横向滚动;show-overflow-tooltip;弹窗宽度已较宽 |
| 编辑取消污染数据 | 使用 sampleDraft 草稿,确定才写回 |
| 删除样本后表格 selection 残留 | removeSample 后 sampleTableRef.value?.clearSelection?.() |
| 两模块逻辑漂移 | 公共组件单点维护;showPrice 区分差异 |
| 提交校验遗漏样本 | 父组件 handleSubmit 现有样本校验若有则保留;无则在 submit 前遍历 formData.samples |
ProtocolViewDialog / DonationViewDialog 卡片改表格(查看弹窗样本区已是卡片+网格,与表单列表风格可后续统一)normalizeSampleRow 到 @/utils/protocol-sample.ts 供表单/组件共用ui-conventions 补充「协议单样本子表」模式编号| 需求 | 对应 Task |
|---|---|
| 卡片改列表 | Task 2 |
| 每行展示样本+运输 | Task 2 列定义 |
| 工具栏「添加样本」「配置运输信息」 | Task 2 |
| 操作列「编辑」「删除」 | Task 2 + Task 3 |
| 采集协议单 / 捐赠协议单 | Task 5 + Task 6 |
| 后端结构不变 | 架构说明 + Task 7 用例 5/6 |
| UI 规范(Tag、按钮图标、inner-table) | Task 2 模板 |