feat(M08-B): 补现场执行记录追溯
This commit is contained in:
@@ -350,14 +350,86 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行记录" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button :icon="History" size="small" @click="openExecutionHistory(row)">
|
||||
记录 {{ executionHistory[row.id].length }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="executionDialogVisible"
|
||||
:title="selectedExecutionItem ? `${selectedExecutionItem.title} · 现场执行记录` : '现场执行记录'"
|
||||
width="min(760px, 94vw)"
|
||||
>
|
||||
<template v-if="selectedExecutionItem">
|
||||
<el-alert
|
||||
:title="selectedExecutionItem.expected"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="selectedExecutionRecords.length" class="handoff-execution-list">
|
||||
<article v-for="record in selectedExecutionRecords" :key="record.id">
|
||||
<header>
|
||||
<el-tag :type="executionStatusTag(record.status)">{{ statusText(record.status) }}</el-tag>
|
||||
<strong>{{ record.executedAt }}</strong>
|
||||
<span>{{ record.operator }}</span>
|
||||
</header>
|
||||
<p>{{ record.result }}</p>
|
||||
<small>证据:{{ record.evidenceRef || '未记录' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else description="还没有现场执行记录" :image-size="72" />
|
||||
<el-form class="handoff-execution-form" label-position="top">
|
||||
<el-form-item label="执行时间">
|
||||
<el-input v-model="executionForm.executedAt" placeholder="YYYY-MM-DD HH:mm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="执行人">
|
||||
<el-input v-model="executionForm.operator" placeholder="现场执行人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结果状态">
|
||||
<el-select v-model="executionForm.status">
|
||||
<el-option label="待处理" value="PENDING" />
|
||||
<el-option label="通过" value="PASS" />
|
||||
<el-option label="阻断" value="BLOCKED" />
|
||||
<el-option label="不适用" value="SKIP" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行结果" class="handoff-execution-result">
|
||||
<el-input
|
||||
v-model="executionForm.result"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录请求、流水、设备回包、失败原因或重试结果"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="证据记录" class="handoff-execution-result">
|
||||
<el-input v-model="executionForm.evidenceRef" placeholder="截图编号、流水号、回包或文件链接" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="executionDialogVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
:disabled="!canAppendExecution"
|
||||
@click="appendExecutionRecord"
|
||||
>
|
||||
追加记录
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { Check, Copy, Download, FileDown, FileUp, FilterX, ListChecks, RotateCcw, Search } from '@lucide/vue';
|
||||
import { Check, Copy, Download, FileDown, FileUp, FilterX, History, ListChecks, Plus, RotateCcw, Search } from '@lucide/vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
type FieldStatus = 'PENDING' | 'PASS' | 'BLOCKED' | 'SKIP';
|
||||
@@ -389,13 +461,32 @@ interface SessionMeta {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface HandoffExecution {
|
||||
id: string;
|
||||
executedAt: string;
|
||||
operator: string;
|
||||
status: FieldStatus;
|
||||
result: string;
|
||||
evidenceRef: string;
|
||||
}
|
||||
|
||||
const storageKey = 'qipai.cleaning.field-handoff';
|
||||
const sessionStorageKey = 'qipai.cleaning.field-handoff.session';
|
||||
const executionStorageKey = 'qipai.cleaning.field-handoff.executions';
|
||||
const category = ref<'全部' | HandoffItem['category']>('全部');
|
||||
const statusFilter = ref<'ALL' | FieldStatus>('ALL');
|
||||
const ownerFilter = ref('ALL');
|
||||
const keywordFilter = ref('');
|
||||
const snapshotInput = ref<HTMLInputElement | null>(null);
|
||||
const executionDialogVisible = ref(false);
|
||||
const selectedExecutionItem = ref<HandoffItem | null>(null);
|
||||
const executionForm = reactive<Omit<HandoffExecution, 'id'>>({
|
||||
executedAt: '',
|
||||
operator: '',
|
||||
status: 'PASS',
|
||||
result: '',
|
||||
evidenceRef: ''
|
||||
});
|
||||
const bulkForm = reactive<{ status: FieldStatus; owner: string; deadline: string; note: string }>({
|
||||
status: 'PASS',
|
||||
owner: '',
|
||||
@@ -463,6 +554,7 @@ const checklist: HandoffItem[] = [
|
||||
const categoryOptions = ['全部', '商户', '微信', '转账', '硬件'];
|
||||
const draft = reactive<Record<string, HandoffDraft>>(createInitialDraft());
|
||||
const sessionMeta = reactive<SessionMeta>(readSessionMeta());
|
||||
const executionHistory = reactive<Record<string, HandoffExecution[]>>(createExecutionHistory());
|
||||
|
||||
const filteredChecklist = computed(() => checklist.filter((item) => {
|
||||
const categoryMatched = category.value === '全部' || item.category === category.value;
|
||||
@@ -495,7 +587,14 @@ function matchesKeyword(item: HandoffItem) {
|
||||
itemDraft.owner,
|
||||
itemDraft.deadline,
|
||||
itemDraft.note,
|
||||
itemDraft.evidenceRef
|
||||
itemDraft.evidenceRef,
|
||||
...executionHistory[item.id].flatMap((record) => [
|
||||
record.executedAt,
|
||||
record.operator,
|
||||
record.status,
|
||||
record.result,
|
||||
record.evidenceRef
|
||||
])
|
||||
].some((value) => String(value).toLowerCase().includes(keyword));
|
||||
}
|
||||
|
||||
@@ -506,6 +605,8 @@ const passedCount = computed(() => checklist.filter((item) => draft[item.id].sta
|
||||
const blockedCount = computed(() => checklist.filter((item) => draft[item.id].status === 'BLOCKED').length);
|
||||
const pendingCount = computed(() => checklist.filter((item) => draft[item.id].status === 'PENDING').length);
|
||||
const evidenceRecordedCount = computed(() => checklist.filter((item) => draft[item.id].evidenceRef.trim()).length);
|
||||
const executedItemCount = computed(() => checklist.filter((item) => executionHistory[item.id].length > 0).length);
|
||||
const totalExecutionCount = computed(() => checklist.reduce((total, item) => total + executionHistory[item.id].length, 0));
|
||||
const evidenceMissingItems = computed(() => checklist.filter((item) => (
|
||||
draft[item.id].status !== 'SKIP' && !draft[item.id].evidenceRef.trim()
|
||||
)));
|
||||
@@ -524,6 +625,9 @@ const missingCloseoutFields = computed(() => {
|
||||
const fields: string[] = [];
|
||||
if (!handoffReady.value) fields.push('检查项状态');
|
||||
if (evidenceMissingCount.value > 0) fields.push('现场证据');
|
||||
if (checklist.some((item) => draft[item.id].status !== 'SKIP' && executionHistory[item.id].length === 0)) {
|
||||
fields.push('现场执行记录');
|
||||
}
|
||||
if (overdueItems.value.length > 0) fields.push('逾期检查项');
|
||||
if (!sessionMeta.acceptor.trim()) fields.push('验收人');
|
||||
if (!sessionMeta.closeoutVerdict.trim()) fields.push('收口结论');
|
||||
@@ -611,6 +715,15 @@ const ownerProgress = computed(() => {
|
||||
- (a.blocked + a.pending + a.evidenceMissing + a.overdue)
|
||||
) || a.ownerLabel.localeCompare(b.ownerLabel, 'zh-Hans-CN'));
|
||||
});
|
||||
const selectedExecutionRecords = computed(() => (
|
||||
selectedExecutionItem.value ? executionHistory[selectedExecutionItem.value.id] : []
|
||||
));
|
||||
const canAppendExecution = computed(() => Boolean(
|
||||
selectedExecutionItem.value
|
||||
&& executionForm.executedAt.trim()
|
||||
&& executionForm.operator.trim()
|
||||
&& executionForm.result.trim()
|
||||
));
|
||||
|
||||
function createInitialDraft() {
|
||||
const saved = readSavedDraft();
|
||||
@@ -620,6 +733,22 @@ function createInitialDraft() {
|
||||
])) as Record<string, HandoffDraft>;
|
||||
}
|
||||
|
||||
function createExecutionHistory() {
|
||||
const saved = readExecutionHistory();
|
||||
return Object.fromEntries(checklist.map((item) => [
|
||||
item.id,
|
||||
normalizeExecutionRecords(saved[item.id])
|
||||
])) as Record<string, HandoffExecution[]>;
|
||||
}
|
||||
|
||||
function readExecutionHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(executionStorageKey) || '{}') as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readSavedDraft() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, HandoffDraft>;
|
||||
@@ -634,6 +763,12 @@ function persistDraft() {
|
||||
localStorage.setItem(storageKey, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
function persistExecutionHistory() {
|
||||
touchSessionMeta();
|
||||
localStorage.setItem(sessionStorageKey, JSON.stringify(sessionMeta));
|
||||
localStorage.setItem(executionStorageKey, JSON.stringify(executionHistory));
|
||||
}
|
||||
|
||||
function readSessionMeta(): SessionMeta {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(sessionStorageKey) || '{}') as Partial<SessionMeta>;
|
||||
@@ -701,10 +836,12 @@ function parseDeadline(value: string) {
|
||||
function resetDraft() {
|
||||
for (const item of checklist) {
|
||||
draft[item.id] = normalizeDraft();
|
||||
executionHistory[item.id] = [];
|
||||
}
|
||||
ownerFilter.value = 'ALL';
|
||||
touchSessionMeta();
|
||||
persistDraft();
|
||||
persistExecutionHistory();
|
||||
}
|
||||
|
||||
function clearHandoffFilters() {
|
||||
@@ -724,6 +861,60 @@ function applyBulkMark() {
|
||||
persistDraft();
|
||||
}
|
||||
|
||||
function openExecutionHistory(item: HandoffItem) {
|
||||
selectedExecutionItem.value = item;
|
||||
Object.assign(executionForm, {
|
||||
executedAt: formatLocalMinute(new Date()),
|
||||
operator: draft[item.id].owner.trim() || sessionMeta.coordinator.trim(),
|
||||
status: draft[item.id].status === 'PENDING' ? 'PASS' : draft[item.id].status,
|
||||
result: '',
|
||||
evidenceRef: ''
|
||||
});
|
||||
executionDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function appendExecutionRecord() {
|
||||
const item = selectedExecutionItem.value;
|
||||
if (!item || !canAppendExecution.value) return;
|
||||
const record: HandoffExecution = {
|
||||
id: createExecutionId(),
|
||||
executedAt: executionForm.executedAt.trim(),
|
||||
operator: executionForm.operator.trim(),
|
||||
status: executionForm.status,
|
||||
result: executionForm.result.trim(),
|
||||
evidenceRef: executionForm.evidenceRef.trim()
|
||||
};
|
||||
executionHistory[item.id].unshift(record);
|
||||
draft[item.id].status = record.status;
|
||||
draft[item.id].owner = record.operator;
|
||||
draft[item.id].note = record.result;
|
||||
if (record.evidenceRef) draft[item.id].evidenceRef = record.evidenceRef;
|
||||
persistDraft();
|
||||
persistExecutionHistory();
|
||||
Object.assign(executionForm, {
|
||||
executedAt: formatLocalMinute(new Date()),
|
||||
operator: record.operator,
|
||||
status: record.status,
|
||||
result: '',
|
||||
evidenceRef: ''
|
||||
});
|
||||
ElMessage.success('现场执行记录已追加');
|
||||
}
|
||||
|
||||
function createExecutionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function executionStatusTag(status: FieldStatus) {
|
||||
if (status === 'PASS') return 'success';
|
||||
if (status === 'BLOCKED') return 'danger';
|
||||
if (status === 'SKIP') return 'info';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
async function copyBlockedSummary() {
|
||||
const header = [
|
||||
`联调批次:${sessionMeta.batchNo || '未填写'}`,
|
||||
@@ -916,7 +1107,9 @@ function buildCloseoutArchive() {
|
||||
pending: pendingCount.value,
|
||||
evidenceRecorded: evidenceRecordedCount.value,
|
||||
evidenceMissing: evidenceMissingCount.value,
|
||||
overdue: overdueItems.value.length
|
||||
overdue: overdueItems.value.length,
|
||||
executedItems: executedItemCount.value,
|
||||
executionRecords: totalExecutionCount.value
|
||||
},
|
||||
categoryProgress: categoryProgress.value,
|
||||
stageProgress: stageProgress.value,
|
||||
@@ -942,7 +1135,8 @@ function serializeHandoffItem(item: HandoffItem) {
|
||||
deadline: itemDraft.deadline,
|
||||
note: itemDraft.note,
|
||||
evidence: item.evidence,
|
||||
evidenceRef: itemDraft.evidenceRef
|
||||
evidenceRef: itemDraft.evidenceRef,
|
||||
executionHistory: executionHistory[item.id].map((record) => ({ ...record }))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -959,7 +1153,7 @@ function buildHandoffReport() {
|
||||
`最后更新:${sessionMeta.updatedAt || '未记录'}`,
|
||||
`结论:${handoffReady.value ? '可进入现场验收收口' : '仍不可收口'}`,
|
||||
`归档门禁:${closeoutReady.value ? '可归档' : `待补齐 ${missingCloseoutFields.value.join('、')}`}`,
|
||||
`汇总:总项 ${checklist.length},通过 ${passedCount.value},阻断 ${blockedCount.value},待处理 ${pendingCount.value},证据 ${evidenceRecordedCount.value}/${checklist.length},待补证据 ${evidenceMissingCount.value},逾期 ${overdueItems.value.length}`
|
||||
`汇总:总项 ${checklist.length},通过 ${passedCount.value},阻断 ${blockedCount.value},待处理 ${pendingCount.value},证据 ${evidenceRecordedCount.value}/${checklist.length},待补证据 ${evidenceMissingCount.value},逾期 ${overdueItems.value.length},已执行 ${executedItemCount.value}/${checklist.length},执行记录 ${totalExecutionCount.value}`
|
||||
];
|
||||
const progress = categoryProgress.value.map((item) => (
|
||||
`- ${item.category}:${item.passed}/${item.total} 通过,阻断 ${item.blocked},待处理 ${item.pending},不适用 ${item.skipped}`
|
||||
@@ -1015,7 +1209,11 @@ function formatReportItem(item: HandoffItem) {
|
||||
` 期限:${itemDraft.deadline || '未填写'}`,
|
||||
` 结果:${itemDraft.note || '未记录'}`,
|
||||
` 应留证据:${item.evidence}`,
|
||||
` 证据记录:${itemDraft.evidenceRef || '未记录'}`
|
||||
` 证据记录:${itemDraft.evidenceRef || '未记录'}`,
|
||||
` 执行记录:${executionHistory[item.id].length || '无'}`,
|
||||
...executionHistory[item.id].map((record) => (
|
||||
` - ${record.executedAt} / ${record.operator} / ${statusText(record.status)} / ${record.result} / 证据:${record.evidenceRef || '未记录'}`
|
||||
))
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1047,7 +1245,9 @@ function exportChecklist() {
|
||||
['session', '证据记录数', `${evidenceRecordedCount.value}/${checklist.length}`, '', '', '', '', '', ''],
|
||||
['session', '待补证据数', String(evidenceMissingCount.value), '', '', '', '', '', ''],
|
||||
['session', '逾期检查项', String(overdueItems.value.length), '', '', '', '', '', ''],
|
||||
['ID', '类别', '检查项', '期望', '状态', '负责人', '期限', '结果记录', '应留证据', '证据记录'],
|
||||
['session', '已执行检查项', `${executedItemCount.value}/${checklist.length}`, '', '', '', '', '', ''],
|
||||
['session', '执行记录数', String(totalExecutionCount.value), '', '', '', '', '', ''],
|
||||
['ID', '类别', '检查项', '期望', '状态', '负责人', '期限', '结果记录', '应留证据', '证据记录', '执行记录数', '最近执行时间'],
|
||||
...exportItems.map((item) => [
|
||||
item.id,
|
||||
item.category,
|
||||
@@ -1058,7 +1258,9 @@ function exportChecklist() {
|
||||
draft[item.id].deadline,
|
||||
draft[item.id].note,
|
||||
item.evidence,
|
||||
draft[item.id].evidenceRef
|
||||
draft[item.id].evidenceRef,
|
||||
String(executionHistory[item.id].length),
|
||||
executionHistory[item.id][0]?.executedAt || ''
|
||||
])
|
||||
]);
|
||||
}
|
||||
@@ -1107,10 +1309,11 @@ function exportRiskRegister() {
|
||||
|
||||
function exportSnapshot() {
|
||||
const payload = {
|
||||
schema: 'qipai.cleaning.field-handoff.snapshot.v1',
|
||||
schema: 'qipai.cleaning.field-handoff.snapshot.v2',
|
||||
exportedAt: new Date().toISOString(),
|
||||
sessionMeta,
|
||||
draft
|
||||
draft,
|
||||
executionHistory
|
||||
};
|
||||
downloadText(
|
||||
`cleaning-field-handoff-snapshot-${Date.now()}.json`,
|
||||
@@ -1133,8 +1336,12 @@ async function importSnapshot(event: Event) {
|
||||
schema?: string;
|
||||
sessionMeta?: Partial<SessionMeta>;
|
||||
draft?: Record<string, Partial<HandoffDraft>>;
|
||||
executionHistory?: Record<string, unknown>;
|
||||
};
|
||||
if (payload.schema !== 'qipai.cleaning.field-handoff.snapshot.v1' || !payload.draft) {
|
||||
if (
|
||||
!['qipai.cleaning.field-handoff.snapshot.v1', 'qipai.cleaning.field-handoff.snapshot.v2'].includes(payload.schema || '')
|
||||
|| !payload.draft
|
||||
) {
|
||||
throw new Error('SNAPSHOT_SCHEMA_INVALID');
|
||||
}
|
||||
Object.assign(sessionMeta, {
|
||||
@@ -1158,9 +1365,13 @@ async function importSnapshot(event: Event) {
|
||||
evidenceRef: value.evidenceRef || ''
|
||||
};
|
||||
}
|
||||
for (const item of checklist) {
|
||||
executionHistory[item.id] = normalizeExecutionRecords(payload.executionHistory?.[item.id]);
|
||||
}
|
||||
touchSessionMeta();
|
||||
persistSessionMeta();
|
||||
persistDraft();
|
||||
persistExecutionHistory();
|
||||
ElMessage.success('联调快照已导入');
|
||||
} catch {
|
||||
ElMessage.error('联调快照格式无效');
|
||||
@@ -1181,6 +1392,29 @@ function normalizeDraft(value?: Partial<HandoffDraft>): HandoffDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExecutionRecords(value: unknown): HandoffExecution[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return [];
|
||||
const record = entry as Partial<HandoffExecution>;
|
||||
if (
|
||||
typeof record.id !== 'string'
|
||||
|| typeof record.executedAt !== 'string'
|
||||
|| typeof record.operator !== 'string'
|
||||
|| !isFieldStatus(record.status)
|
||||
|| typeof record.result !== 'string'
|
||||
) return [];
|
||||
return [{
|
||||
id: record.id,
|
||||
executedAt: record.executedAt,
|
||||
operator: record.operator,
|
||||
status: record.status,
|
||||
result: record.result,
|
||||
evidenceRef: typeof record.evidenceRef === 'string' ? record.evidenceRef : ''
|
||||
}];
|
||||
}).slice(0, 200);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||
downloadText(filename, `\uFEFF${content}`, 'text/csv;charset=utf-8');
|
||||
|
||||
@@ -779,6 +779,53 @@ textarea {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.handoff-execution-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.handoff-execution-list article {
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.handoff-execution-list header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.handoff-execution-list header span:last-child,
|
||||
.handoff-execution-list small {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.handoff-execution-list p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.handoff-execution-form {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
display: grid;
|
||||
gap: 0 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.handoff-execution-result {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -983,6 +1030,7 @@ textarea {
|
||||
.photo-grid,
|
||||
.trend-row,
|
||||
.handoff-session .el-form,
|
||||
.handoff-execution-form,
|
||||
.handoff-category-progress,
|
||||
.stats-filter,
|
||||
.stats-grid {
|
||||
|
||||
Reference in New Issue
Block a user