This commit is contained in:
zlwu committed 2026-08-31 15:18:18 +08:00
1 parent 04d462ad43
commit 7e9155fef1
7 files changed
+1754 -68

No files matched your search

@@ -0,0 +1,611 @@
# 质检第三方按公司聚合卡片 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 (`- [x]`) syntax for tracking.
**Goal:** 质检工单 / 我的质检任务批次列表中,自检仍一项目一卡;第三方按「批次内公司」聚合成公司卡,弹窗内分项填参数/状态/结论,备注与检测材料一份并按可写集合同步写入。
**Architecture:** 纯前端改造。新增 `qc-company-task-group` 工具做分组与共享字段选取;新增 `QcCompanyTaskResultDialog` 做公司弹窗;在 `QcWorkOrderTaskSubList` 将任务拆成自检卡 + 公司卡并接线。保存循环调用现有 `fetchCompleteTaskWithResult`,不改后端表结构。
**Tech Stack:** Vue 3 + TypeScript + Element Plus(web-admin);单测用 `node:test` + `pnpm exec tsx --test`。
**Spec:** `docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md`
**Demo:** `.superpowers/brainstorm/80309-1787910046/content/001-qc-company-card-demo.html`
---
## File map
| 文件 | 职责 |
|------|------|
| Create: `web-admin/src/utils/qc-company-task-group.ts` | 第三方按公司分组、进度汇总、共享备注/附件选取 |
| Create: `web-admin/src/utils/qc-company-task-group.test.ts` | 上述纯函数单测 |
| Create: `web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue` | 公司维度填写/查看弹窗 |
| Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue` | 列表渲染拆分;打开公司弹窗;自检逻辑不动 |
| 不改 | 后端 API / DDL;配置检测弹窗;附件 ZIP 目录 |
---
### Task 1: 分组与共享字段工具(TDD)
**Files:**
- Create: `web-admin/src/utils/qc-company-task-group.ts`
- Create: `web-admin/src/utils/qc-company-task-group.test.ts`
- [x] **Step 1: 写失败单测**
```ts
// web-admin/src/utils/qc-company-task-group.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { QcTask } from '@/api/qc-task'
import {
buildTaskListDisplayItems,
pickSharedRemarkAndAttachments,
companyGroupProgress
} from './qc-company-task-group'
function task(partial: Partial<QcTask> & Pick<QcTask, 'id' | 'taskType'>): QcTask {
return {
taskNo: 'T1',
itemCode: 'I',
itemName: 'Item',
taskStatus: 'dzx',
workOrderId: 'wo1',
...partial
} as QcTask
}
describe('buildTaskListDisplayItems', () => {
it('keeps lab tasks as single cards and groups third-party by companyId', () => {
const tasks = [
task({ id: 'l1', taskType: 'sysz', itemName: '无菌' }),
task({
id: 't1',
taskType: 'dsfjc',
companyId: 'c1',
companyName: '金域',
itemName: '乙肝'
}),
task({
id: 't2',
taskType: 'dsfjc',
companyId: 'c1',
companyName: '金域',
itemName: '丙肝'
}),
task({
id: 't3',
taskType: 'dsfjc',
companyId: 'c2',
companyName: '迪安',
itemName: 'HIV'
})
]
const items = buildTaskListDisplayItems(tasks)
assert.equal(items.length, 3)
assert.equal(items[0].kind, 'lab')
assert.equal(items[0].kind === 'lab' && items[0].task.id, 'l1')
assert.equal(items[1].kind, 'company')
if (items[1].kind === 'company') {
assert.equal(items[1].companyId, 'c1')
assert.equal(items[1].tasks.length, 2)
}
assert.equal(items[2].kind, 'company')
if (items[2].kind === 'company') {
assert.equal(items[2].companyId, 'c2')
}
})
it('falls back to single lab-like card when third-party has empty companyId', () => {
const tasks = [
task({ id: 't0', taskType: 'dsfjc', companyId: '', companyName: '', itemName: '孤儿' })
]
const items = buildTaskListDisplayItems(tasks)
assert.equal(items.length, 1)
assert.equal(items[0].kind, 'orphan-third')
})
})
describe('companyGroupProgress', () => {
it('counts ywc vs total', () => {
const p = companyGroupProgress([
task({ id: 'a', taskType: 'dsfjc', taskStatus: 'ywc' }),
task({ id: 'b', taskType: 'dsfjc', taskStatus: 'dzx' })
])
assert.deepEqual(p, { completed: 1, total: 2 })
})
})
describe('pickSharedRemarkAndAttachments', () => {
it('picks latest non-empty remark and attachments by updateTime', () => {
const picked = pickSharedRemarkAndAttachments([
task({
id: 'a',
taskType: 'dsfjc',
remark: '旧备注',
thirdPartyAttachments: 'http://a.pdf',
updateTime: '2026-08-01 10:00:00',
attachmentFiles: [{ fileUrl: 'http://a.pdf', fileName: 'a.pdf' }]
}),
task({
id: 'b',
taskType: 'dsfjc',
remark: '新备注',
thirdPartyAttachments: '',
updateTime: '2026-08-28 12:00:00',
attachmentFiles: []
})
])
assert.equal(picked.remark, '新备注')
assert.equal(picked.thirdPartyAttachments, 'http://a.pdf')
assert.equal(picked.attachmentFiles?.length, 1)
})
})
```
- [x] **Step 2: 跑测确认失败**
```bash
cd web-admin && pnpm exec tsx --test src/utils/qc-company-task-group.test.ts
```
Expected: FAIL(模块不存在)
- [x] **Step 3: 实现工具**
```ts
// web-admin/src/utils/qc-company-task-group.ts
import type { QcTask } from '@/api/qc-task'
export type QcTaskListLabItem = { kind: 'lab'; task: QcTask }
export type QcTaskListOrphanThirdItem = { kind: 'orphan-third'; task: QcTask }
export type QcTaskListCompanyItem = {
kind: 'company'
companyId: string
companyName: string
tasks: QcTask[]
}
export type QcTaskListDisplayItem =
| QcTaskListLabItem
| QcTaskListOrphanThirdItem
| QcTaskListCompanyItem
function trimId(v?: string) {
return (v || '').trim()
}
/** 自检卡在前(原序);有 companyId 的第三方按首次出现公司顺序分组;无 companyId 降级为 orphan-third */
export function buildTaskListDisplayItems(tasks: QcTask[]): QcTaskListDisplayItem[] {
const lab: QcTaskListDisplayItem[] = []
const orphans: QcTaskListDisplayItem[] = []
const companyOrder: string[] = []
const companyMap = new Map<string, QcTaskListCompanyItem>()
for (const t of tasks || []) {
if ((t.taskType || '').trim() === 'sysz') {
lab.push({ kind: 'lab', task: t })
continue
}
if ((t.taskType || '').trim() !== 'dsfjc') {
lab.push({ kind: 'lab', task: t })
continue
}
const cid = trimId(t.companyId)
if (!cid) {
orphans.push({ kind: 'orphan-third', task: t })
continue
}
let g = companyMap.get(cid)
if (!g) {
g = {
kind: 'company',
companyId: cid,
companyName: (t.companyName || '').trim() || '第三方公司',
tasks: []
}
companyMap.set(cid, g)
companyOrder.push(cid)
}
g.tasks.push(t)
}
return [
...lab,
...companyOrder.map((id) => companyMap.get(id)!),
...orphans
]
}
export function companyGroupProgress(tasks: QcTask[]) {
const list = tasks || []
const completed = list.filter((t) => (t.taskStatus || '').trim() === 'ywc').length
return { completed, total: list.length }
}
function timeMs(v?: string) {
if (!v) return 0
const n = Date.parse(String(v).replace(/-/g, '/'))
return Number.isFinite(n) ? n : 0
}
export function pickSharedRemarkAndAttachments(tasks: QcTask[]): {
remark: string
thirdPartyAttachments: string
attachmentFiles: NonNullable<QcTask['attachmentFiles']>
} {
const list = [...(tasks || [])].sort((a, b) => timeMs(b.updateTime) - timeMs(a.updateTime))
let remark = ''
let thirdPartyAttachments = ''
let attachmentFiles: NonNullable<QcTask['attachmentFiles']> = []
for (const t of list) {
if (!remark && (t.remark || '').trim()) remark = (t.remark || '').trim()
const urls = (t.thirdPartyAttachments || '').trim()
const files = t.attachmentFiles || []
if (!thirdPartyAttachments && (urls || files.length)) {
thirdPartyAttachments = urls
attachmentFiles = files.length ? [...files] : []
}
if (remark && (thirdPartyAttachments || attachmentFiles.length)) break
}
return { remark, thirdPartyAttachments, attachmentFiles }
}
export function uniqueAssigneeNames(tasks: QcTask[]): string {
const names: string[] = []
const seen = new Set<string>()
for (const t of tasks || []) {
const raw = (t.assigneeUserNames || '').trim()
if (!raw) continue
for (const part of raw.split(/[,,、]/)) {
const n = part.trim()
if (!n || seen.has(n)) continue
seen.add(n)
names.push(n)
}
}
return names.join('、') || '—'
}
```
- [x] **Step 4: 跑测确认通过**
```bash
cd web-admin && pnpm exec tsx --test src/utils/qc-company-task-group.test.ts
```
Expected: PASS
- [x] **Step 5: Commit**(仅当用户要求提交时执行)
```bash
git add web-admin/src/utils/qc-company-task-group.ts web-admin/src/utils/qc-company-task-group.test.ts
git commit -m "$(cat <<'EOF'
feat(qc): add third-party task grouping helpers by company
EOF
)"
```
---
### Task 2: 公司结果弹窗组件
**Files:**
- Create: `web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue`
- [x] **Step 1: 实现弹窗骨架与 props**
对齐现网 `QcWorkOrderTaskSubList` 单任务填写弹窗:
- `dialog-form-layout` + `align-center` + `append-to-body` + `modal-class="protocol-dialog-mask"`
- Props:
- `visible`(v-model)
- `companyName: string`
- `tasks: QcTask[]`(可写/可见集合)
- `readonly?: boolean`
- `operateScope: QcTaskOperateScope`
- Emits:`update:visible`、`saved`
打开时:
1. 对每个 `task` 并行 `fetchQcTaskDetail` + `fetchQcTaskResults`
2. 本地状态:`itemStates: Array<{ taskId, itemName, params: QcTaskResultItem[], saveStatus, conclusion }>`
3. 共享:`remark`、`attachments`(`apiFilesToFinanceItems` + `pickSharedRemarkAndAttachments`)
模板结构(要点):
```vue
<el-dialog
:title="(readonly ? '查看检测结果' : '填写检测结果') + ' · ' + companyName"
class="dialog-form-layout qc-company-task-result-dialog"
...
>
<DialogFormBody :loading="loading">
<el-form label-position="top" class="biz-dialog-form">
<div v-for="item in itemStates" :key="item.taskId" class="company-item-block">
<div class="company-item-block__head">
<span>{{ item.itemName }}</span>
<el-tag size="small" :type="taskStatusTagType(item.saveStatus)">
{{ taskStatusLabel(item.saveStatus) }}
</el-tag>
</div>
<div class="section-title">检测参数</div>
<div class="form-grid-3"><!-- 同现网 paramType 分支 --></div>
<div class="section-title">检测结论</div>
<div class="form-grid-2 result-conclusion-row">
<el-form-item label="任务状态" required>
<el-radio-group v-model="item.saveStatus" :disabled="readonly">
<el-radio value="dzx">待执行</el-radio>
<el-radio value="ywc">已完成</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="结论" :required="item.saveStatus === 'ywc'">
<el-radio-group v-model="item.conclusion" :disabled="readonly">
<el-radio value="" :disabled="item.saveStatus === 'ywc'">待结论</el-radio>
<el-radio value="tg">通过</el-radio>
<el-radio value="btg">不通过</el-radio>
</el-radio-group>
</el-form-item>
</div>
</div>
<div class="form-section">
<div class="section-title">备注与检测材料</div>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="remark" type="textarea" :rows="3" :disabled="readonly" />
</el-form-item>
<ProtocolFinanceConfirmFields
v-model:finance-remark="remark"
v-model:attachments="attachments"
:show-remark="false"
attachment-label="检测材料"
:attachment-required="anyCompleted"
upload-folder="qc-third-party"
:disabled="readonly"
/>
</div>
</el-form>
</DialogFormBody>
<template #footer>
<el-button class="btn-ghost" @click="close"><el-icon><Close /></el-icon>取消</el-button>
<el-button v-if="!readonly" class="btn-main" type="primary" :loading="saving" @click="submit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
```
注意:`ProtocolFinanceConfirmFields` 若无 `disabled` prop,只读时用 `v-if` 换成只读附件列表(`ProtocolAttachmentReadonly`),与现网查看态一致即可。
- [x] **Step 2: 实现 submit**
校验顺序:
1. 附件上传中 → warning
2. `anyCompleted` 且无有效附件 →「请上传检测材料」
3. 任一 `saveStatus === 'ywc'` 且结论非 tg/btg →「请选择检测结论」(可带项目名)
然后顺序(或 `for...of`)调用:
```ts
await fetchCompleteTaskWithResult(
item.taskId,
{
conclusion: item.conclusion,
results: item.params,
remark,
thirdPartyAttachments: financeAttachmentsToUrlString(attachments),
taskStatus: item.saveStatus
},
operateScope
)
```
收集失败项:`failures: string[]`;若有失败 `ElMessage.error` 列出项目名;若全部成功 `ElMessage.success`、emit `saved`、关弹窗。部分成功也 emit `saved` 以便列表刷新。
复用标签 helper:从 `@/utils/qc-labels` 或 TaskSubList 现有 `taskStatusLabel` / `taskStatusTagType` / `parseQcParamOptions` 导入,**不要复制粘贴一大段标签映射**;若现函数未导出,把纯函数抽到 `qc-labels` 或本 dialog 旁小 util。
- [x] **Step 3: 本地目视**
`pnpm dev`,临时在任一页挂载组件用假数据打开(或等 Task 3 接线后验)。本任务以组件可编译、逻辑自洽为准。
- [x] **Step 4: Commit**(仅当用户要求提交时)
```bash
git add web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue
git commit -m "$(cat <<'EOF'
feat(qc): add company-level QC result dialog for third-party tasks
EOF
)"
```
---
### Task 3: 列表接线(QcWorkOrderTaskSubList)
**Files:**
- Modify: `web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue`
- [x] **Step 1: 计算 displayItems**
```ts
import {
buildTaskListDisplayItems,
companyGroupProgress,
uniqueAssigneeNames,
type QcTaskListCompanyItem
} from '@/utils/qc-company-task-group'
import QcCompanyTaskResultDialog from './QcCompanyTaskResultDialog.vue'
const displayItems = computed(() => buildTaskListDisplayItems(tableData.value))
```
- [x] **Step 2: 改卡片网格模板**
将原 `v-for="row in tableData"` 改为 `v-for="item in displayItems"`:
- `item.kind === 'lab' | 'orphan-third'`:沿用现有 `qc-task-card` 模板,数据用 `item.task`(orphan 第三方仍走原单任务「填写结果」弹窗,避免无公司维度)
- `item.kind === 'company'`:新公司卡 UI(对齐 Demo)
公司卡要点:
```vue
<div
class="qc-task-card qc-task-card--compact qc-task-card--company"
@click="openCompanyResult(item)"
@dblclick="openCompanyResult(item)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ item.companyName }}</span>
<div class="qc-task-card__sub">第三方 · {{ item.tasks.length }} 个检测项目</div>
<div class="qc-task-card__tags">
<el-tag :type="companyProgressTagType(item)" size="small">
{{ companyProgressText(item) }}
</el-tag>
<el-tag type="primary" size="small">第三方检测</el-tag>
</div>
</div>
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__assignees">{{ uniqueAssigneeNames(item.tasks) }}</span>
<el-button
v-if="canFillCompany(item)"
type="primary"
size="small"
class="btn-op-item primary"
@click.stop="openCompanyResult(item, false)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
<el-button
v-else
type="primary"
size="small"
class="btn-op-item primary"
@click.stop="openCompanyResult(item, true)"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</div>
```
```ts
function companyProgressText(item: QcTaskListCompanyItem) {
const { completed, total } = companyGroupProgress(item.tasks)
if (completed === 0) return '待执行'
if (completed === total) return `${completed}/${total} 已完成`
return `${completed}/${total} 已完成`
}
function companyProgressTagType(item: QcTaskListCompanyItem) {
const { completed, total } = companyGroupProgress(item.tasks)
if (total > 0 && completed === total) return 'success'
if (completed > 0) return 'warning'
return 'info'
}
function canFillCompany(item: QcTaskListCompanyItem) {
return item.tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
}
const companyDialogVisible = ref(false)
const companyDialogReadonly = ref(false)
const companyDialogTasks = ref<QcTask[]>([])
const companyDialogName = ref('')
function openCompanyResult(item: QcTaskListCompanyItem, forceReadonly?: boolean) {
// mine:tasks 已是当前用户可见子集(列表本身已过滤);若工单页全量则可写=全部
// 若 mine 模式下列表含非本人任务(通常不会),再 filter isCurrentUserAssignee
let tasks = item.tasks
if (listMode.value === 'mine') {
tasks = tasks.filter((t) => isCurrentUserAssignee(t))
}
if (!tasks.length) return
companyDialogName.value = item.companyName
companyDialogTasks.value = tasks
companyDialogReadonly.value =
forceReadonly === true || !tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
companyDialogVisible.value = true
}
```
挂载:
```vue
<QcCompanyTaskResultDialog
v-model:visible="companyDialogVisible"
:company-name="companyDialogName"
:tasks="companyDialogTasks"
:readonly="companyDialogReadonly"
:operate-scope="operateScope"
@saved="loadData(); emit('changed')"
/>
```
自检卡的开始 / 填写结果 / 查看 / 删除 **保持原函数**,不要改到公司路径。
- [x] **Step 3: 样式**
公司卡增加浅绿边框/浅底(`--primary-bg`),与 Demo 一致;勿引入小程序色 `#0a9f6d`。
- [x] **Step 4: 手动验收(对照 spec §8)**
在 `pnpm dev` 下验证:
1. 质检工单 `inbound_cell_storage`:自检卡数量不变;第三方变公司卡
2. 打开公司弹窗:多参数三列;状态/结论单选
3. 保存后同公司各任务备注/附件一致(工单页)
4. 我的质检任务:仅本人负责项进弹窗与保存
5. 自检「开始」仍可用
- [x] **Step 5: Commit**(仅当用户要求提交时)
```bash
git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue \
web-admin/src/views/quality-inspection/qc-work-order/QcCompanyTaskResultDialog.vue \
web-admin/src/utils/qc-company-task-group.ts \
web-admin/src/utils/qc-company-task-group.test.ts
git commit -m "$(cat <<'EOF'
feat(qc): group third-party QC tasks by company on work-order cards
EOF
)"
```
---
### Task 4: 图谱更新与收尾
**Files:**
- 运行:`./scripts/update-graph.sh frontend`(或 web-admin 对应入口)
- [x] **Step 1: 更新 graphify**
```bash
cd /Users/wuzilong/work/00code/zkxw && ./scripts/update-graph.sh frontend
```
- [x] **Step 2: 将 spec 状态改为已实现(实现完成后)**
把 `docs/superpowers/specs/2026-08-28-qc-third-party-company-card-design.md` 文首「待用户审阅」改为「已实现」并注明完成日期。
---
## Spec coverage checklist
| Spec 要求 | Task |
|-----------|------|
| 自检不合并 | Task 3 |
| 第三方按公司聚合 | Task 1 + 3 |
| 空 companyId 降级 | Task 1 |
| 分项参数/状态/结论(现网单选) | Task 2 |
| 共享备注/材料 + 任一项完成则材料必填 | Task 2 |
| 工单全量同步 / 我的任务子集 | Task 2 + 3 |
| 方案 A 循环 complete-with-result | Task 2 |
| 打开回显最近非空共享字段 | Task 1 + 2 |
| 不做配置检测/DDL/整公司删除 | 明确不在 File map |
## 执行说明
- 提交步骤默认 **跳过**,除非用户明确要求 commit。
- 两页共用 `QcWorkOrderTaskSubList`,改一处即可覆盖质检工单与我的质检任务。
@@ -0,0 +1,160 @@
# 质检任务第三方按公司聚合卡片 — 设计
> 日期:2026-08-28
> 状态:已实现
> 完成日期:2026-08-28
> 范围:`web-admin` 质检工单、我的质检任务(批次内任务列表)
> 方案:A(前端聚合 + 循环调用现有单任务保存接口)
> 交互原型:`.superpowers/brainstorm/80309-1787910046/content/001-qc-company-card-demo.html`
> 实现计划:`docs/superpowers/plans/2026-08-28-qc-third-party-company-card.md`
## 1. 背景与目标
现状:检测批次下每个质检项目一张任务卡。第三方公司项目多时,列表卡片过多。
目标:
- **实验室自检**:不合并,仍一项目一卡,交互保持现状。
- **第三方检测**:同一质检批次下,同一公司的全部(可见)项目聚合成 **一张公司卡**;点击打开弹窗,在弹窗内按项目填写。
- **分项填写**:检测参数、任务状态、结论按单个项目填写。
- **共享填写**:备注、检测材料只填一份,并按规则同步到本次保存的可写任务集合。
## 2. 已确认决策
| 决策点 | 结论 |
|--------|------|
| 自检是否合并 | 否,保持单项目卡 |
| 第三方聚合维度 | 同工单、同批次、同 `companyId` |
| 实现路径 | 方案 A:前端聚合 + 复用 `POST /qc-tasks/{id}/complete-with-result` |
| 共享字段落库 | 仍写在各任务的 `remark` / `third_party_attachments`(冗余同步,不改表) |
| 质检工单页保存范围 | 该批次该公司下全部第三方任务 |
| 我的质检任务页 | 弹窗只展示、只保存当前用户负责的任务;备注/材料也只写这些任务 |
| 任务状态 / 结论 UI | 沿用现网单选:状态「待执行 / 已完成」;结论「待结论 / 通过 / 不通过」;选已完成时禁用待结论且结论必填 |
| 本期不做 | 改配置检测 UI、新建批次-公司实体、公司卡批量删除、附件导出目录重构 |
## 3. 列表展示
### 3.1 层级
工单 → 检测批次 Tab → 任务区(`QcWorkOrderBatchSubList` → 任务子列表)。
任务区内混合两类卡片:
1. **自检卡**(`taskType = sysz`):现有紧凑卡,不改行为。
2. **公司卡**(由 `taskType = dsfjc` 按 `companyId` 聚合):新增样式与交互。
排序:先自检卡(保持原任务顺序),再公司卡(按公司名或首次出现顺序稳定排序);公司卡内项目顺序与配置/原任务顺序一致。
### 3.2 公司卡字段
| 元素 | 规则 |
|------|------|
| 主标题 | `companyName` |
| 副文案 | `第三方 · N 个检测项目`(N = 该卡可见任务数) |
| 进度 Tag | `已完成数/总数`;全完成 success,部分 warning,全未完成 info |
| 类型 Tag | `第三方检测` |
| 负责人 | 可见任务 assignee 去重后拼接 |
| 主按钮 | 有可填权限 →「填写结果」;否则「查看」 |
| 点击/双击 | 与主按钮一致,打开公司弹窗 |
第三方现网不需要「开始」;公司卡不提供开始。
### 3.3 我的质检任务聚合
- 仅用当前用户作为负责人的第三方任务参与聚合。
- 某公司下自己负责 0 项 → 不展示该公司卡。
### 3.4 异常数据
- `companyId` 为空的第三方任务:不并入其它公司卡,各自降级为单项目卡(或单独「未归属公司」卡,实现时二选一,**推荐降级为单项目卡**),避免误聚合。
### 3.5 删除
本期公司卡不提供「删除整公司」。单任务删除仍走现有入口(或配置「调整检测」);公司弹窗内不做删除,控制范围。
## 4. 公司弹窗
### 4.1 结构(自上而下)
1. 标题:`填写检测结果 · {公司名}` / `查看检测结果 · {公司名}`
2. **检测项目**(可写/可见集合内每个任务一块)
- 项目名 + 状态 Tag
- **检测参数**:按项目参数模板,三列栅格(文本 / 数字 / 下拉,与现网单任务弹窗一致)
- **检测结论**:现网单选组(任务状态、结论)
3. **备注与检测材料**(整弹窗一份)
- 备注 textarea
- 检测材料上传组件(与现网第三方附件一致)
- 若可写集合中 **任一项** 任务状态为「已完成」,则检测材料必填
只读模式:全部控件禁用,无保存按钮(或仅关闭)。
### 4.2 打开回显
- 各项目:各自 `results`、任务状态、结论。
- 共享备注 / 材料:在本次弹窗任务集合中,取 **最近 `updateTime` 且对应字段非空** 的值;皆空则空白。
### 4.3 保存
对可写集合中每个任务依次调用现有:
`POST /qc-tasks/{id}/complete-with-result`
Body 要点:
- 分项:`results`、`taskStatus`、`conclusion`
- 共享:同一份 `remark`、`thirdPartyAttachments`
- 校验:逐项校验状态/结论规则(已完成必须结论;与现网一致)
- 材料:存在任一「已完成」时,共享材料必填(整单校验一次)
失败策略:逐任务提交;失败时提示失败项及原因,已成功项保留。不做跨任务 DB 事务(方案 A 取舍,写入 spec 明示)。
保存成功后刷新批次任务列表 / 触发 `tasks-changed`。
## 5. 前端改动面(实现指引)
主要落点(实现计划阶段再拆任务):
| 区域 | 改动 |
|------|------|
| `QcWorkOrderTaskSubList.vue`(或抽出 composable/子组件) | 将 `tableData` 拆成自检列表 + 按公司分组;渲染公司卡;公司弹窗 |
| 新建组件(建议) | 如 `QcCompanyTaskResultDialog.vue`:多项目参数 + 共享备注/材料 |
| 聚合工具 | 如 `groupThirdPartyTasksByCompany(tasks)` |
| API | 不新增后端接口;复用任务详情、参数、`complete-with-result` |
| 质检工单 / 我的质检任务 | 共用上述子列表,靠 `listMode` 区分可写集合 |
查看弹窗、工单系统查看里「第三方项目」只读展示:可后续对齐公司分组,**非本期必做**(列表填写路径优先)。
## 6. 后端与数据
- **不改表结构**。`biz_qc_task` 仍一项目一任务;`company_id` / `company_name` 已有。
- 备注、附件继续按任务冗余存储;同次保存写入相同值。
- 工单完成判定、附件导出等现有逻辑不变(依赖任务级字段)。
## 7. 风险与缓解
| 风险 | 缓解 |
|------|------|
| 多次请求部分失败 | 明确提示;支持再次打开补救;后续可升级方案 B 批量 API |
| 历史同公司任务备注/附件不一致 | 打开时按「最近非空」回显;保存后统一为本次共享值 |
| 我的任务子集与工单全量语义不同 | UI 与保存范围按 `listMode` 分支;文档与验收分开写 |
| 公司弹窗项目多、参数多导致过长 | `dialog-form-layout` 仅 body 滚动;项目块可考虑默认展开全部(首版不做折叠,除非验收反馈) |
## 8. 验收场景(摘要)
1. 同批次配置 2 个自检 + 公司 A 3 项 + 公司 B 1 项 → 列表为 2 张自检卡 + 2 张公司卡。
2. 打开公司 A 弹窗,可见 3 个项目块;多参数项目三列展示;状态/结论为单选。
3. 填写共享备注与材料并保存 → 工单页下该公司该批 3 个任务备注与附件一致。
4. 我的质检任务:用户仅负责公司 A 的 2/3 项 → 公司卡显示 2 项;保存只更新这 2 项。
5. 任一项选「已完成」且无材料 → 保存被拦截。
6. 自检卡「开始 / 填写结果」行为与改造前一致。
7. 全完成公司卡进度为 success,主按钮可为查看(视权限)。
## 9. 工作量粗估
约 5~8 人天(前端聚合 + 公司弹窗为主;回归两页多 Tab;无后端 DDL)。
## 10. 非目标
- 移动端 / H5 同步改造
- OpenSpec 工程规范变更归档(若需可另开 change)
- 方案 C(共享实体表)
@@ -0,0 +1,137 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
buildTaskListDisplayItems,
pickSharedRemarkAndAttachments,
companyGroupProgress,
uniqueAssigneeNames
} from './qc-company-task-group'
type TestQcTask = {
id: string
taskType: string
taskNo?: string
itemCode?: string
itemName?: string
taskStatus?: string
workOrderId?: string
companyId?: string
companyName?: string
remark?: string
thirdPartyAttachments?: string
updateTime?: string
assigneeUserNames?: string
attachmentFiles?: Array<{ fileUrl: string; fileName?: string; fileType?: string }>
}
function task(partial: Partial<TestQcTask> & Pick<TestQcTask, 'id' | 'taskType'>): TestQcTask {
return {
taskNo: 'T1',
itemCode: 'I',
itemName: 'Item',
taskStatus: 'dzx',
workOrderId: 'wo1',
...partial
}
}
describe('buildTaskListDisplayItems', () => {
it('keeps lab tasks as single cards and groups third-party by companyId', () => {
const tasks = [
task({ id: 'l1', taskType: 'sysz', itemName: '无菌' }),
task({
id: 't1',
taskType: 'dsfjc',
companyId: 'c1',
companyName: '金域',
itemName: '乙肝'
}),
task({
id: 't2',
taskType: 'dsfjc',
companyId: 'c1',
companyName: '金域',
itemName: '丙肝'
}),
task({
id: 't3',
taskType: 'dsfjc',
companyId: 'c2',
companyName: '迪安',
itemName: 'HIV'
})
]
const items = buildTaskListDisplayItems(tasks)
assert.equal(items.length, 3)
assert.equal(items[0].kind, 'lab')
assert.equal(items[0].kind === 'lab' && items[0].task.id, 'l1')
assert.equal(items[1].kind, 'company')
if (items[1].kind === 'company') {
assert.equal(items[1].companyId, 'c1')
assert.equal(items[1].tasks.length, 2)
}
assert.equal(items[2].kind, 'company')
if (items[2].kind === 'company') {
assert.equal(items[2].companyId, 'c2')
}
})
it('falls back to single lab-like card when third-party has empty companyId', () => {
const tasks = [
task({ id: 't0', taskType: 'dsfjc', companyId: '', companyName: '', itemName: '孤儿' })
]
const items = buildTaskListDisplayItems(tasks)
assert.equal(items.length, 1)
assert.equal(items[0].kind, 'orphan-third')
})
})
describe('companyGroupProgress', () => {
it('counts ywc vs total', () => {
const p = companyGroupProgress([
task({ id: 'a', taskType: 'dsfjc', taskStatus: 'ywc' }),
task({ id: 'b', taskType: 'dsfjc', taskStatus: 'dzx' })
])
assert.deepEqual(p, { completed: 1, total: 2 })
})
})
describe('pickSharedRemarkAndAttachments', () => {
it('picks latest non-empty remark and attachments by updateTime', () => {
const picked = pickSharedRemarkAndAttachments([
task({
id: 'a',
taskType: 'dsfjc',
remark: '旧备注',
thirdPartyAttachments: 'http://a.pdf',
updateTime: '2026-08-01 10:00:00',
attachmentFiles: [{ fileUrl: 'http://a.pdf', fileName: 'a.pdf' }]
}),
task({
id: 'b',
taskType: 'dsfjc',
remark: '新备注',
thirdPartyAttachments: '',
updateTime: '2026-08-28 12:00:00',
attachmentFiles: []
})
])
assert.equal(picked.remark, '新备注')
assert.equal(picked.thirdPartyAttachments, 'http://a.pdf')
assert.equal(picked.attachmentFiles?.length, 1)
})
})
describe('uniqueAssigneeNames', () => {
it('dedupes assignee names split by comma or顿号', () => {
const names = uniqueAssigneeNames([
task({ id: 'a', taskType: 'dsfjc', assigneeUserNames: '张三,李四' }),
task({ id: 'b', taskType: 'dsfjc', assigneeUserNames: '李四、王五' })
])
assert.equal(names, '张三、李四、王五')
})
it('returns em dash when no assignees', () => {
assert.equal(uniqueAssigneeNames([task({ id: 'a', taskType: 'dsfjc' })]), '—')
})
})
@@ -0,0 +1,106 @@
import type { QcTask } from '../api/qc-task'
export type QcTaskListLabItem = { kind: 'lab'; task: QcTask }
export type QcTaskListOrphanThirdItem = { kind: 'orphan-third'; task: QcTask }
export type QcTaskListCompanyItem = {
kind: 'company'
companyId: string
companyName: string
tasks: QcTask[]
}
export type QcTaskListDisplayItem =
| QcTaskListLabItem
| QcTaskListOrphanThirdItem
| QcTaskListCompanyItem
function trimId(v?: string) {
return (v || '').trim()
}
/** 自检卡在前(原序);有 companyId 的第三方按首次出现公司顺序分组;无 companyId 降级为 orphan-third */
export function buildTaskListDisplayItems(tasks: QcTask[]): QcTaskListDisplayItem[] {
const lab: QcTaskListDisplayItem[] = []
const orphans: QcTaskListDisplayItem[] = []
const companyOrder: string[] = []
const companyMap = new Map<string, QcTaskListCompanyItem>()
for (const t of tasks || []) {
if ((t.taskType || '').trim() === 'sysz') {
lab.push({ kind: 'lab', task: t })
continue
}
if ((t.taskType || '').trim() !== 'dsfjc') {
lab.push({ kind: 'lab', task: t })
continue
}
const cid = trimId(t.companyId)
if (!cid) {
orphans.push({ kind: 'orphan-third', task: t })
continue
}
let g = companyMap.get(cid)
if (!g) {
g = {
kind: 'company',
companyId: cid,
companyName: (t.companyName || '').trim() || '第三方公司',
tasks: []
}
companyMap.set(cid, g)
companyOrder.push(cid)
}
g.tasks.push(t)
}
return [...lab, ...companyOrder.map((id) => companyMap.get(id)!), ...orphans]
}
export function companyGroupProgress(tasks: QcTask[]) {
const list = tasks || []
const completed = list.filter((t) => (t.taskStatus || '').trim() === 'ywc').length
return { completed, total: list.length }
}
function timeMs(v?: string) {
if (!v) return 0
const n = Date.parse(String(v).replace(/-/g, '/'))
return Number.isFinite(n) ? n : 0
}
export function pickSharedRemarkAndAttachments(tasks: QcTask[]): {
remark: string
thirdPartyAttachments: string
attachmentFiles: NonNullable<QcTask['attachmentFiles']>
} {
const list = [...(tasks || [])].sort((a, b) => timeMs(b.updateTime) - timeMs(a.updateTime))
let remark = ''
let thirdPartyAttachments = ''
let attachmentFiles: NonNullable<QcTask['attachmentFiles']> = []
for (const t of list) {
if (!remark && (t.remark || '').trim()) remark = (t.remark || '').trim()
const urls = (t.thirdPartyAttachments || '').trim()
const files = t.attachmentFiles || []
if (!thirdPartyAttachments && (urls || files.length)) {
thirdPartyAttachments = urls
attachmentFiles = files.length ? [...files] : []
}
if (remark && (thirdPartyAttachments || attachmentFiles.length)) break
}
return { remark, thirdPartyAttachments, attachmentFiles }
}
export function uniqueAssigneeNames(tasks: QcTask[]): string {
const names: string[] = []
const seen = new Set<string>()
for (const t of tasks || []) {
const raw = (t.assigneeUserNames || '').trim()
if (!raw) continue
for (const part of raw.split(/[,,、]/)) {
const n = part.trim()
if (!n || seen.has(n)) continue
seen.add(n)
names.push(n)
}
}
return names.join('、') || '—'
}
@@ -184,7 +184,6 @@
v-model="prodForm[field.key]"
clearable
:maxlength="field.maxLength"
:show-word-limit="!!field.maxLength"
/>
<DecimalInput
v-else-if="field.kind === 'number' || field.kind === 'integer'"
@@ -0,0 +1,542 @@
<!-- 质检第三方 — 按公司聚合的检测结果填写/查看弹窗 -->
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
:width="dialogWidth"
class="dialog-form-layout qc-company-task-result-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@update:model-value="onVisibleChange"
>
<DialogFormBody :loading="loading">
<el-form label-position="top" class="biz-dialog-form">
<div
v-for="(item, itemIndex) in itemStates"
:key="item.taskId"
class="company-item-block form-section"
>
<div class="company-item-block__head">
<span class="company-item-block__name">
<span class="company-item-block__index">{{ itemIndex + 1 }}.</span>
{{ item.itemName || '—' }}
</span>
<el-tag size="small" :type="taskStatusTagType(item.saveStatus)">
{{ taskStatusLabel(item.saveStatus) }}
</el-tag>
</div>
<div class="section-title">检测参数</div>
<div v-if="!loading && item.params.length === 0" class="company-item-block__empty-params">
该项目暂无配置参数,请先在第三方项目中维护检测参数
</div>
<div class="form-grid-3">
<el-form-item
v-for="(param, idx) in item.params"
:key="param.paramId || param.paramCode || idx"
:label="param.paramName + (param.paramUnit ? `(${param.paramUnit})` : '')"
>
<el-input
v-if="param.paramType === 'text'"
v-model="param.paramValue"
placeholder="请输入"
:disabled="readonly"
/>
<DecimalInput
v-else-if="param.paramType === 'number'"
:model-value="toResultNumber(param.paramValue)"
:precision="2"
style="width: 100%"
placeholder="请输入"
:disabled="readonly"
@update:model-value="(v) => setResultNumber(param, v)"
/>
<el-select
v-else-if="param.paramType === 'select'"
v-model="param.paramValue"
placeholder="请选择"
clearable
filterable
style="width: 100%"
:disabled="readonly"
>
<el-option
v-for="opt in parseQcParamOptions(param.options)"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
<el-input
v-else
v-model="param.paramValue"
placeholder="请输入"
:disabled="readonly"
/>
</el-form-item>
</div>
<div class="section-title">检测结论</div>
<div class="form-grid-2 result-conclusion-row">
<el-form-item label="任务状态" required>
<el-radio-group v-model="item.saveStatus" :disabled="readonly">
<el-radio value="dzx">待执行</el-radio>
<el-radio value="ywc">已完成</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="结论" :required="item.saveStatus === 'ywc'">
<el-radio-group v-model="item.conclusion" :disabled="readonly">
<el-radio value="" :disabled="item.saveStatus === 'ywc'">待结论</el-radio>
<el-radio value="tg">通过</el-radio>
<el-radio value="btg">不通过</el-radio>
</el-radio-group>
</el-form-item>
</div>
</div>
<div class="form-section shared-section">
<div class="section-title">备注与检测材料</div>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="remark"
type="textarea"
:rows="3"
placeholder="选填"
:disabled="readonly"
/>
</el-form-item>
<template v-if="readonly">
<el-form-item label="检测材料" class="form-col-span-2">
<ProtocolAttachmentReadonly
v-if="readonlyAttachmentFiles.length"
:files="readonlyAttachmentFiles"
@preview="onAttachmentPreview"
@download="onAttachmentDownload"
/>
<span v-else class="shared-section__empty">—</span>
</el-form-item>
</template>
<ProtocolFinanceConfirmFields
v-else
v-model:finance-remark="remark"
v-model:attachments="attachments"
:show-remark="false"
attachment-label="检测材料"
:attachment-required="anyCompleted"
upload-folder="qc-third-party"
/>
</div>
</el-form>
</DialogFormBody>
<template #footer>
<el-button class="btn-ghost" @click="close">
<el-icon><Close /></el-icon>{{ readonly ? '关闭' : '取消' }}
</el-button>
<el-button v-if="!readonly" class="btn-main" type="primary" :loading="saving" @click="submit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check } from '@element-plus/icons-vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { fetchQcTaskDetail, type QcTask, type QcTaskOperateScope } from '@/api/qc-task'
import {
fetchQcTaskResults,
fetchCompleteTaskWithResult,
type QcTaskResultItem
} from '@/api/qc-task-result'
import { parseQcParamOptions } from '@/utils/qc-item-param-options'
import { taskStatusLabel, taskStatusTagType } from '@/utils/qc-labels'
import { pickSharedRemarkAndAttachments } from '@/utils/qc-company-task-group'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
qcPreviewAttachment,
qcAttachmentFullUrl,
qcAttachmentFilesFromCommaUrls
} from './composables/useQcViewAttachmentHelpers'
defineOptions({ name: 'QcCompanyTaskResultDialog' })
type ItemState = {
taskId: string
itemName: string
params: QcTaskResultItem[]
saveStatus: 'dzx' | 'ywc'
conclusion: '' | 'tg' | 'btg'
}
const props = withDefaults(
defineProps<{
visible: boolean
companyName: string
tasks: QcTask[]
readonly?: boolean
operateScope: QcTaskOperateScope
}>(),
{
readonly: false
}
)
const emit = defineEmits<{
'update:visible': [value: boolean]
saved: []
}>()
const loading = ref(false)
const saving = ref(false)
const itemStates = ref<ItemState[]>([])
const remark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
/** 防止快速开关/切换任务时旧请求回写覆盖新状态 */
let loadSeq = 0
const dialogWidth = '920px'
const dialogTitle = computed(
() =>
`${props.readonly ? '查看检测结果' : '填写检测结果'} · ${props.companyName || '第三方公司'}`
)
const anyCompleted = computed(() => itemStates.value.some((i) => i.saveStatus === 'ywc'))
const readonlyAttachmentFiles = computed(() =>
attachments.value
.filter((a) => a.fileUrl)
.map((a) => ({
id: a.id,
fileUrl: a.fileUrl,
fileName: a.fileName,
fileType: a.fileType
}))
)
function onVisibleChange(v: boolean) {
emit('update:visible', v)
}
function close() {
emit('update:visible', false)
}
/** 第三方弹窗仅展示待执行/已完成;其它状态按待执行回显 */
function normalizeThirdPartySaveStatus(status?: string): 'dzx' | 'ywc' {
return (status || '').trim() === 'ywc' ? 'ywc' : 'dzx'
}
function normalizeConclusion(raw?: string): '' | 'tg' | 'btg' {
const c = (raw || '').trim()
return c === 'tg' || c === 'btg' ? c : ''
}
function normalizeResultParams(items: QcTaskResultItem[]): QcTaskResultItem[] {
return (items || []).map((p) => ({
...p,
paramType: p.paramType || 'text',
paramValue: p.paramValue ?? ''
}))
}
function apiFilesToFinanceItems(
files?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
): FinanceAttachmentItem[] {
if (!files?.length) return []
return files.map((f, i) => ({
id: f.id || `att_${i}`,
fileName: f.fileName || '',
fileUrl: f.fileUrl || '',
fileType: f.fileType || ''
}))
}
function toResultNumber(v: string | undefined) {
if (v === '' || v === undefined || v === null) return undefined
const n = Number(v)
return Number.isFinite(n) ? n : undefined
}
function setResultNumber(param: QcTaskResultItem, v: number | undefined) {
param.paramValue = v === undefined || v === null ? '' : String(v)
}
function hasResultValue(v: unknown) {
return v !== '' && v !== undefined && v !== null
}
function onAttachmentPreview(file: Record<string, unknown>) {
qcPreviewAttachment(file)
}
function onAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: qcAttachmentFullUrl })
}
async function loadAll() {
const seq = ++loadSeq
const tasks = props.tasks || []
loading.value = true
itemStates.value = []
remark.value = ''
attachments.value = []
try {
const loaded = await Promise.all(
tasks.map(async (task) => {
const [detail, results] = await Promise.all([
fetchQcTaskDetail(task.id).catch(() => null),
fetchQcTaskResults(task.id).catch(() => [] as QcTaskResultItem[])
])
const merged: QcTask = {
...task,
...(detail || {}),
remark: detail?.remark ?? task.remark,
thirdPartyAttachments: detail?.thirdPartyAttachments ?? task.thirdPartyAttachments,
attachmentFiles: detail?.attachmentFiles?.length
? detail.attachmentFiles
: task.attachmentFiles,
updateTime: detail?.updateTime ?? task.updateTime,
conclusion: detail?.conclusion ?? task.conclusion,
taskStatus: detail?.taskStatus || task.taskStatus,
itemName: detail?.itemName || task.itemName
}
const item: ItemState = {
taskId: task.id,
itemName: merged.itemName || '—',
params: normalizeResultParams(results || []),
saveStatus: normalizeThirdPartySaveStatus(merged.taskStatus),
conclusion: normalizeConclusion(merged.conclusion)
}
return { item, merged }
})
)
if (seq !== loadSeq) return
itemStates.value = loaded.map((x) => x.item)
const shared = pickSharedRemarkAndAttachments(loaded.map((x) => x.merged))
remark.value = shared.remark
if (shared.attachmentFiles?.length) {
attachments.value = apiFilesToFinanceItems(shared.attachmentFiles)
} else if ((shared.thirdPartyAttachments || '').trim()) {
attachments.value = apiFilesToFinanceItems(
qcAttachmentFilesFromCommaUrls(shared.thirdPartyAttachments) as Array<{
id?: string
fileUrl: string
fileName?: string
fileType?: string
}>
)
} else {
attachments.value = []
}
} catch {
if (seq !== loadSeq) return
ElMessage.error('加载检测结果失败')
itemStates.value = []
remark.value = ''
attachments.value = []
} finally {
if (seq === loadSeq) loading.value = false
}
}
async function submit() {
if (props.readonly) return
if (attachments.value.some((a) => a._uploading)) {
ElMessage.warning('附件正在上传,请稍候')
return
}
if (anyCompleted.value && !attachments.value.some((a) => a.fileUrl && !a._uploading)) {
ElMessage.warning('请上传检测材料')
return
}
for (const item of itemStates.value) {
if (item.saveStatus === 'ywc' && item.conclusion !== 'tg' && item.conclusion !== 'btg') {
ElMessage.warning(`请选择「${item.itemName}」的检测结论`)
return
}
for (const p of item.params) {
if (p.requiredFlag === '1' && !hasResultValue(p.paramValue)) {
ElMessage.warning(`请填写「${item.itemName}」参数「${p.paramName}」`)
return
}
}
}
const thirdPartyAttachments = financeAttachmentsToUrlString(attachments.value)
const remarkVal = remark.value
saving.value = true
const failures: string[] = []
let successCount = 0
try {
for (const item of itemStates.value) {
try {
await fetchCompleteTaskWithResult(
item.taskId,
{
conclusion: item.conclusion,
results: item.params
.filter((p) => hasResultValue(p.paramValue))
.map((p) => ({
...p,
paramValue:
p.paramValue === undefined || p.paramValue === null ? '' : String(p.paramValue)
})),
remark: remarkVal,
thirdPartyAttachments,
taskStatus: item.saveStatus
},
props.operateScope
)
successCount += 1
} catch {
failures.push(item.itemName || item.taskId)
}
}
if (successCount > 0) {
emit('saved')
}
if (failures.length === 0) {
ElMessage.success('已保存')
close()
} else if (successCount > 0) {
ElMessage.warning(`部分保存成功,失败项目:${failures.join('、')}`)
} else {
ElMessage.error(`保存失败:${failures.join('、')}`)
}
} finally {
saving.value = false
}
}
watch(
() => [props.visible, (props.tasks || []).map((t) => t.id).join(','), props.readonly] as const,
([visible]) => {
if (!visible) {
loadSeq += 1
loading.value = false
return
}
void loadAll()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.company-item-block {
margin-bottom: 12px;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
&__name {
font-size: 15px;
font-weight: 600;
color: #1e293b;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__index {
margin-right: 2px;
color: var(--mint-text-muted, #64748b);
font-variant-numeric: tabular-nums;
}
&__empty-params {
text-align: center;
padding: 8px 0 12px;
color: var(--mint-text-muted, #64748b);
font-size: 13px;
}
.section-title {
margin-top: 4px;
}
.result-conclusion-row {
margin-bottom: 0;
}
}
.form-section {
background: #fafafa;
border-radius: 8px;
padding: 20px;
}
.form-section .section-title {
position: relative;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
color: #334155;
margin-bottom: 12px;
padding: 0 0 8px 8px;
border-bottom: 1px solid #ebeef5;
&::before {
content: '';
position: absolute;
left: 0;
top: 3px;
width: 2px;
height: 10px;
border-radius: 1px;
background: #047857;
}
}
.shared-section {
margin-top: 0;
&__empty {
color: var(--mint-text-muted, #64748b);
font-size: 13px;
}
:deep(.protocol-finance-confirm-fields .el-form-item) {
margin-bottom: 0;
}
}
.result-conclusion-row {
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-form-item__label) {
display: inline-flex;
width: 100%;
justify-content: flex-start;
text-align: left;
padding-left: 0 !important;
}
}
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -25,84 +25,138 @@
</div>
</div>
<div class="qc-task-cards-grid">
<div
v-for="(row, idx) in tableData"
:key="row.id || idx"
class="qc-task-card qc-task-card--compact"
@dblclick="handleView(row)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ row.itemName || '—' }}</span>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
{{ taskStatusLabel(row.taskStatus) }}
</el-tag>
<el-tag :type="taskTypeTagType(row.taskType)" size="small">
{{ taskTypeLabel(row.taskType) }}
</el-tag>
<el-tag :type="qcConclusionDisplayTagType(row.conclusion)" size="small">
{{
row.conclusionLabel || qcConclusionDisplayLabel(row.conclusion)
}}
</el-tag>
<template v-for="(item, idx) in displayItems">
<div
v-if="item.kind === 'company'"
:key="displayItemKey(item, idx)"
class="qc-task-card qc-task-card--compact qc-task-card--company"
@click="openCompanyResult(item)"
@dblclick="openCompanyResult(item)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ item.companyName }}</span>
<div class="qc-task-card__sub">
第三方 · {{ item.tasks.length }} 个检测项目
</div>
<div class="qc-task-card__tags">
<el-tag :type="companyProgressTagType(item)" size="small">
{{ companyProgressText(item) }}
</el-tag>
<el-tag type="primary" size="small">第三方检测</el-tag>
</div>
</div>
</div>
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__assignees">{{
row.assigneeUserNames || '—'
}}</span>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(row)">
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__assignees">{{
uniqueAssigneeNames(item.tasks)
}}</span>
<div class="qc-work-order-task-sublist__ops">
<el-button
v-if="canStartTask(row)"
v-if="canFillCompany(item)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleStart(row)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="canFillResult(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openResult(row)"
@click.stop="openCompanyResult(item, false)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
</template>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleView(row)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreTaskActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item" size="small" @click.stop>
<el-icon><More /></el-icon>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openCompanyResult(item, true)"
>
<el-icon><View /></el-icon>查看
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
<div
v-else
:key="displayItemKey(item, idx)"
class="qc-task-card qc-task-card--compact"
@dblclick="handleView(item.task)"
>
<div class="qc-task-card__line qc-task-card__line--top">
<span class="qc-task-card__name">{{ item.task.itemName || '—' }}</span>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(item.task.taskStatus)" size="small">
{{ taskStatusLabel(item.task.taskStatus) }}
</el-tag>
<el-tag :type="taskTypeTagType(item.task.taskType)" size="small">
{{ taskTypeLabel(item.task.taskType) }}
</el-tag>
<el-tag
:type="qcConclusionDisplayTagType(item.task.conclusion)"
size="small"
>
{{
item.task.conclusionLabel ||
qcConclusionDisplayLabel(item.task.conclusion)
}}
</el-tag>
</div>
</div>
<div class="qc-task-card__line qc-task-card__line--bottom">
<span class="qc-task-card__assignees">{{
item.task.assigneeUserNames || '—'
}}</span>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(item.task)">
<el-button
v-if="canStartTask(item.task)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleStart(item.task)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="canFillResult(item.task)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="openResult(item.task)"
>
<el-icon><EditPen /></el-icon>填写结果
</el-button>
</template>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleView(item.task)"
>
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreTaskActions(item.task)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, item.task)"
>
<el-button class="btn-op-item" size="small" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="hasPrimaryTaskActions(item.task)"
command="view"
>
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-else-if="!loading" class="qc-task-cards-wrap qc-task-cards-wrap--empty">
@@ -350,6 +404,15 @@
</template>
</el-dialog>
<QcCompanyTaskResultDialog
v-model:visible="companyDialogVisible"
:company-name="companyDialogName"
:tasks="companyDialogTasks"
:readonly="companyDialogReadonly"
:operate-scope="operateScope"
@saved="handleTaskMutationSuccess"
/>
<SystemViewDialog
v-model:visible="viewVisible"
title="任务详情"
@@ -582,6 +645,14 @@
isOutboundPilotServiceQcTask
} from '@/utils/outbound-qc-pilot-view-sections'
import type { TagType } from '@/utils/status-tag-registry'
import {
buildTaskListDisplayItems,
companyGroupProgress,
uniqueAssigneeNames,
type QcTaskListCompanyItem,
type QcTaskListDisplayItem
} from '@/utils/qc-company-task-group'
import QcCompanyTaskResultDialog from './QcCompanyTaskResultDialog.vue'
/** 根据来源类型返回协议状态标签 */
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
@@ -631,6 +702,12 @@
const tableData = ref<QcTask[]>([])
const loading = ref(false)
const displayItems = computed(() => buildTaskListDisplayItems(tableData.value))
function displayItemKey(item: QcTaskListDisplayItem, idx: number) {
if (item.kind === 'company') return `company_${item.companyId || idx}`
return item.task.id || `${item.kind}_${idx}`
}
function isCurrentUserAssignee(row: QcTask) {
const uid = String(userStore.info?.id || userStore.info?.userId || '').trim()
@@ -638,6 +715,42 @@
return (row.assigneeUserIds || []).some((id) => String(id) === uid)
}
function companyProgressText(item: QcTaskListCompanyItem) {
const { completed, total } = companyGroupProgress(item.tasks)
if (completed === 0) return '待执行'
return `${completed}/${total} 已完成`
}
function companyProgressTagType(item: QcTaskListCompanyItem) {
const { completed, total } = companyGroupProgress(item.tasks)
if (total > 0 && completed === total) return 'success'
if (completed > 0) return 'warning'
return 'info'
}
function canFillCompany(item: QcTaskListCompanyItem) {
return item.tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
}
const companyDialogVisible = ref(false)
const companyDialogReadonly = ref(false)
const companyDialogTasks = ref<QcTask[]>([])
const companyDialogName = ref('')
function openCompanyResult(item: QcTaskListCompanyItem, forceReadonly?: boolean) {
let tasks = item.tasks
if (listMode.value === 'mine') {
tasks = tasks.filter((t) => isCurrentUserAssignee(t))
}
if (!tasks.length) return
companyDialogName.value = item.companyName
companyDialogTasks.value = tasks
companyDialogReadonly.value =
forceReadonly === true ||
!tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
companyDialogVisible.value = true
}
const resultVisible = ref(false)
const resultLoading = ref(false)
const resultTaskId = ref('')
@@ -1588,6 +1701,24 @@
gap: 4px;
padding: 6px 8px;
}
&--company {
border-color: #a7f3d0;
background: #ecfdf5;
cursor: pointer;
&:hover {
border-color: var(--primary, #10b981);
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
}
}
}
.qc-task-card__sub {
width: 100%;
font-size: 11px;
color: var(--mint-text-muted, #334155);
line-height: 1.35;
}
.qc-task-card__line {