2026-10-08-freight-link-existing-enrich.md 8.6 KB

配置运费「关联已有」列表 enrichment Implementation Plan

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: 让协议单与运费信息页「配置运费 → 关联已有」列表对齐「运费信息」主表信息密度(合并单元格 + 样本明细 + 创建人),便于用户判断关联哪一笔批次。

Architecture: 抽出 flattenSampleFreights 工具与 SampleFreightPickTable 选批表格组件;两处弹窗改为 includeItems: true 加载并嵌入该组件;按 freightId 单选,不改 create/link API。

Tech Stack: Vue 3 + Element Plus + TypeScript(web-admin);既有 fetchSampleFreightList。

Spec: docs/superpowers/specs/2026-10-08-freight-link-existing-enrich-design.md


File Structure

文件 职责
Create: web-admin/src/utils/sample-freight-flatten.ts flatten + spanMethod
Create: web-admin/src/components/business/sample-freight-pick-table/index.vue 关联已有选批表
Modify: web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue 嵌入选批表、加宽、keyword
Modify: web-admin/src/components/business/sample-transport-form-section/index.vue 嵌入选批表、加宽、includeItems
Modify(可选): web-admin/src/views/cell-storage/sample-freight/index.vue 改用公共 flatten(减少重复)

Task 1: flatten 工具

Files:

  • Create: web-admin/src/utils/sample-freight-flatten.ts

  • [x] Step 1: 新增工具文件

import type { SampleFreight } from '@/api/sample-freight'

export type SampleFreightFlatRow = {
  rowKey: string
  freightId: string
  rowspan: number
  isFirst: boolean
  transportMode: string
  transportModeLabel: string
  freightAmount: number
  linkedSampleCount: number
  createBy?: string
  createByName?: string
  createTime: string
  sampleSource?: string
  protocolNo?: string
  sampleCode?: string
  sampleTypeName?: string
  sampleId?: string
  protocolId?: string
  /** 原始批次,选中时回传 */
  freight: SampleFreight
}

export function flattenSampleFreights(list: SampleFreight[]): SampleFreightFlatRow[] {
  const rows: SampleFreightFlatRow[] = []
  for (const f of list) {
    const items = f.items?.length ? f.items : [null]
    items.forEach((it, idx) => {
      rows.push({
        rowKey: `${f.id}|${it?.sampleId || idx}`,
        freightId: f.id,
        rowspan: idx === 0 ? items.length : 0,
        isFirst: idx === 0,
        transportMode: f.transportMode,
        transportModeLabel: f.transportModeLabel || f.transportMode,
        freightAmount: f.freightAmount,
        linkedSampleCount: f.linkedSampleCount ?? (f.items?.length || 0),
        createBy: f.createBy || '',
        createByName: f.createByName || f.createBy || '',
        createTime: f.createTime || '',
        sampleSource: it?.sampleSource,
        protocolNo: it?.protocolNo,
        sampleCode: it?.sampleCode,
        sampleTypeName: it?.sampleTypeName,
        sampleId: it?.sampleId,
        protocolId: it?.protocolId,
        freight: f
      })
    })
  }
  return rows
}

const MERGE_PROPS = new Set([
  'selection',
  'radio',
  'transportMode',
  'freightAmount',
  'linkedSampleCount',
  'createByName',
  'createTime'
])

export function sampleFreightSpanMethod({
  column,
  row
}: {
  column: { type?: string; property?: string }
  row: SampleFreightFlatRow
}): { rowspan: number; colspan: number } {
  const prop = column.type === 'selection' ? 'selection' : column.property || ''
  if (MERGE_PROPS.has(prop)) {
    if (row.rowspan > 0) return { rowspan: row.rowspan, colspan: 1 }
    return { rowspan: 0, colspan: 0 }
  }
  return { rowspan: 1, colspan: 1 }
}
  • Step 2: 确认无 TS 错误后进入 Task 2(本工具无独立单测;逻辑与主列表一致)

Task 2: SampleFreightPickTable 组件

Files:

  • Create: web-admin/src/components/business/sample-freight-pick-table/index.vue

  • [x] Step 1: 实现选批表格

Props:

  • list: SampleFreight[]
  • loading?: boolean
  • modelValue: string(选中的 freightId)
  • maxHeight?: number | string(默认 280)
  • keyword?: string(可选,仅展示由父级控制检索时可不内置)

Emits:

  • update:modelValue
  • select → SampleFreight | null

行为:

  • 内部 flattenSampleFreights + sampleFreightSpanMethod
  • 第一列 radio(prop="radio"),仅 isFirst 行显示;点击任意行 setCurrentRow / 设置 modelValue 为该行 freightId
  • 列:运输方式 Tag、运费、关联数、来源 Tag、协议单号、样本编码、样本类型、创建人、创建时间
  • 金额用 formatMoneyDisplay
  • @row-click 选中整批

模板要点(radio 列):

<el-table-column prop="radio" label="" min-width="48" align="center" header-align="center">
  <template #default="{ row }">
    <el-radio
      v-if="row.isFirst"
      :model-value="modelValue"
      :value="row.freightId"
      @change="emitSelect(row.freight)"
    />
  </template>
</el-table-column>

@row-click="(row) => emitSelect(row.freight)"。

  • Step 2: 肉眼确认组件可被 import

Task 3: FreightConfigDialog 接入

Files:

  • Modify: web-admin/src/views/cell-storage/sample-freight/FreightConfigDialog.vue

  • [x] Step 1: 弹窗 width 改为 1020px

  • [x] Step 2: 「关联已有」区替换为 keyword + SampleFreightPickTable

<div v-else class="link-existing-block" style="margin-bottom: 12px">
  <div class="sample-pick-toolbar" style="margin-bottom: 10px">
    <el-input
      v-model="existingKeyword"
      placeholder="运输方式/协议单号/样本编码"
      clearable
      style="width: 260px"
      @keyup.enter="loadExistingFreights"
    />
    <el-button class="btn-ghost" @click="loadExistingFreights">
      <el-icon><Search /></el-icon>
      搜索
    </el-button>
  </div>
  <SampleFreightPickTable
    v-model="selectedFreightId"
    :list="existingFreightList"
    :loading="existingLoading"
    @select="onPickFreight"
  />
</div>
  • Step 3: loadExistingFreights 传 includeItems: true 与 keyword
const res = await fetchSampleFreightList({
  page: 1,
  pageSize: 50,
  includeItems: true,
  keyword: existingKeyword.value || undefined
})
  • [x] Step 4: 用 selectedFreightId + selectedExisting 同步;open/closed 时清空 keyword 与选中

  • [x] Step 5: 提交时仍用 selectedExisting.value.id(或由 id 从 list 找回)


Task 4: SampleTransportFormSection 接入

Files:

  • Modify: web-admin/src/components/business/sample-transport-form-section/index.vue

  • [x] Step 1: 配置运费弹窗 width 改为 1020px

  • [x] Step 2: 「关联已有」旧 el-table 换成 SampleFreightPickTable + 可选 keyword(与 Task 3 一致)

  • [x] Step 3: openFreightDialog 中 list 请求加 includeItems: true

  • [x] Step 4: selectedExistingFreight 由 pick 的 @select / v-model 更新;confirmFreight 的 link 分支不变


Task 5:(可选)主列表复用 flatten

Files:

  • Modify: web-admin/src/views/cell-storage/sample-freight/index.vue

  • [x] Step 1: 删除本地 flattenFreights / 调整 spanMethod,改 import flattenSampleFreights + sampleFreightSpanMethod

  • [x] Step 2: FlatRow 可复用 SampleFreightFlatRow(操作列仍本地;span 合并 props 若含 actions,在页面包装一层把 actions 加入 merge 集合,或扩展 util 支持 extraMergeProps)

若扩展成本高可跳过本 Task,验收不依赖此项。


Task 6: 验收

  • Step 1: 运费信息页 → 配置运费 → 关联已有:列齐全、合并正确、搜索可用、可单选并提交(代码已接入,请浏览器手工点验)
  • Step 2: 细胞存储协议单 → 配置运费 → 关联已有:同上(共用组件已接入)
  • Step 3: 科研捐赠协议单 → 配置运费 → 关联已有:同上(共用 SampleTransportFormSection)
  • Step 4: 运行 ./scripts/update-graph.sh frontend

Spec coverage checklist

Spec 要求 Task
对齐运费信息列(含创建人) 2
两入口一致 3, 4
includeItems=true 3, 4
按 freightId 单选 2
弹窗加宽 3, 4
轻量 keyword 3, 4
不改 create/link 规则 3, 4 保持现有提交
创建人后端已有 前置已完成

Self-review notes

  • 无 TBD/占位。
  • SampleFreightFlatRow.freight 保证选中后无需再查 map。
  • radio 列 prop="radio" 须在 MERGE_PROPS 中,与 selection 同行为。