2026-06-10-sample-transport-list-mode.md 22 KB

样本及运输信息 · 卡片改列表 实现计划

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 ≥ 160
  • 弹窗 label-position="top"、dialog-form-layout、无 backdrop blur
  • 采集协议单保留 price 字段及 syncContractAmount 联动
  • 「配置运输信息」:须先勾选表格行才可用(沿用现有批量逻辑,文案调整)

交互设计(定稿)

主区域结构

┌─ 样本及运输信息 ─────────────────────────────────────┐
│ [添加样本]  [配置运输信息]   ← 后者无勾选时 disabled   │
│ ┌─ el-table(stripe, inner-table)─────────────────┐ │
│ │ □ │序号│样本类型│编码│采集量│单位│价格*│年限│状态│ │
│ │   │    │运输类型│目的站点│预计到达│快递/携带摘要  │ │
│ │   │                                    │编辑│删除│ │
│ └──────────────────────────────────────────────────┘ │
│ el-empty(无数据)                                    │
└──────────────────────────────────────────────────────┘
* 价格列仅采集协议单

子弹窗 A:添加/编辑样本

项 说明
标题 添加样本 / 编辑样本
宽度 840px
布局 form-grid-2(窄屏自动折行,沿用全局样式)
字段 与现卡片内字段一致(见下表)
确定 校验必填 → 写入/更新 samples 对应行 → 关闭
取消 丢弃草稿,关闭

采集协议单样本字段: 样本类型*、样本编码*、采集量、单位、价格(元)、存储年限(年)、样本状态

捐赠协议单样本字段: 同上,无价格

必填规则(与现逻辑一致):

  • sampleTypeId、sampleCodeId 提交前由父级整体校验;子弹窗确定时做同样校验并 ElMessage.warning

联动(确定写入前执行,逻辑从父组件迁入组件或通过 callback):

  • onSampleTypeChange:协议单同步 price+storageYears;捐赠单仅 storageYears
  • onSampleCodeChange:回填 sampleCode
  • onSampleStatusChange:协议单触发 syncContractAmount

子弹窗 B:配置运输信息

项 说明
标题 配置运输信息(原「批量运输信息」)
宽度 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)。


组件 API 设计

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、batchTransportVisible
  • sampleDialogVisible、sampleDialogMode('add'|'edit')、sampleDialogIndex、sampleDraft(编辑草稿,避免取消污染列表)
  • normalizeSampleRow / createEmptyTransport(从父组件复制,保持结构一致)
  • 展示 helper:sampleTypeName、unitName、siteName、expressName、sampleTransportExtra、sampleRowLabel

实现任务

Task 1: 新建公共组件骨架

Files:

  • 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>
  • Step 2: 从 ProtocolFormDialog.vue 复制并精简以下函数到组件内

来源行号参考(2026-06-10):

  • createEmptyTransport ~L1282
  • normalizeSampleRow ~L1588(去掉 _transportExpanded)
  • sampleCodeOptionLabel / isSampleCodeOptionDisabled / onSampleCodeChange
  • onSampleTypeChange(分支:showPrice 时写 price)
  • onSampleStatusChange
  • sampleTransportExtra / siteName / expressName
  • openBatchTransportDialog / 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')
}

Task 2: 实现列表区模板

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>
  • Step 2: 添加 scoped 样式
.sample-transport-section {
  .sample-toolbar {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 16px;
  }
  .sample-transport-table-wrap {
    margin-top: 0;
  }
}

Task 3: 实现样本添加/编辑子弹窗

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>
  • Step 2: 草稿逻辑(避免取消污染列表)
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
}

Task 4: 迁移运输配置子弹窗

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>
  • Step 2: 打开弹窗时 Object.assign(batchTransport, createEmptyTransport()) 重置表单

Task 5: 接入采集协议单弹窗

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'
  • Step 2: 替换 Tab3 整块(L265-509)
<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>
  • Step 3: 删除父组件中已迁入子组件的代码

删除项:

  • Tab3 卡片 template(L272-508)
  • 批量运输弹窗 template(L1032-1130 附近)
  • selectedSamples、batchTransport、batchTransportVisible
  • createEmptyTransport、openBatchTransportDialog、confirmBatchTransportApply
  • addSample、removeSample(若仅样本 Tab 使用)
  • onSampleSelectionChange
  • sampleRowLabel、sampleTransportExtra(若仅样本 Tab 使用)
  • scoped 样式块 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。


Task 6: 接入捐赠协议单弹窗

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 附近)


Task 7: 验证与图谱更新

  • Step 1: 本地 lint
cd web-admin && pnpm lint

Expected: 无 ESLint 错误(或仅既有无关 warning)

  • Step 2: 手工测试清单
# 场景 预期
1 采集协议单 - 新增 - 添加样本 弹窗填写 → 列表出现一行摘要
2 采集协议单 - 编辑样本 修改价格 → 合同金额联动更新
3 采集协议单 - 勾选多行 - 配置运输信息 选中行 transport 批量更新,列表运输列刷新
4 采集协议单 - 删除样本 行移除,合同金额重算
5 采集协议单 - 保存 payload.samples 结构与改前一致
6 捐赠协议单 - 同上 1/3/4/5 无价格列,无合同金额联动
7 样本编码不可重复选择 编辑弹窗下拉禁用已占用编码
8 无样本时空态 显示 el-empty
  • Step 3: 更新知识图谱
./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 供表单/组件共用
  • OpenSpec ui-conventions 补充「协议单样本子表」模式编号

Spec 覆盖自检

需求 对应 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 模板