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
| 文件 | 职责 |
|---|---|
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 |
断言文案更新 |
Files:
web-admin/src/utils/sample-freight-config-guard.tsCreate: 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(),
'所选样本均已配置运费,请先取消运费后再配置'
)
})
})
Run:
cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts
Expected: FAIL(模块不存在)
/** 协议单/运费配置:区分可配置与已配样本 */
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 '所选样本均已配置运费,请先取消运费后再配置'
}
Run:
cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts
Expected: PASS
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
)"
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>
确认 @/components/business/freight-unlink-dialog/index.vue 可被其它 Vue 文件 import(无语法错误即可;完整联调在 Task 3/4)。
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
)"
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) 才可点。
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。)
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 匹配即可),避免上面草稿里的重复分支。
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
)"
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()。
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
}
freightIdFreightConfigDialog.vue 的 isSampleSelectable / onSampleSelectionChange 已排除已配样本,本 Task 不必改。若打开时无提示,可保持现状(配置从全局选样,用户看不到已配行)。
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
)"
Files:
mobile-oa-h5/src/market/list-presenter.jsmobile-oa-h5/src/market/components/MarketBottomBar.vueModify: 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'), '取消运费')
Run:
cd mobile-oa-h5 && node --test src/market/list-presenter.test.js
Expected: FAIL on unlink label
list-presenter.js:
unlink: '取消运费'
MarketBottomBar.vue:
unlink: '取消运费'
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,满足部分取消)
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
)"
Files: 无强制代码;按需跑脚本
cd web-admin && node --import tsx --test src/utils/sample-freight-config-guard.test.ts
Expected: PASS
cd mobile-oa-h5 && node --test src/market/list-presenter.test.js src/market/module-contracts.test.js
Expected: PASS
./scripts/update-graph.sh frontend
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 要求 | Task |
|---|---|
| 双按钮文案「配置运费」「取消运费」 | 3, 4, 5 |
| 已配不可进配置;混合跳过 N | 1, 3 |
| 取消统一弹窗勾选、可部分解除 | 2, 3, 4 |
| 编辑运费保留 | 4(不改编辑入口) |
| Web 协议单 + 运费页 | 3, 4 |
| mobile-oa-h5 对齐 | 5 |
| 无新 API | 全任务 |
| 废除单按钮串联 | 未实现该路径 |
clearLocalFreight 在 Task 3 步骤中写了冗余分支,实现时收敛为按 id/_rowKey 一次 map。!freightId,本计划以文案对齐为主,避免无必要大改表单。