778 lines
24 KiB
Markdown
778 lines
24 KiB
Markdown
# 入库质检工单 · 协议单多样本 Tab 分组 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:** 在细胞存储质检、科研捐赠质检两个 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`
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `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 分组)。
|
||
|
||
---
|
||
|
||
### Task 1: 协议分组工具函数
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/utils/qc-work-order-groups.ts`
|
||
|
||
- [ ] **Step 1: 在文件末尾、`buildAllTabListItems` 之前追加类型与 helper**
|
||
|
||
在 `isGateTriggerSource` 之后、`buildAllTabListItems` 之前插入:
|
||
|
||
```typescript
|
||
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 }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 本地 smoke(Node REPL 或临时 script,可选)**
|
||
|
||
在 `web-admin` 目录用两条 mock 工单(同 `collectionProtocolId`、不同 `sampleCode`)调用 `groupInboundQcWorkOrdersByProtocol`,期望返回 1 个 group、`sampleGroups.length === 2`。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 协议组头 card-meta helper
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/utils/qc-work-order-card-meta.ts`
|
||
|
||
- [ ] **Step 1: 增加 import 与 helper**
|
||
|
||
文件顶部 import 区增加:
|
||
|
||
```typescript
|
||
import {
|
||
countConfiguredSamplesInProtocolGroup,
|
||
type QcWorkOrderProtocolGroupModel
|
||
} from '@/utils/qc-work-order-groups'
|
||
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
|
||
```
|
||
|
||
在 `buildDonationGroupContext` 之后追加:
|
||
|
||
```typescript
|
||
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
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: SampleGroup / SingleCard 支持隐藏组头与卡片级协议费用
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSampleGroup.vue`
|
||
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue`
|
||
|
||
- [ ] **Step 1: `QcWorkOrderSampleGroup.vue` — props + 条件渲染**
|
||
|
||
`defineProps` 增加:
|
||
|
||
```typescript
|
||
hideContextBar?: boolean
|
||
hideProtocolFeeSummary?: boolean
|
||
```
|
||
|
||
模板中 `qc-sample-row__ctx` 外层加 `v-if="!hideContextBar"`。
|
||
|
||
`QcWorkOrderSingleCard` 传参增加:
|
||
|
||
```vue
|
||
:hide-protocol-fee-summary="hideProtocolFeeSummary"
|
||
```
|
||
|
||
- [ ] **Step 2: `QcWorkOrderSingleCard.vue` — prop + computed**
|
||
|
||
props 增加:
|
||
|
||
```typescript
|
||
hideProtocolFeeSummary?: boolean
|
||
```
|
||
|
||
修改 `showProtocolFeeSummary` computed:
|
||
|
||
```typescript
|
||
const showProtocolFeeSummary = computed(
|
||
() =>
|
||
!props.hideProtocolFeeSummary &&
|
||
effectiveCardMeta.value.showProtocolFeeSummary &&
|
||
!!props.row.paymentSummary
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: 浏览器快速确认**
|
||
|
||
打开细胞存储 Tab,在改动 CardList 前行为应不变(默认 prop false)。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 新建 `QcWorkOrderProtocolGroup.vue`
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderProtocolGroup.vue`
|
||
|
||
- [ ] **Step 1: 创建组件(完整骨架)**
|
||
|
||
```vue
|
||
<!-- 质检工单 · 同协议多样本分组(协议组头 + 样本 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>
|
||
```
|
||
|
||
- [ ] **Step 2: 追加 scoped SCSS**
|
||
|
||
在 `</script>` 后追加样式(复用 `QcWorkOrderSampleGroup` 的 pill Tab 类名,协议组头对齐 `qc-sample-row__ctx`):
|
||
|
||
```scss
|
||
<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>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `QcWorkOrderCardList` 接入协议分组
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderCardList.vue`
|
||
|
||
- [ ] **Step 1: import 与 groupedRows 分支**
|
||
|
||
script 区:
|
||
|
||
```typescript
|
||
import {
|
||
groupInboundQcWorkOrders,
|
||
groupInboundQcWorkOrdersByProtocol,
|
||
groupOutboundQcWorkOrders,
|
||
buildAllTabListItems,
|
||
type QcWorkOrderAllTabListItem
|
||
} from '@/utils/qc-work-order-groups'
|
||
import QcWorkOrderProtocolGroup from './QcWorkOrderProtocolGroup.vue'
|
||
```
|
||
|
||
新增 computed:
|
||
|
||
```typescript
|
||
const protocolGroupedRows = computed(() => {
|
||
if (!cardMetaUsesInboundGroups(props.cardMetaConfig.tabKey)) return []
|
||
return groupInboundQcWorkOrdersByProtocol(props.rows)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 模板 — inbound 分支改用 ProtocolGroup**
|
||
|
||
将 `useGroupedList` 分支中:
|
||
|
||
```vue
|
||
<QcWorkOrderSampleGroup
|
||
v-for="(group, idx) in groupedRows"
|
||
```
|
||
|
||
替换为:
|
||
|
||
```vue
|
||
<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`:
|
||
|
||
```typescript
|
||
const groupedRows = computed(() => {
|
||
if (cardMetaUsesOutboundGroups(props.cardMetaConfig.tabKey)) {
|
||
return groupOutboundQcWorkOrders(props.rows)
|
||
}
|
||
return []
|
||
})
|
||
```
|
||
|
||
inbound 专用 `protocolGroupedRows` 见 Step 1。
|
||
|
||
- [ ] **Step 3: 浏览器验证 manage 页**
|
||
|
||
URL: `http://localhost:3006/#/quality-group/quality-manage/qc-work-order?tab=inbound_cell_storage`
|
||
|
||
期望:同协议多样本 → 1 张协议卡 + 样本 Tab;单样本 → 无样本 Tab 行。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 关键词 placeholder + mine 前端过滤
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/utils/qc-work-order-tabs.ts`
|
||
- Modify: `web-admin/src/utils/qc-mine-task-groups.ts`
|
||
|
||
- [ ] **Step 1: `keywordPlaceholderForTab`**
|
||
|
||
将 inbound 默认 return 改为:
|
||
|
||
```typescript
|
||
if (tab.key === 'inbound_research_donation') {
|
||
return '工单号/捐赠单号/样本编码/入库编码'
|
||
}
|
||
if (tab.group === 'inbound') {
|
||
return '工单号/协议号/样本编码/入库编码'
|
||
}
|
||
```
|
||
|
||
(放在 `tab.group === 'outbound'` 判断之前,且 `inbound_research_donation` 先于 general inbound。)
|
||
|
||
- [ ] **Step 2: `keywordMatchesWorkOrder` inbound 字段**
|
||
|
||
将 inbound 组 fields 数组改为:
|
||
|
||
```typescript
|
||
: [row.workOrderNo, row.protocolNo, row.sampleCode, row.labStorageCode]
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 后端列表关键词 — `protocol_no`
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/crud/qc_work_order.py`
|
||
|
||
- [ ] **Step 1: 在 `list_work_orders` 关键词 OR 中追加**
|
||
|
||
定位 `_filtered()` 内(约 L1272):
|
||
|
||
```python
|
||
keyword_conditions = [
|
||
BizQcWorkOrder.work_order_no.like(like),
|
||
BizQcWorkOrder.sample_code.like(like),
|
||
BizQcWorkOrder.protocol_no.like(like),
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: 手动 API 验证**
|
||
|
||
```bash
|
||
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` 含关键词的工单。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/app/crud/qc_work_order.py
|
||
git commit -m "feat(backend): include protocol number in QC work order list keyword search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 联调回归与文档收尾
|
||
|
||
**Files:**
|
||
- Modify: `docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md`(状态 → 已实现)
|
||
|
||
- [ ] **Step 1: 前端 lint**
|
||
|
||
```bash
|
||
cd web-admin && pnpm lint
|
||
```
|
||
|
||
期望:无新增 error。
|
||
|
||
- [ ] **Step 2: 手工回归清单(两页)**
|
||
|
||
| # | 场景 | 页面 | 期望 |
|
||
|---|------|------|------|
|
||
| 1 | 同协议 2+ 样本 | 质检工单 · 细胞存储 | 1 协议卡,样本 Tab 可切换 |
|
||
| 2 | 同协议 1 样本 | 同上 | 无样本 Tab,直接工单区 |
|
||
| 3 | 同样本 接收+生产 | 同上 | 内层触发来源 Tab 正常 |
|
||
| 4 | 协议费用 | 细胞存储 | 组头一次,卡片内不重复 |
|
||
| 5 | 科研捐赠 | 科研捐赠 Tab | 无费用条,捐赠单号组头 |
|
||
| 6 | 关键词 | 两页 | 协议号/捐赠单号可搜 |
|
||
| 7 | 所有 Tab | 两页 | 仍平铺,无协议分组 |
|
||
| 8 | 出库/临时 Tab | 质检工单 | 布局不变 |
|
||
| 9 | 我的质检任务 | mine · 细胞存储 | 与 manage 分组一致,仅查看 |
|
||
|
||
- [ ] **Step 3: 更新知识图谱**
|
||
|
||
```bash
|
||
./scripts/update-graph.sh frontend
|
||
./scripts/update-graph.sh backend
|
||
```
|
||
|
||
- [ ] **Step 4: 更新 spec 状态**
|
||
|
||
`docs/superpowers/specs/2026-06-25-qc-work-order-protocol-group-design.md` 头部 `状态:已实现`。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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 Coverage Checklist
|
||
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## Plan complete
|
||
|
||
Saved to `docs/superpowers/plans/2026-06-25-qc-work-order-protocol-group.md`.
|
||
|
||
**Two execution options:**
|
||
|
||
1. **Subagent-Driven (recommended)** — 按 Task 1→8 派发子 agent,每 Task 完成后 review
|
||
2. **Inline Execution** — 本会话内用 `executing-plans` 连续实现,Task 5 完成后浏览器 checkpoint
|
||
|
||
需要哪种方式开始写代码?
|