feat(M08-B): 补联调执行阶段看板

This commit is contained in:
Codex
2026-07-07 14:33:15 +08:00
parent 31052625ba
commit 3713ddd1aa
7 changed files with 176 additions and 10 deletions
@@ -159,6 +159,30 @@
</div>
</div>
</section>
<section class="handoff-stage-board">
<header>
<div>
<h3>执行阶段看板</h3>
<span>{{ stageProgress.length }} 个现场阶段</span>
</div>
<el-button :icon="Copy" @click="copyStageSummary">复制阶段摘要</el-button>
</header>
<div class="handoff-stage-grid">
<button
v-for="item in stageProgress"
:key="item.category"
type="button"
class="handoff-stage-card"
:class="{ blocked: item.blocked > 0, ready: item.ready }"
@click="category = item.category"
>
<span>{{ item.category }}</span>
<strong>{{ item.verdict }}</strong>
<em>{{ item.passed }}/{{ item.total }} 通过 · 证据 {{ item.evidenceRecorded }}/{{ item.total }}</em>
<small>{{ item.nextFocus }}</small>
</button>
</div>
</section>
<section class="handoff-owner-board">
<header>
<div>
@@ -487,6 +511,31 @@ const categoryProgress = computed(() => categoryOptions
passPercent: items.length ? Math.round((passed / items.length) * 100) : 0
};
}));
const stageProgress = computed(() => categoryProgress.value.map((item) => {
const items = checklist.filter((check) => check.category === item.category);
const evidenceRecorded = items.filter((check) => draft[check.id].evidenceRef.trim()).length;
const evidenceMissing = items.filter((check) => (
draft[check.id].status !== 'SKIP' && !draft[check.id].evidenceRef.trim()
)).length;
const overdue = items.filter((check) => isOverdueDraft(draft[check.id])).length;
const ready = item.pending === 0 && item.blocked === 0 && evidenceMissing === 0 && overdue === 0;
const nextItem = items.find((check) => {
const itemDraft = draft[check.id];
return itemDraft.status === 'BLOCKED'
|| itemDraft.status === 'PENDING'
|| isOverdueDraft(itemDraft)
|| (itemDraft.status !== 'SKIP' && !itemDraft.evidenceRef.trim());
});
return {
...item,
evidenceRecorded,
evidenceMissing,
overdue,
ready,
verdict: ready ? '可收口' : item.blocked > 0 ? '存在阻断' : '待补齐',
nextFocus: nextItem ? `${nextItem.title} · ${draft[nextItem.id].owner || '未分派'}` : '无待处理缺口'
};
}));
const ownerProgress = computed(() => {
const groups = new Map<string, {
ownerKey: string;
@@ -721,6 +770,25 @@ async function copyOwnerSummary() {
}
}
async function copyStageSummary() {
const header = [
`联调批次:${sessionMeta.batchNo || '未填写'}`,
`现场/门店:${sessionMeta.site || '未填写'}`,
`负责人:${sessionMeta.coordinator || '未填写'}`,
`执行时间:${sessionMeta.executedAt || '未填写'}`,
`最后更新:${sessionMeta.updatedAt || '未记录'}`
].join('\n');
const content = stageProgress.value.map((item) => (
`${item.category}${item.verdict},通过 ${item.passed}/${item.total},阻断 ${item.blocked},待处理 ${item.pending},缺证据 ${item.evidenceMissing},逾期 ${item.overdue},下一关注 ${item.nextFocus}`
)).join('\n');
try {
await navigator.clipboard.writeText(`${header}\n\n${content}`);
ElMessage.success('阶段摘要已复制');
} catch {
ElMessage.warning('当前浏览器不允许自动复制,请使用归档包留存');
}
}
async function copyVerdict() {
const verdict = [
`联调批次:${sessionMeta.batchNo || '未填写'}`,
@@ -807,6 +875,7 @@ function buildCloseoutArchive() {
overdue: overdueItems.value.length
},
categoryProgress: categoryProgress.value,
stageProgress: stageProgress.value,
ownerProgress: ownerProgress.value,
riskItems: riskItems.value.map((item) => serializeHandoffItem(item)),
evidenceMissingItems: evidenceMissingItems.value.map((item) => serializeHandoffItem(item)),
@@ -851,6 +920,9 @@ function buildHandoffReport() {
const progress = categoryProgress.value.map((item) => (
`- ${item.category}${item.passed}/${item.total} 通过,阻断 ${item.blocked},待处理 ${item.pending},不适用 ${item.skipped}`
));
const stages = stageProgress.value.map((item) => (
`- ${item.category}${item.verdict},通过 ${item.passed}/${item.total},证据 ${item.evidenceRecorded}/${item.total},缺证据 ${item.evidenceMissing},逾期 ${item.overdue},下一关注 ${item.nextFocus}`
));
const owners = ownerProgress.value.map((item) => (
`- ${item.ownerLabel}:总项 ${item.total},通过 ${item.passed},待处理 ${item.pending},阻断 ${item.blocked},缺证据 ${item.evidenceMissing},逾期 ${item.overdue}`
));
@@ -870,6 +942,9 @@ function buildHandoffReport() {
'## 分类进度',
...progress,
'',
'## 执行阶段看板',
...stages,
'',
'## 负责人看板',
...owners,
'',
+75
View File
@@ -569,6 +569,73 @@ textarea {
font-size: 12px;
}
.handoff-stage-board {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d6e4dc;
border-radius: 8px;
background: #f8fcfa;
}
.handoff-stage-board header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.handoff-stage-board h3 {
margin: 0;
font-size: 14px;
}
.handoff-stage-board header span {
color: #60708a;
font-size: 12px;
}
.handoff-stage-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.handoff-stage-card {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid #d6e4dc;
border-radius: 8px;
background: #ffffff;
color: #172033;
text-align: left;
cursor: pointer;
}
.handoff-stage-card.blocked {
border-color: #f0c7c7;
background: #fff7f7;
}
.handoff-stage-card.ready {
border-color: #b8dec4;
background: #f3fbf5;
}
.handoff-stage-card span,
.handoff-stage-card em,
.handoff-stage-card small {
color: #60708a;
font-size: 12px;
font-style: normal;
}
.handoff-stage-card strong {
font-size: 15px;
}
.handoff-owner-board {
display: grid;
gap: 10px;
@@ -803,6 +870,10 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.handoff-stage-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.trend-row {
grid-template-columns: 92px minmax(80px, 1fr);
}
@@ -826,6 +897,10 @@ textarea {
grid-template-columns: 1fr;
}
.handoff-stage-grid {
grid-template-columns: 1fr;
}
.action-board {
grid-template-columns: 1fr;
}