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 中,将同一协议单下多个样本质检工单合并为一张协议组卡片,组内用 Tab 切换样本,样本内保留触发来源 Tab。
Architecture: 前端新增 groupInboundQcWorkOrdersByProtocol() 与 QcWorkOrderProtocolGroup.vue 作为协议外层;复用 QcWorkOrderSampleGroup 作为样本内层(hideContextBar);QcWorkOrderCardList 在 inbound-grouped 布局时渲染协议组。后端 list_work_orders 关键词增加 protocol_no 匹配。两页(manage/mine)共用 CardList,mine 关键词补 protocolNo。
Tech Stack: Python 3.9 / FastAPI / SQLAlchemy(backend);Vue 3 / TypeScript / Element Plus(web-admin)
Spec: docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md
| 文件 | 职责 |
|---|---|
web-admin/src/utils/qc-work-order-groups.ts |
协议分组模型、groupInboundQcWorkOrdersByProtocol、gate 工单/进度统计 helper |
web-admin/src/utils/qc-work-order-card-meta.ts |
协议组头副标题、协议状态 Tag、组级 paymentSummary 选取 |
web-admin/src/utils/qc-work-order-tabs.ts |
入库 Tab 关键词 placeholder 含协议号/捐赠单号 |
web-admin/src/utils/qc-mine-task-groups.ts |
mine 前端关键词增加 protocolNo |
web-admin/src/views/.../QcWorkOrderProtocolGroup.vue |
新建 协议组卡片(组头 + 样本 Tab + 内嵌 SampleGroup) |
web-admin/src/views/.../QcWorkOrderSampleGroup.vue |
hideContextBar、hideProtocolFeeSummary prop |
web-admin/src/views/.../QcWorkOrderSingleCard.vue |
尊重 hideProtocolFeeSummary |
web-admin/src/views/.../QcWorkOrderCardList.vue |
inbound 布局改用 QcWorkOrderProtocolGroup |
backend/app/crud/qc_work_order.py |
列表关键词 OR 增加 protocol_no.like |
不修改: buildAllTabListItems(「所有」Tab)、出库/临时 Tab、buildMineCardListModel(mine 走 flat rows + CardList 分组)。
Files:
Modify: web-admin/src/utils/qc-work-order-groups.ts
[ ] Step 1: 在文件末尾、buildAllTabListItems 之前追加类型与 helper
在 isGateTriggerSource 之后、buildAllTabListItems 之前插入:
export function resolveInboundProtocolGroupKey(row: QcWorkOrder): string {
const id = (row.collectionProtocolId || '').trim()
if (id) return id
const no = (row.protocolNo || '').trim()
if (no) return `protocol-no:${no}`
return row.id
}
export function resolveInboundSampleGroupKey(row: QcWorkOrder): string {
return (row.sampleCode || '').trim() || row.id
}
/** 样本组内用于组头/Tab 状态的 gate 工单(接收优先,否则首条) */
export function resolveGateWorkOrderForSampleGroup(
workOrders: QcWorkOrder[]
): QcWorkOrder | undefined {
if (!workOrders.length) return undefined
return (
workOrders.find((w) => isGateTriggerSource(w.triggerSource)) ||
workOrders[0]
)
}
export type QcWorkOrderProtocolGroupModel = {
groupKey: string
protocolId: string
protocolNo: string
receptionSource: 'cell_storage' | 'research_donation'
sampleGroups: QcWorkOrderSampleGroupModel[]
workOrders: QcWorkOrder[]
}
function protocolGroupSortTime(group: QcWorkOrderProtocolGroupModel): string {
let max = ''
for (const wo of group.workOrders) {
const t = String(wo.createTime || '')
if (t > max) max = t
}
return max
}
/** 入库 Tab:按协议单分组,组内再按样本分组 */
export function groupInboundQcWorkOrdersByProtocol(
rows: QcWorkOrder[]
): QcWorkOrderProtocolGroupModel[] {
const map = new Map<string, QcWorkOrder[]>()
for (const row of rows) {
const key = resolveInboundProtocolGroupKey(row)
const list = map.get(key)
if (list) list.push(row)
else map.set(key, [row])
}
const groups = Array.from(map.entries()).map(([groupKey, workOrders]) => {
const sampleGroups = groupInboundQcWorkOrders(workOrders)
const head = workOrders[0]
const source =
(head?.receptionSource || 'cell_storage') === 'research_donation'
? 'research_donation'
: 'cell_storage'
return {
groupKey,
protocolId: (head?.collectionProtocolId || '').trim(),
protocolNo: (head?.protocolNo || '').trim(),
receptionSource: source as 'cell_storage' | 'research_donation',
sampleGroups,
workOrders
}
})
return groups.sort(
(a, b) => protocolGroupSortTime(b).localeCompare(protocolGroupSortTime(a))
)
}
/** 协议组进度:已配置样本数 / 样本总数(按 gate 工单 workOrderStatus !== dpz) */
export function countConfiguredSamplesInProtocolGroup(
group: QcWorkOrderProtocolGroupModel
): { configured: number; total: number } {
const total = group.sampleGroups.length
let configured = 0
for (const sg of group.sampleGroups) {
const gate = resolveGateWorkOrderForSampleGroup(sg.workOrders)
if (gate && gate.workOrderStatus !== 'dpz') configured += 1
}
return { configured, total }
}
在 web-admin 目录用两条 mock 工单(同 collectionProtocolId、不同 sampleCode)调用 groupInboundQcWorkOrdersByProtocol,期望返回 1 个 group、sampleGroups.length === 2。
git add web-admin/src/utils/qc-work-order-groups.ts
git commit -m "feat(web-admin): add inbound QC work order protocol grouping utils"
Files:
Modify: web-admin/src/utils/qc-work-order-card-meta.ts
[ ] Step 1: 增加 import 与 helper
文件顶部 import 区增加:
import {
countConfiguredSamplesInProtocolGroup,
type QcWorkOrderProtocolGroupModel
} from '@/utils/qc-work-order-groups'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
在 buildDonationGroupContext 之后追加:
export function buildProtocolGroupProgressLabel(
group: QcWorkOrderProtocolGroupModel
): string {
const { configured, total } = countConfiguredSamplesInProtocolGroup(group)
if (total <= 0) return ''
if (configured <= 0) return ''
return `${configured}/${total} 已配置`
}
export function resolveProtocolGroupStatus(row: QcWorkOrder): {
label: string
tagType: TagType
} {
const status = String(row.protocolStatus || '')
const source = row.receptionSource || 'cell_storage'
return {
label: protocolStatusLabelBySource(status, source),
tagType: protocolStatusTagTypeBySource(status, source)
}
}
/** 协议组级费用摘要:取组内首条有 paymentSummary 的 cell_storage 工单 */
export function resolveProtocolGroupPaymentSummary(
group: QcWorkOrderProtocolGroupModel
): QcProtocolPaymentSummary | null {
if (group.receptionSource !== 'cell_storage') return null
for (const wo of group.workOrders) {
if (wo.paymentSummary) return wo.paymentSummary
}
return null
}
git add web-admin/src/utils/qc-work-order-card-meta.ts
git commit -m "feat(web-admin): add protocol group card meta helpers for inbound QC"
Files:
web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSampleGroup.vueModify: web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue
[ ] Step 1: QcWorkOrderSampleGroup.vue — props + 条件渲染
defineProps 增加:
hideContextBar?: boolean
hideProtocolFeeSummary?: boolean
模板中 qc-sample-row__ctx 外层加 v-if="!hideContextBar"。
QcWorkOrderSingleCard 传参增加:
:hide-protocol-fee-summary="hideProtocolFeeSummary"
QcWorkOrderSingleCard.vue — prop + computedprops 增加:
hideProtocolFeeSummary?: boolean
修改 showProtocolFeeSummary computed:
const showProtocolFeeSummary = computed(
() =>
!props.hideProtocolFeeSummary &&
effectiveCardMeta.value.showProtocolFeeSummary &&
!!props.row.paymentSummary
)
打开细胞存储 Tab,在改动 CardList 前行为应不变(默认 prop false)。
git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSampleGroup.vue \
web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue
git commit -m "feat(web-admin): allow hiding sample context bar and card-level protocol fee"
QcWorkOrderProtocolGroup.vueFiles:
Create: web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolGroup.vue
[ ] Step 1: 创建组件(完整骨架)
<!-- 质检工单 · 同协议多样本分组(协议组头 + 样本 Tab + SampleGroup) -->
<template>
<article class="qc-protocol-row">
<div class="qc-protocol-row__ctx">
<span class="qc-protocol-row__idx">{{ groupIndex }}</span>
<span class="qc-protocol-row__code">{{ group.protocolNo || '—' }}</span>
<el-tag v-if="protocolStatus.label" :type="protocolStatus.tagType" size="small">
{{ protocolStatus.label }}
</el-tag>
<span class="qc-protocol-row__sub">
{{ sampleCountLabel }}
<template v-if="progressLabel"> · {{ progressLabel }}</template>
</span>
<span v-if="group.workOrders.length > 1" class="qc-protocol-row__badge">
{{ group.workOrders.length }} 条质检
</span>
</div>
<QcWorkOrderProtocolPaymentPanel
v-if="protocolPaymentSummary"
:summary="protocolPaymentSummary"
compact
class="qc-protocol-row__fee"
/>
<div
v-if="group.sampleGroups.length > 1"
class="qc-protocol-sample-tabs"
>
<div class="qc-protocol-sample-tabs__row" role="tablist" aria-label="同协议样本">
<button
v-for="(sampleGroup, tabIndex) in group.sampleGroups"
:key="sampleTabKey(sampleGroup)"
type="button"
role="tab"
class="qc-wo-indep-tabs__btn"
:class="{ 'is-active': sampleTabKey(sampleGroup) === activeSampleKey }"
:aria-selected="sampleTabKey(sampleGroup) === activeSampleKey"
@click="activeSampleKey = sampleTabKey(sampleGroup)"
>
<span class="qc-wo-tab__index">{{ tabIndex + 1 }}</span>
<span class="qc-wo-tab__no">{{ sampleGroup.sampleCode || '—' }}</span>
<span v-if="sampleTypeLabel(sampleGroup)" class="qc-wo-tab__source qc-wo-tab__source--reception">
{{ sampleTypeLabel(sampleGroup) }}
</span>
<el-tag
v-if="gateWorkOrder(sampleGroup)"
:type="workOrderStatusTagType(gateWorkOrder(sampleGroup)!.workOrderStatus)"
size="small"
class="qc-wo-tab__status"
>
{{ workOrderStatusLabel(gateWorkOrder(sampleGroup)!.workOrderStatus) }}
</el-tag>
</button>
</div>
</div>
<QcWorkOrderSampleGroup
v-if="activeSampleGroup"
:group="activeSampleGroup"
:group-index="groupIndex"
:card-meta-config="cardMetaConfig"
:task-refresh-token="taskRefreshToken"
:show-work-order-ops="showWorkOrderOps"
:show-work-order-view-only="showWorkOrderViewOnly"
:task-list-mode="taskListMode"
:preset-tasks-by-work-order-id="presetTasksByWorkOrderId"
hide-context-bar
:hide-protocol-fee-summary="!!protocolPaymentSummary"
@configure="(row) => emit('configure', row)"
@view="(row) => emit('view', row)"
@command="(cmd, row) => emit('command', cmd, row)"
@tasks-changed="emit('tasks-changed')"
/>
</article>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import { workOrderStatusLabel } from '@/utils/qc-labels'
import type { TagType } from '@/utils/status-tag-registry'
import {
type QcWorkOrderProtocolGroupModel,
type QcWorkOrderSampleGroupModel,
resolveGateWorkOrderForSampleGroup,
resolveInboundSampleGroupKey
} from '@/utils/qc-work-order-groups'
import type { QcWorkOrderCardMetaConfig } from '@/utils/qc-work-order-card-meta'
import {
buildProtocolGroupProgressLabel,
resolveProtocolGroupPaymentSummary,
resolveProtocolGroupStatus
} from '@/utils/qc-work-order-card-meta'
import QcWorkOrderSampleGroup from './QcWorkOrderSampleGroup.vue'
import QcWorkOrderProtocolPaymentPanel from './QcWorkOrderProtocolPaymentPanel.vue'
defineOptions({ name: 'QcWorkOrderProtocolGroup' })
const props = withDefaults(
defineProps<{
group: QcWorkOrderProtocolGroupModel
groupIndex: number
cardMetaConfig: QcWorkOrderCardMetaConfig
taskRefreshToken: number
showWorkOrderOps?: boolean
showWorkOrderViewOnly?: boolean
taskListMode?: 'workOrder' | 'mine'
presetTasksByWorkOrderId?: Record<string, QcTask[]>
}>(),
{
showWorkOrderOps: true,
showWorkOrderViewOnly: false,
taskListMode: 'workOrder'
}
)
const emit = defineEmits<{
configure: [row: QcWorkOrder]
view: [row: QcWorkOrder]
command: [cmd: string, row: QcWorkOrder]
'tasks-changed': []
}>()
function sampleTabKey(sampleGroup: QcWorkOrderSampleGroupModel) {
return sampleGroup.groupKey || resolveInboundSampleGroupKey(sampleGroup.workOrders[0])
}
const activeSampleKey = ref(
props.group.sampleGroups[0] ? sampleTabKey(props.group.sampleGroups[0]) : ''
)
watch(
() => props.group.sampleGroups.map((g) => sampleTabKey(g)).join(','),
() => {
const keys = props.group.sampleGroups.map((g) => sampleTabKey(g))
if (!keys.includes(activeSampleKey.value)) {
activeSampleKey.value = keys[0] || ''
}
}
)
const activeSampleGroup = computed(() =>
props.group.sampleGroups.find((g) => sampleTabKey(g) === activeSampleKey.value)
)
const gateRow = computed(() =>
resolveGateWorkOrderForSampleGroup(
props.group.sampleGroups[0]?.workOrders || props.group.workOrders
)
)
const protocolStatus = computed(() =>
gateRow.value
? resolveProtocolGroupStatus(gateRow.value)
: { label: '', tagType: 'info' as TagType }
)
const sampleCountLabel = computed(() => `${props.group.sampleGroups.length} 个样本`)
const progressLabel = computed(() => buildProtocolGroupProgressLabel(props.group))
const protocolPaymentSummary = computed(() =>
resolveProtocolGroupPaymentSummary(props.group)
)
function sampleTypeLabel(sampleGroup: QcWorkOrderSampleGroupModel) {
const gate = resolveGateWorkOrderForSampleGroup(sampleGroup.workOrders)
return (gate?.sampleTypeName || sampleGroup.workOrders[0]?.sampleTypeName || '').trim()
}
function gateWorkOrder(sampleGroup: QcWorkOrderSampleGroupModel) {
return resolveGateWorkOrderForSampleGroup(sampleGroup.workOrders)
}
function workOrderStatusTagType(status: string): TagType {
if (status === 'ywc') return 'success'
if (status === 'ypz') return 'warning'
if (status === 'yzf') return 'danger'
return 'info'
}
</script>
在 </script> 后追加样式(复用 QcWorkOrderSampleGroup 的 pill Tab 类名,协议组头对齐 qc-sample-row__ctx):
<style scoped lang="scss">
.qc-protocol-row {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.qc-protocol-row__ctx {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 12px 16px;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
border-bottom: 1px solid #d1fae5;
}
.qc-protocol-row__idx {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
min-width: 20px;
}
.qc-protocol-row__code {
font-size: 14px;
font-weight: 600;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.qc-protocol-row__sub {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.qc-protocol-row__badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 999px;
background: var(--primary-bg, #ecfdf5);
color: #047857;
border: 1px solid #d1fae5;
}
.qc-protocol-row__fee {
border-bottom: 1px solid #e2e8f0;
}
.qc-protocol-sample-tabs {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.qc-protocol-sample-tabs__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
max-width: 100%;
}
/* 将 QcWorkOrderSampleGroup.vue 中 .qc-wo-indep-tabs__btn 及相关 pill Tab 样式复制到此处 */
</style>
git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolGroup.vue
git commit -m "feat(web-admin): add protocol group card with sample tabs for inbound QC"
QcWorkOrderCardList 接入协议分组Files:
Modify: web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderCardList.vue
[ ] Step 1: import 与 groupedRows 分支
script 区:
import {
groupInboundQcWorkOrders,
groupInboundQcWorkOrdersByProtocol,
groupOutboundQcWorkOrders,
buildAllTabListItems,
type QcWorkOrderAllTabListItem
} from '@/utils/qc-work-order-groups'
import QcWorkOrderProtocolGroup from './QcWorkOrderProtocolGroup.vue'
新增 computed:
const protocolGroupedRows = computed(() => {
if (!cardMetaUsesInboundGroups(props.cardMetaConfig.tabKey)) return []
return groupInboundQcWorkOrdersByProtocol(props.rows)
})
将 useGroupedList 分支中:
<QcWorkOrderSampleGroup
v-for="(group, idx) in groupedRows"
替换为:
<QcWorkOrderProtocolGroup
v-if="cardMetaUsesInboundGroups(cardMetaConfig.tabKey)"
v-for="(group, idx) in protocolGroupedRows"
:key="group.groupKey"
:group="group"
:group-index="cardIndex(idx)"
...
/>
<QcWorkOrderSampleGroup
v-else
v-for="(group, idx) in groupedRows"
...
/>
或更简洁:groupedRows computed 内 inbound 返回空数组,单独 protocolGroupedRows 仅 inbound 使用;outbound 仍用 groupedRows。
推荐改 groupedRows:
const groupedRows = computed(() => {
if (cardMetaUsesOutboundGroups(props.cardMetaConfig.tabKey)) {
return groupOutboundQcWorkOrders(props.rows)
}
return []
})
inbound 专用 protocolGroupedRows 见 Step 1。
URL: http://localhost:3006/#/quality-group/quality-manage/qc-work-order?tab=inbound_cell_storage
期望:同协议多样本 → 1 张协议卡 + 样本 Tab;单样本 → 无样本 Tab 行。
git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderCardList.vue
git commit -m "feat(web-admin): render inbound QC lists as protocol groups with sample tabs"
Files:
web-admin/src/utils/qc-work-order-tabs.tsModify: web-admin/src/utils/qc-mine-task-groups.ts
[ ] Step 1: keywordPlaceholderForTab
将 inbound 默认 return 改为:
if (tab.key === 'inbound_research_donation') {
return '工单号/捐赠单号/样本编码/入库编码'
}
if (tab.group === 'inbound') {
return '工单号/协议号/样本编码/入库编码'
}
(放在 tab.group === 'outbound' 判断之前,且 inbound_research_donation 先于 general inbound。)
keywordMatchesWorkOrder inbound 字段将 inbound 组 fields 数组改为:
: [row.workOrderNo, row.protocolNo, row.sampleCode, row.labStorageCode]
git add web-admin/src/utils/qc-work-order-tabs.ts web-admin/src/utils/qc-mine-task-groups.ts
git commit -m "feat(web-admin): support protocol number in inbound QC keyword filters"
protocol_noFiles:
Modify: backend/app/crud/qc_work_order.py
[ ] Step 1: 在 list_work_orders 关键词 OR 中追加
定位 _filtered() 内(约 L1272):
keyword_conditions = [
BizQcWorkOrder.work_order_no.like(like),
BizQcWorkOrder.sample_code.like(like),
BizQcWorkOrder.protocol_no.like(like),
]
cd backend && source venv/bin/activate
# 假设本地服务已起;替换 TOKEN 与协议号
curl -s "http://127.0.0.1:8000/api/web/admin/qc-work-orders?page=1&pageSize=10&workOrderType=inbound&receptionSource=cell_storage&keyword=XY2026" \
-H "Authorization: Bearer <token>" | python -m json.tool | head -40
期望:返回列表中 protocolNo 含关键词的工单。
git add backend/app/crud/qc_work_order.py
git commit -m "feat(backend): include protocol number in QC work order list keyword search"
Files:
Modify: docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md(状态 → 已实现)
[ ] Step 1: 前端 lint
cd web-admin && pnpm lint
期望:无新增 error。
| # | 场景 | 页面 | 期望 |
|---|---|---|---|
| 1 | 同协议 2+ 样本 | 质检工单 · 细胞存储 | 1 协议卡,样本 Tab 可切换 |
| 2 | 同协议 1 样本 | 同上 | 无样本 Tab,直接工单区 |
| 3 | 同样本 接收+生产 | 同上 | 内层触发来源 Tab 正常 |
| 4 | 协议费用 | 细胞存储 | 组头一次,卡片内不重复 |
| 5 | 科研捐赠 | 科研捐赠 Tab | 无费用条,捐赠单号组头 |
| 6 | 关键词 | 两页 | 协议号/捐赠单号可搜 |
| 7 | 所有 Tab | 两页 | 仍平铺,无协议分组 |
| 8 | 出库/临时 Tab | 质检工单 | 布局不变 |
| 9 | 我的质检任务 | mine · 细胞存储 | 与 manage 分组一致,仅查看 |
./scripts/update-graph.sh frontend
./scripts/update-graph.sh backend
docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md 头部 状态:已实现。
git add docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md
git commit -m "docs: mark inbound QC protocol group spec as implemented"
| Spec § | Task |
|---|---|
| §2 三层结构 / 分组键 | Task 1 |
| §3.1 协议组头 | Task 2, 4 |
| §3.2 样本 Tab | Task 4 |
| §3.3 hideContextBar + 内层 SampleGroup | Task 3, 4 |
| §3 协议费用组级一次 | Task 2, 3, 4 |
| §4.4 所有 Tab 不变 | 不修改 buildAllTabListItems |
| §4.5 mine 共用 + protocolNo 关键词 | Task 5, 6 |
| §5.1 后端 protocol_no | Task 7 |
| §5.2 placeholder | Task 6 |
| §7 验收 1–9 | Task 8 |
Saved to docs/superpowers/plans/2026-06-25-qc-work-order-protocol-group.md.
Two execution options:
executing-plans 连续实现,Task 5 完成后浏览器 checkpoint需要哪种方式开始写代码?