2026-10-12-freight-config-cancel-two-button.md 23 KB

运费「配置 / 取消」双按钮规则 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: 协议单、运费信息页与市场小程序对齐:已配运费样本不可再进「配置运费」;混合勾选跳过并提示;「取消运费」统一弹窗勾选后再 unlink。

Architecture: 抽取纯函数分区「未配 / 已配」样本;Web 抽公共 FreightUnlinkDialog;协议单配置只带未配样本;运费页文案与批量取消改为弹窗路径;小程序仅改文案与断言(create/link 已过滤 freightId)。

Tech Stack: Vue 3 + Element Plus(web-admin)、Vue 3 + Vant(mobile-oa-h5)、node:test 单测、现有 /web/admin/sample-freights API(无后端契约变更)。

Spec: docs/superpowers/specs/2026-10-12-freight-config-cancel-two-button-design.md


File map

文件 职责
Create: web-admin/src/utils/sample-freight-config-guard.ts 分区样本、生成跳过/全已配提示文案
Create: web-admin/src/utils/sample-freight-config-guard.test.ts 上述纯函数单测
Create: web-admin/src/components/business/freight-unlink-dialog/index.vue 公共「取消运费」勾选弹窗
Modify: web-admin/src/components/business/sample-transport-form-section/index.vue 配置过滤 + 取消弹窗接入 + 文案
Modify: web-admin/src/views/cell-storage/sample-freight/index.vue 文案、改用公共弹窗、批量取消改弹窗选样
Modify: mobile-oa-h5/src/market/list-presenter.js unlink →「取消运费」
Modify: mobile-oa-h5/src/market/components/MarketBottomBar.vue unlink →「取消运费」
Modify: mobile-oa-h5/src/market/list-presenter.test.js 断言文案更新

Task 1: 配置运费选样守卫(纯函数 + TDD)

Files:

  • Create: web-admin/src/utils/sample-freight-config-guard.ts
  • Create: web-admin/src/utils/sample-freight-config-guard.test.ts

  • [ ] Step 1: Write the failing test

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
  partitionFreightConfigSamples,
  freightConfigSkipMessage,
  freightConfigAllConfiguredMessage
} from './sample-freight-config-guard'

function row(partial: Record<string, unknown>) {
  return partial
}

describe('partitionFreightConfigSamples', () => {
  it('splits configured vs unconfigured (id / draft / freightId)', () => {
    const selected = [
      row({ id: 'a', freight: { id: 'f1' } }),
      row({ id: 'b', freightDraft: { clientKey: 'k1' } }),
      row({ id: 'c', freightId: 'f2' }),
      row({ id: 'd' }),
      row({ id: 'e', freight: null, freightId: '' })
    ]
    const { configurable, skipped } = partitionFreightConfigSamples(selected)
    assert.deepEqual(
      configurable.map((r) => r.id),
      ['d', 'e']
    )
    assert.deepEqual(
      skipped.map((r) => r.id),
      ['a', 'b', 'c']
    )
  })

  it('treats empty selection as no configurable', () => {
    const { configurable, skipped } = partitionFreightConfigSamples([])
    assert.equal(configurable.length, 0)
    assert.equal(skipped.length, 0)
  })
})

describe('freight config messages', () => {
  it('builds skip and all-configured copy', () => {
    assert.equal(
      freightConfigSkipMessage(2),
      '已跳过 2 个已配置运费的样本,将仅为其余样本配置运费'
    )
    assert.equal(
      freightConfigAllConfiguredMessage(),
      '所选样本均已配置运费,请先取消运费后再配置'
    )
  })
})
  • Step 2: Run test to verify it fails

Run:

cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts

Expected: FAIL(模块不存在)

  • Step 3: Write minimal implementation
/** 协议单/运费配置:区分可配置与已配样本 */

export function sampleHasFreight(row: Record<string, any> | null | undefined): boolean {
  if (!row) return false
  if (row.freight && typeof row.freight === 'object' && row.freight.id) return true
  if (row.freightDraft && typeof row.freightDraft === 'object') return true
  const fid = String(row.freightId || '').trim()
  return !!fid
}

export function partitionFreightConfigSamples<T extends Record<string, any>>(selected: T[]): {
  configurable: T[]
  skipped: T[]
} {
  const configurable: T[] = []
  const skipped: T[] = []
  for (const row of selected || []) {
    if (sampleHasFreight(row)) skipped.push(row)
    else configurable.push(row)
  }
  return { configurable, skipped }
}

export function freightConfigSkipMessage(skippedCount: number): string {
  return `已跳过 ${skippedCount} 个已配置运费的样本,将仅为其余样本配置运费`
}

export function freightConfigAllConfiguredMessage(): string {
  return '所选样本均已配置运费,请先取消运费后再配置'
}
  • Step 4: Run test to verify it passes

Run:

cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts

Expected: PASS

  • Step 5: Commit
git add web-admin/src/utils/sample-freight-config-guard.ts web-admin/src/utils/sample-freight-config-guard.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add freight config sample partition helper

EOF
)"

Task 2: 公共「取消运费」弹窗组件

Files:

  • Create: web-admin/src/components/business/freight-unlink-dialog/index.vue

  • [ ] Step 1: Create the dialog component

实现要点(对齐运费页现有取消弹窗 UI,标题改为「取消运费」):

<!-- web-admin/src/components/business/freight-unlink-dialog/index.vue -->
<template>
  <el-dialog
    :model-value="modelValue"
    title="取消运费"
    width="720px"
    class="dialog-form-layout"
    align-center
    append-to-body
    modal-class="protocol-dialog-mask"
    destroy-on-close
    :close-on-click-modal="false"
    @update:model-value="emit('update:modelValue', $event)"
  >
    <p class="unlink-hint">勾选要取消运费的样本:</p>
    <el-table
      ref="tableRef"
      :data="items"
      stripe
      class="list-table"
      max-height="360"
      row-key="rowKey"
      @selection-change="onSelectionChange"
    >
      <el-table-column type="selection" min-width="48" align="center" header-align="center" />
      <el-table-column label="来源" min-width="100" align="center" header-align="center">
        <template #default="{ row }">
          <el-tag :type="row.sampleSource === 'cell_storage' ? 'success' : 'warning'" size="small">
            {{ row.sampleSource === 'cell_storage' ? '细胞存储' : '科研捐赠' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="protocolNo" label="协议单号" min-width="140" show-overflow-tooltip />
      <el-table-column prop="sampleCode" label="样本编码" min-width="120" show-overflow-tooltip />
      <el-table-column prop="sampleTypeName" label="样本类型" min-width="110" show-overflow-tooltip />
    </el-table>
    <template #footer>
      <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
        <el-icon><Close /></el-icon>
        取消
      </el-button>
      <el-button class="btn-main" type="primary" :loading="submitting" @click="onConfirm">
        <el-icon><Check /></el-icon>
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
  import { Check, Close } from '@element-plus/icons-vue'
  import type { SampleFreightItem } from '@/api/sample-freight'
  import { nextTick, ref, watch } from 'vue'

  export type FreightUnlinkRow = SampleFreightItem & {
    rowKey: string
    freightId: string
  }

  const props = defineProps<{
    modelValue: boolean
    items: FreightUnlinkRow[]
    /** 默认勾选的 rowKey 列表 */
    defaultSelectedKeys?: string[]
    submitting?: boolean
  }>()

  const emit = defineEmits<{
    'update:modelValue': [boolean]
    confirm: [FreightUnlinkRow[]]
  }>()

  const tableRef = ref()
  const selected = ref<FreightUnlinkRow[]>([])

  function onSelectionChange(rows: FreightUnlinkRow[]) {
    selected.value = rows
  }

  async function applyDefaultSelection() {
    await nextTick()
    const table = tableRef.value
    if (!table) return
    table.clearSelection?.()
    const keys = new Set(props.defaultSelectedKeys || [])
    for (const row of props.items) {
      if (keys.has(row.rowKey)) table.toggleRowSelection(row, true)
    }
  }

  watch(
    () => [props.modelValue, props.items, props.defaultSelectedKeys] as const,
    ([visible]) => {
      if (visible) applyDefaultSelection()
    }
  )

  function onConfirm() {
    if (!selected.value.length) {
      ElMessage.warning('请勾选要取消运费的样本')
      return
    }
    emit('confirm', selected.value)
  }
</script>

<style scoped lang="scss">
  .unlink-hint {
    margin: 0 0 12px;
    font-size: 13px;
    color: var(--el-text-color-secondary);
  }
</style>
  • Step 2: Smoke-check import path

确认 @/components/business/freight-unlink-dialog/index.vue 可被其它 Vue 文件 import(无语法错误即可;完整联调在 Task 3/4)。

  • Step 3: Commit
git add web-admin/src/components/business/freight-unlink-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add shared freight unlink dialog

EOF
)"

Task 3: 协议单 SampleTransportFormSection 接入

Files:

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

  • [ ] Step 1: Import guard + dialog; rename toolbar button

将工具栏「取消运费关联」改为「取消运费」,并引入:

import FreightUnlinkDialog, {
  type FreightUnlinkRow
} from '@/components/business/freight-unlink-dialog/index.vue'
import {
  partitionFreightConfigSamples,
  freightConfigSkipMessage,
  freightConfigAllConfiguredMessage,
  sampleHasFreight
} from '@/utils/sample-freight-config-guard'
import { fetchSampleFreightDetail, fetchUnlinkSampleFreight } from '@/api/sample-freight'

canUnlinkFreight 保持:勾选中存在 sampleHasFreight(row) 才可点。

  • Step 2: Rewrite openFreightDialog

替换清空后直接打开的逻辑:

async function openFreightDialog() {
  if (!selectedSamples.value.length) {
    ElMessage.warning('请先勾选需要配置运费的样本')
    return
  }
  const { configurable, skipped } = partitionFreightConfigSamples(selectedSamples.value)
  if (!configurable.length) {
    ElMessage.warning(freightConfigAllConfiguredMessage())
    return
  }
  if (skipped.length) {
    ElMessage.info(freightConfigSkipMessage(skipped.length))
  }
  // 后续弹窗内 confirmFreight 必须只用 configurable,不要再用全部 selectedSamples
  freightConfigTargets.value = configurable
  freightMode.value = 'create'
  freightForm.transportMode = ''
  freightForm.freightAmount = null
  selectedExistingFreight.value = null
  selectedFreightId.value = ''
  existingFreightKeyword.value = ''
  await loadTransportModeOptions()
  await reloadExistingFreights()
  freightDialogVisible.value = true
}

新增 freightConfigTargets = ref<Array<Record<string, any>>>([])。在 confirmFreight 开头:

const selected = freightConfigTargets.value.length
  ? freightConfigTargets.value
  : selectedSamples.value

(其余 create/link 逻辑不变,但对象改为 selected。)

  • Step 3: Replace unlinkSelectedFreight with dialog flow

删除「直接对勾选行 unlink」的主路径,改为:

const unlinkDialogVisible = ref(false)
const unlinkDialogItems = ref<FreightUnlinkRow[]>([])
const unlinkDefaultKeys = ref<string[]>([])
const unlinkSubmitting = ref(false)

function unlinkRowKey(it: { sampleSource: string; sampleId: string }) {
  return `${it.sampleSource}:${it.sampleId}`
}

async function openUnlinkFreightDialog() {
  const seeds = selectedSamples.value.filter((r) => sampleHasFreight(r))
  if (!seeds.length) {
    ElMessage.warning('请先选择已配置运费的样本')
    return
  }
  const freightIds = [
    ...new Set(
      seeds
        .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 { items?: any[]; id?: string }
      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 seeds) {
    const source = String(props.sampleSource || 'cell_storage')
    const sampleId = String(s.id || '')
    if (!sampleId) {
      // 仅草稿:本地清除,不进服务端 items
      continue
    }
    const key = unlinkRowKey({ sampleSource: source as any, sampleId })
    if (!byKey.has(key)) {
      byKey.set(key, {
        sampleSource: source as any,
        sampleId,
        protocolId: s.protocolId,
        sampleCode: s.sampleCode,
        sampleTypeName: sampleTypeName(s.sampleTypeId),
        freightId: String(s.freight?.id || s.freightId || ''),
        rowKey: key
      })
    }
  }
  // 纯 freightDraft 无 id:确认时本地清 draft(见 Step 3b)
  unlinkDialogItems.value = [...byKey.values()]
  unlinkDefaultKeys.value = seeds
    .filter((s) => s.id)
    .map((s) =>
      unlinkRowKey({
        sampleSource: String(props.sampleSource || 'cell_storage') as any,
        sampleId: String(s.id)
      })
    )
  if (!unlinkDialogItems.value.length && seeds.every((s) => !s.id && s.freightDraft)) {
    // 仅草稿:确认框后本地清除
    try {
      await ElMessageBox.confirm(
        '确定取消所选样本的运费吗?取消后可再次配置运费。',
        '取消运费',
        { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
      )
    } catch {
      return
    }
    clearLocalFreight(seeds)
    ElMessage.success('已取消运费')
    return
  }
  if (!unlinkDialogItems.value.length) {
    ElMessage.warning('没有可取消的运费样本')
    return
  }
  unlinkDialogVisible.value = true
}

function clearLocalFreight(rows: Array<Record<string, any>>) {
  updateSamples(
    props.samples.map((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 }
    })
  )
}

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 }
      })
    )
    // 同时清勾选中的纯 draft
    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
  }
}

模板中按钮 @click="openUnlinkFreightDialog",并挂上:

<FreightUnlinkDialog
  v-model="unlinkDialogVisible"
  :items="unlinkDialogItems"
  :default-selected-keys="unlinkDefaultKeys"
  :submitting="unlinkSubmitting"
  @confirm="onUnlinkDialogConfirm"
/>

实现时将 clearLocalFreight 写成单一清晰版本(按 id / _rowKey 匹配即可),避免上面草稿里的重复分支。

  • Step 4: Manual check list (protocol)
  1. 未配样本 → 配置运费正常。
  2. 已配样本 → 提示先取消。
  3. 混合勾选 → info 跳过 N,弹窗只配未配。
  4. 取消运费 → 弹窗列出同批样本,可只取消部分。
  • Step 5: Commit
git add web-admin/src/components/business/sample-transport-form-section/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): gate protocol freight config and unify cancel dialog

EOF
)"

Task 4: 运费信息页对齐

Files:

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

  • [ ] Step 1: Rename labels

  • 工具栏「取消关联」→「取消运费」

  • 行内「取消关联」→「取消运费」

  • 内联 el-dialog title「取消关联」删除,改用 FreightUnlinkDialog

  • 确定按钮文案「确定解除」→「确定」(与公共组件一致)

  • 批量确认框标题/文案中的「取消关联」改为「取消运费」

  • [ ] Step 2: Replace inline unlink dialog with FreightUnlinkDialog

删除模板内联取消 el-dialog(约 title=取消关联 那段)。改为:

<FreightUnlinkDialog
  v-model="unlinkVisible"
  :items="unlinkDialogItems"
  :default-selected-keys="unlinkDefaultKeys"
  :submitting="unlinkSubmitting"
  @confirm="confirmUnlinkFromDialog"
/>

openUnlinkDialog(freightId):

async function openUnlinkDialog(freightId: string) {
  try {
    const res = await fetchSampleFreightDetail(freightId)
    const data = ((res as any)?.data || res) as SampleFreight
    unlinkDialogItems.value = (data.items || []).map((it) => ({
      ...it,
      freightId,
      rowKey: `${it.sampleSource}:${it.sampleId}`
    }))
    unlinkDefaultKeys.value = [] // 行内进入:不预勾选,或预勾选全部——推荐预勾选全部以便「取消整批」
    unlinkDefaultKeys.value = unlinkDialogItems.value.map((r) => r.rowKey)
    unlinkVisible.value = true
  } catch {
    ElMessage.error('加载关联样本失败')
  }
}

confirmUnlinkFromDialog(rows):按 freightId 分组调用 fetchUnlinkSampleFreight(支持多批),成功后 loadData()。

  • Step 3: Change batch toolbar cancel to dialog(禁止整批无勾选直清)

handleBatchUnlink 不再 MessageBox 后直接 unlink 全部 items。改为:

async function handleBatchUnlink() {
  if (!selectedFreightIds.value.length) return
  const byKey = new Map<string, FreightUnlinkRow>()
  for (const id of selectedFreightIds.value) {
    const res = await fetchSampleFreightDetail(id)
    const data = ((res as any)?.data || res) as SampleFreight
    for (const it of data.items || []) {
      const row = {
        ...it,
        freightId: id,
        rowKey: `${it.sampleSource}:${it.sampleId}`
      }
      byKey.set(row.rowKey, row)
    }
  }
  unlinkDialogItems.value = [...byKey.values()]
  unlinkDefaultKeys.value = unlinkDialogItems.value.map((r) => r.rowKey)
  if (!unlinkDialogItems.value.length) {
    ElMessage.warning('所选运费下没有关联样本')
    return
  }
  unlinkVisible.value = true
}
  • Step 4: FreightConfigDialog already filters freightId

FreightConfigDialog.vue 的 isSampleSelectable / onSampleSelectionChange 已排除已配样本,本 Task 不必改。若打开时无提示,可保持现状(配置从全局选样,用户看不到已配行)。

  • Step 5: Commit
git add web-admin/src/views/cell-storage/sample-freight/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): align sample-freight page cancel UX with two-button rules

EOF
)"

Task 5: 市场小程序文案对齐

Files:

  • Modify: mobile-oa-h5/src/market/list-presenter.js
  • Modify: mobile-oa-h5/src/market/components/MarketBottomBar.vue
  • Modify: mobile-oa-h5/src/market/list-presenter.test.js

  • [ ] Step 1: Update failing assertion first

在 list-presenter.test.js 中把:

assert.equal(actionLabel('unlink'), '取消关联')

改为:

assert.equal(actionLabel('unlink'), '取消运费')
  • Step 2: Run test to verify it fails

Run:

cd mobile-oa-h5 && node --test src/market/list-presenter.test.js

Expected: FAIL on unlink label

  • Step 3: Update labels

list-presenter.js:

unlink: '取消运费'

MarketBottomBar.vue:

unlink: '取消运费'
  • Step 4: Run tests
cd mobile-oa-h5 && node --test src/market/list-presenter.test.js src/market/module-contracts.test.js

Expected: PASS(create/link 仍只返回无 freightId 样本;unlink 仍用 detail items,满足部分取消)

  • Step 5: Commit
git add mobile-oa-h5/src/market/list-presenter.js \
  mobile-oa-h5/src/market/components/MarketBottomBar.vue \
  mobile-oa-h5/src/market/list-presenter.test.js
git commit -m "$(cat <<'EOF'
feat(mobile-oa-h5): rename freight unlink action to 取消运费

EOF
)"

Task 6: 验收与图谱(若改了 web-admin / backend 结构)

Files: 无强制代码;按需跑脚本

  • Step 1: Run web-admin unit test
cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts

Expected: PASS

  • Step 2: Run mobile tests
cd mobile-oa-h5 && node --test src/market/list-presenter.test.js src/market/module-contracts.test.js

Expected: PASS

  • Step 3: Manual QA checklist(对照 spec §7)
  1. 协议单:已配不可配置;混合跳过 N;取消弹窗可部分解除。
  2. 运费信息页:文案「取消运费」;弹窗勾选;编辑仍可用。
  3. 小程序:列表/底栏「取消运费」;create/link 选不到已关联样本。
  • Step 4: Update graphify if web-admin files created
./scripts/update-graph.sh frontend
  • Step 5: Final commit if graph or leftover docs
git add web-admin/graphify-out docs/superpowers/specs/2026-10-12-freight-config-cancel-two-button-design.md \
  docs/superpowers/plans/2026-10-12-freight-config-cancel-two-button.md
git commit -m "$(cat <<'EOF'
docs: add freight two-button config/cancel spec and plan

EOF
)"

(若用户要求不自动 commit 文档,可跳过本步,仅保留工作区文件。)


Spec coverage checklist

Spec 要求 Task
双按钮文案「配置运费」「取消运费」 3, 4, 5
已配不可进配置;混合跳过 N 1, 3
取消统一弹窗勾选、可部分解除 2, 3, 4
编辑运费保留 4(不改编辑入口)
Web 协议单 + 运费页 3, 4
mobile-oa-h5 对齐 5
无新 API 全任务
废除单按钮串联 未实现该路径

Self-review notes

  • clearLocalFreight 在 Task 3 步骤中写了冗余分支,实现时收敛为按 id/_rowKey 一次 map。
  • 运费页批量取消改为弹窗,满足「不一定整批全消」。
  • 小程序 create/link 已 filter !freightId,本计划以文案对齐为主,避免无必要大改表单。