|
@@ -112,9 +112,8 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="stat-divider"></div>
|
|
<div class="stat-divider"></div>
|
|
|
<div class="stat-item">
|
|
<div class="stat-item">
|
|
|
- <span class="stat-value" :class="{ 'is-fail': Number(displayFailCount) > 0 }">
|
|
|
|
|
- {{ displayFailCount }}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <!-- 失败数固定用红色(与成功数的绿色对应),0 也保持红色,避免完成态一眼看不出哪一项是失败口径 -->
|
|
|
|
|
+ <span class="stat-value is-fail">{{ displayFailCount }}</span>
|
|
|
<span class="stat-label">导入失败(条)</span>
|
|
<span class="stat-label">导入失败(条)</span>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- AI 无法清洗的表:同属失败语义,单独明示(此前只藏在处理日志里) -->
|
|
<!-- AI 无法清洗的表:同属失败语义,单独明示(此前只藏在处理日志里) -->
|
|
@@ -128,7 +127,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="complete-actions">
|
|
<div class="complete-actions">
|
|
|
<Button class="complete-log" @click="handleViewLog">查看清洗日志</Button>
|
|
<Button class="complete-log" @click="handleViewLog">查看清洗日志</Button>
|
|
|
- <Button v-if="isFused" type="primary" class="complete-result" @click="handleStartAnalysis">
|
|
|
|
|
|
|
+ <Button v-if="isSmartCleanEntry" type="primary" class="complete-result" @click="handleStartAnalysis">
|
|
|
开始分析
|
|
开始分析
|
|
|
</Button>
|
|
</Button>
|
|
|
<Button v-else type="primary" class="complete-result" @click="handleViewResult">查看结果</Button>
|
|
<Button v-else type="primary" class="complete-result" @click="handleViewResult">查看结果</Button>
|
|
@@ -167,6 +166,7 @@
|
|
|
import {
|
|
import {
|
|
|
clearImportProgress,
|
|
clearImportProgress,
|
|
|
getActiveImportProgress,
|
|
getActiveImportProgress,
|
|
|
|
|
+ getCleanFlowSource,
|
|
|
getImportProgressEventSource,
|
|
getImportProgressEventSource,
|
|
|
resetImportProgressRuntimeState,
|
|
resetImportProgressRuntimeState,
|
|
|
saveImportProgress,
|
|
saveImportProgress,
|
|
@@ -213,6 +213,20 @@
|
|
|
const logModalOpen = ref(false);
|
|
const logModalOpen = ref(false);
|
|
|
let eventSource = getImportProgressEventSource();
|
|
let eventSource = getImportProgressEventSource();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 清洗 100% 后等待数据治理收尾的兜底:
|
|
|
|
|
+ * 治理未启动 / 启动失败 / 进度不再推送 / SSE 断流 / AI 终态消息丢失时,页面不能永远停在「执行中」
|
|
|
|
|
+ * —— 超过静默期就按清洗完成处理,用户仍能进结果页(治理可在数据页手动补跑)。
|
|
|
|
|
+ */
|
|
|
|
|
+ const COMPLETION_FALLBACK_GRACE_MS = 15_000;
|
|
|
|
|
+ /** 治理压根没启动时用更短的静默期(终端消息里 101→103→102 基本同刻到达,等 3s 足够) */
|
|
|
|
|
+ const COMPLETION_FALLBACK_QUICK_MS = 3_000;
|
|
|
|
|
+ const completionFallback = ref(false);
|
|
|
|
|
+ let completionFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
|
+ /** 最近一次收到任务事件(cmd 101/102/103)的时间戳:区分「任务在跑只是慢」与「任务没动静」 */
|
|
|
|
|
+ let lastTaskActivityAt = 0;
|
|
|
|
|
+ let completionFallbackNotified = false;
|
|
|
|
|
+
|
|
|
const closeSse = () => {
|
|
const closeSse = () => {
|
|
|
const activeEventSource = eventSource || getImportProgressEventSource();
|
|
const activeEventSource = eventSource || getImportProgressEventSource();
|
|
|
activeEventSource?.close();
|
|
activeEventSource?.close();
|
|
@@ -286,6 +300,8 @@
|
|
|
governTaskStarted.value = snapshot.governTaskStarted;
|
|
governTaskStarted.value = snapshot.governTaskStarted;
|
|
|
governTaskFinished.value = snapshot.governTaskFinished;
|
|
governTaskFinished.value = snapshot.governTaskFinished;
|
|
|
shouldRunGovernAfterClean.value = snapshot.shouldRunGovernAfterClean;
|
|
shouldRunGovernAfterClean.value = snapshot.shouldRunGovernAfterClean;
|
|
|
|
|
+ // 恢复快照即视为「此刻还活着」:卡 100% 的兜底看门狗从页面打开时刻重新计时
|
|
|
|
|
+ lastTaskActivityAt = Date.now();
|
|
|
return true;
|
|
return true;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -363,8 +379,8 @@
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 自主清洗的完成出口「查看结果」:跳清洗结果统计页(导入文件列表带数据量/成功失败条数)。
|
|
|
|
|
- * 智能清洗的对应出口是「开始分析」(见 {@link handleStartAnalysis})。
|
|
|
|
|
|
|
+ * 自主页面发起的清洗,完成出口「查看结果」:跳清洗结果统计页(导入文件列表带数据量/成功失败条数)。
|
|
|
|
|
+ * 智能页面发起的清洗对应出口是「开始分析」(见 {@link handleStartAnalysis})。
|
|
|
*/
|
|
*/
|
|
|
const handleViewResult = async () => {
|
|
const handleViewResult = async () => {
|
|
|
if (!shouldRunGovernAfterClean.value) {
|
|
if (!shouldRunGovernAfterClean.value) {
|
|
@@ -373,7 +389,7 @@
|
|
|
await navigateToImportResult();
|
|
await navigateToImportResult();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- /** 智能清洗的完成出口「开始分析」:直接进案件研判(智能工作台)新对话草稿 */
|
|
|
|
|
|
|
+ /** 智能页面发起的清洗,完成出口「开始分析」:直接进案件研判(智能工作台)新对话草稿 */
|
|
|
const handleStartAnalysis = async () => {
|
|
const handleStartAnalysis = async () => {
|
|
|
if (!shouldRunGovernAfterClean.value) {
|
|
if (!shouldRunGovernAfterClean.value) {
|
|
|
createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
|
|
createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
|
|
@@ -390,9 +406,12 @@
|
|
|
// 激活时机与 URL 更新存在竞态(URL 参数保留作直连兜底)。
|
|
// 激活时机与 URL 更新存在竞态(URL 参数保留作直连兜底)。
|
|
|
sessionStorage.setItem(
|
|
sessionStorage.setItem(
|
|
|
CLEAN_RESULT_CONTEXT_KEY,
|
|
CLEAN_RESULT_CONTEXT_KEY,
|
|
|
- JSON.stringify({ tab: 'files', mode: isFused.value ? 'smart' : 'manual' }),
|
|
|
|
|
|
|
+ JSON.stringify({ tab: 'files', mode: isSmartCleanEntry.value ? 'smart' : 'manual' }),
|
|
|
|
|
+ );
|
|
|
|
|
+ await go(
|
|
|
|
|
+ { path: PageEnum.BASE_HOME, query: { tab: 'files', mode: isSmartCleanEntry.value ? 'smart' : 'manual' } },
|
|
|
|
|
+ true,
|
|
|
);
|
|
);
|
|
|
- await go({ path: PageEnum.BASE_HOME, query: { tab: 'files', mode: isFused.value ? 'smart' : 'manual' } }, true);
|
|
|
|
|
await nextTick();
|
|
await nextTick();
|
|
|
emitter.emit(CASE_DATA_REFRESH_EVENT);
|
|
emitter.emit(CASE_DATA_REFRESH_EVENT);
|
|
|
};
|
|
};
|
|
@@ -580,6 +599,7 @@
|
|
|
taskProcess: progress.value,
|
|
taskProcess: progress.value,
|
|
|
});
|
|
});
|
|
|
governTaskStarted.value = true;
|
|
governTaskStarted.value = true;
|
|
|
|
|
+ lastTaskActivityAt = Date.now();
|
|
|
persistImportProgress();
|
|
persistImportProgress();
|
|
|
if (!eventSource || !sseConnected.value) {
|
|
if (!eventSource || !sseConnected.value) {
|
|
|
connectSse();
|
|
connectSse();
|
|
@@ -604,6 +624,11 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (payload) {
|
|
if (payload) {
|
|
|
|
|
+ // 任何任务事件(清洗 101 / 治理 102 / AI 状态 103)都算「任务在推进」:兜底看门狗据此续期;
|
|
|
|
|
+ // 999 心跳不算,避免 SSE 还连着但任务早已停摆时被无限续期
|
|
|
|
|
+ if (payload.cmd === 101 || payload.cmd === 102 || payload.cmd === 103) {
|
|
|
|
|
+ lastTaskActivityAt = Date.now();
|
|
|
|
|
+ }
|
|
|
const value = normalizeSseProcess(payload.process);
|
|
const value = normalizeSseProcess(payload.process);
|
|
|
if (payload.cmd === 101) {
|
|
if (payload.cmd === 101) {
|
|
|
const shouldApplyCleanProgress = !calcTaskTriggered.value && activeStep.value < 2;
|
|
const shouldApplyCleanProgress = !calcTaskTriggered.value && activeStep.value < 2;
|
|
@@ -882,6 +907,19 @@
|
|
|
const aiStage = ref<SmartCleanStatusVO | null>(null);
|
|
const aiStage = ref<SmartCleanStatusVO | null>(null);
|
|
|
const aiStageTimeout = ref(false);
|
|
const aiStageTimeout = ref(false);
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 完成面板第二个按钮按「清洗从哪个页面发起」区分:
|
|
|
|
|
+ * - 自主页面(传统壳:案件数据 → 上传数据 → 清洗)→「查看结果」(清洗结果统计页)
|
|
|
|
|
+ * - 智能页面(智能工作台:研判方案视图 → 上传文件 → 清洗)→「开始分析」(回工作台新对话)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 来源由上传页启动清洗流程时写入 sessionStorage(见 importProgressSession.setCleanFlowSource);
|
|
|
|
|
+ * 拿不到时退回旧的 fused 判断兜底(仅智能清洗链路会带 fused=1)。
|
|
|
|
|
+ */
|
|
|
|
|
+ const isSmartCleanEntry = computed(() => {
|
|
|
|
|
+ const source = getCleanFlowSource();
|
|
|
|
|
+ return source ? source === 'smart' : isFused.value;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
/** 交给 AI 判定的表数 = 全部 sheet - 原本就匹配上模板的 sheet */
|
|
/** 交给 AI 判定的表数 = 全部 sheet - 原本就匹配上模板的 sheet */
|
|
|
const aiSheetTotal = computed(() =>
|
|
const aiSheetTotal = computed(() =>
|
|
|
Math.max(0, (aiStage.value?.sheetTotal ?? 0) - (aiStage.value?.matchedCount ?? 0)),
|
|
Math.max(0, (aiStage.value?.sheetTotal ?? 0) - (aiStage.value?.matchedCount ?? 0)),
|
|
@@ -892,20 +930,48 @@
|
|
|
/** 清洗结果页的「无法清洗」统计与明细(仅融合模式有 AI 链路) */
|
|
/** 清洗结果页的「无法清洗」统计与明细(仅融合模式有 AI 链路) */
|
|
|
const unableCount = computed(() => (isFused.value ? (aiStage.value?.needReviewCount ?? 0) : 0));
|
|
const unableCount = computed(() => (isFused.value ? (aiStage.value?.needReviewCount ?? 0) : 0));
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 统一进度:系统清洗(SSE)与 AI 识别两条链路按各自处理的 sheet 数加权合成一个百分比。
|
|
|
|
|
+ * AI 识别进度 = 已判定数 / 待判定数(判定完的入库/并入清洗随 SSE 推进)。
|
|
|
|
|
+ * 非融合模式(纯既有清洗)直接用 SSE 进度。
|
|
|
|
|
+ */
|
|
|
|
|
+ const overallProgress = computed(() => {
|
|
|
|
|
+ if (!isFused.value || !aiStage.value) return progress.value;
|
|
|
|
|
+ // 批次已到终态 = 1/2/3 级全部收尾,进度定格 100%(与 isComplete 的判定保持一致)
|
|
|
|
|
+ if (aiStageFinished.value) return 100;
|
|
|
|
|
+ const matched = aiStage.value.matchedCount ?? 0;
|
|
|
|
|
+ const total = Math.max(1, aiSheetTotal.value + matched);
|
|
|
|
|
+ const aiWeight = aiSheetTotal.value / total;
|
|
|
|
|
+ const aiPct = Math.round((aiJudgedCount.value / Math.max(1, aiSheetTotal.value)) * 100);
|
|
|
|
|
+ return Math.min(100, Math.round(progress.value * (1 - aiWeight) + aiPct * aiWeight));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 清洗阶段完成判定:非融合模式看 SSE 进度;融合模式看 AI 批次终态 —— 后端在
|
|
* 清洗阶段完成判定:非融合模式看 SSE 进度;融合模式看 AI 批次终态 —— 后端在
|
|
|
* 1/2/3 级全部结束后推终态消息(cmd 103,stage=DONE/PARTIAL/FAILED)。纯 AI/4级
|
|
* 1/2/3 级全部结束后推终态消息(cmd 103,stage=DONE/PARTIAL/FAILED)。纯 AI/4级
|
|
|
* 批次没有 doClean,SSE 进度不会走到 100%,不能拿 progress 当完成依据。
|
|
* 批次没有 doClean,SSE 进度不会走到 100%,不能拿 progress 当完成依据。
|
|
|
*/
|
|
*/
|
|
|
const cleanPhaseDone = computed(() => (isFused.value ? aiStageFinished.value : progress.value >= 100));
|
|
const cleanPhaseDone = computed(() => (isFused.value ? aiStageFinished.value : progress.value >= 100));
|
|
|
- const isComplete = computed(
|
|
|
|
|
- () => cleanPhaseDone.value && (!shouldRunGovernAfterClean.value || governTaskFinished.value),
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 「正常完成」判定(不含兜底):
|
|
|
|
|
+ * 1) 清洗完成 + (治理关闭或治理已完成);
|
|
|
|
|
+ * 2) 治理已完成且进度环已 100% —— 即便 AI 阶段终态消息(cmd 103)丢了也算完成。
|
|
|
|
|
+ */
|
|
|
|
|
+ const baseComplete = computed(
|
|
|
|
|
+ () =>
|
|
|
|
|
+ (cleanPhaseDone.value && (!shouldRunGovernAfterClean.value || governTaskFinished.value)) ||
|
|
|
|
|
+ (governTaskFinished.value && overallProgress.value >= 100),
|
|
|
);
|
|
);
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 最终完成态:兜底成立时不再要求 cleanPhaseDone —— 融合模式的 AI 阶段终态消息(cmd 103)
|
|
|
|
|
+ * 丢失时,进度环可能已经 100%(cmd 101),但 aiStageFinished 永远为 false,会永久卡住。
|
|
|
|
|
+ */
|
|
|
|
|
+ const isComplete = computed(() => completionFallback.value || baseComplete.value);
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 完成时提示「另有 N 个表 AI 无法清洗」——只在完成那一刻提醒一次。
|
|
* 完成时提示「另有 N 个表 AI 无法清洗」——只在完成那一刻提醒一次。
|
|
|
* 完成后<b>不自动跳转</b>(2026-10-11 需求):跳转由完成面板上的按钮触发,
|
|
* 完成后<b>不自动跳转</b>(2026-10-11 需求):跳转由完成面板上的按钮触发,
|
|
|
- * 自主清洗给「查看结果」,智能清洗给「开始分析」。
|
|
|
|
|
|
|
+ * 自主页面发起 →「查看结果」,智能页面发起 →「开始分析」。
|
|
|
*/
|
|
*/
|
|
|
watch(isComplete, (done) => {
|
|
watch(isComplete, (done) => {
|
|
|
if (!done) return;
|
|
if (!done) return;
|
|
@@ -926,20 +992,67 @@
|
|
|
void triggerCalcTask();
|
|
void triggerCalcTask();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ const clearCompletionFallbackTimer = () => {
|
|
|
|
|
+ if (completionFallbackTimer !== undefined) {
|
|
|
|
|
+ clearTimeout(completionFallbackTimer);
|
|
|
|
|
+ completionFallbackTimer = undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const resetCompletionFallback = () => {
|
|
|
|
|
+ clearCompletionFallbackTimer();
|
|
|
|
|
+ completionFallback.value = false;
|
|
|
|
|
+ lastTaskActivityAt = 0;
|
|
|
|
|
+ completionFallbackNotified = false;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 进度环已 100%、但「正常完成」条件还不成立 = 卡在收尾阶段,需要看门狗兜底 */
|
|
|
|
|
+ const completionStalled = computed(() => overallProgress.value >= 100 && !baseComplete.value);
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
- * 统一进度:系统清洗(SSE)与 AI 识别两条链路按各自处理的 sheet 数加权合成一个百分比。
|
|
|
|
|
- * AI 识别进度 = 已判定数 / 待判定数(判定完的入库/并入清洗随 SSE 推进)。
|
|
|
|
|
- * 非融合模式(纯既有清洗)直接用 SSE 进度。
|
|
|
|
|
|
|
+ * 卡 100% 的兜底看门狗:静默期内只要还有任务事件(cmd 101/102/103)就继续等,
|
|
|
|
|
+ * 彻底没动静后按完成处理 —— 覆盖治理未启动/启动失败、SSE 断流、AI 终态消息丢失等异常。
|
|
|
*/
|
|
*/
|
|
|
- const overallProgress = computed(() => {
|
|
|
|
|
- if (!isFused.value || !aiStage.value) return progress.value;
|
|
|
|
|
- // 批次已到终态 = 1/2/3 级全部收尾,进度定格 100%(与 isComplete 的判定保持一致)
|
|
|
|
|
- if (aiStageFinished.value) return 100;
|
|
|
|
|
- const matched = aiStage.value.matchedCount ?? 0;
|
|
|
|
|
- const total = Math.max(1, aiSheetTotal.value + matched);
|
|
|
|
|
- const aiWeight = aiSheetTotal.value / total;
|
|
|
|
|
- const aiPct = Math.round((aiJudgedCount.value / Math.max(1, aiSheetTotal.value)) * 100);
|
|
|
|
|
- return Math.min(100, Math.round(progress.value * (1 - aiWeight) + aiPct * aiWeight));
|
|
|
|
|
|
|
+ const scheduleCompletionFallback = () => {
|
|
|
|
|
+ clearCompletionFallbackTimer();
|
|
|
|
|
+ if (!completionStalled.value) return;
|
|
|
|
|
+ const waitMs = governTaskStarted.value ? COMPLETION_FALLBACK_GRACE_MS : COMPLETION_FALLBACK_QUICK_MS;
|
|
|
|
|
+ completionFallbackTimer = setTimeout(() => {
|
|
|
|
|
+ completionFallbackTimer = undefined;
|
|
|
|
|
+ if (!completionStalled.value) return;
|
|
|
|
|
+ // 还在静默期内(可能有事件晚到)或治理刚开始、需要给更长的观察期
|
|
|
|
|
+ const quietFor = Date.now() - lastTaskActivityAt;
|
|
|
|
|
+ if (
|
|
|
|
|
+ quietFor < COMPLETION_FALLBACK_QUICK_MS ||
|
|
|
|
|
+ (governTaskStarted.value && quietFor < COMPLETION_FALLBACK_GRACE_MS)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ scheduleCompletionFallback();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ completionFallback.value = true;
|
|
|
|
|
+ addTaskLog('超过静默期未收到后续进度,已按完成处理(数据治理可能未执行)', 'milestone');
|
|
|
|
|
+ if (!completionFallbackNotified) {
|
|
|
|
|
+ completionFallbackNotified = true;
|
|
|
|
|
+ createMessage.warning('长时间未收到数据治理完成通知,已按清洗完成处理;如需治理可在数据页手动执行');
|
|
|
|
|
+ }
|
|
|
|
|
+ }, waitMs);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ watch(
|
|
|
|
|
+ completionStalled,
|
|
|
|
|
+ (stalled) => {
|
|
|
|
|
+ if (!stalled) {
|
|
|
|
|
+ resetCompletionFallback();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ scheduleCompletionFallback();
|
|
|
|
|
+ },
|
|
|
|
|
+ { immediate: true },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 新一轮任务(清洗未启动)时清掉上一轮留下的兜底状态,避免沿用旧的「已完成」判定
|
|
|
|
|
+ watch(cleanTaskStarted, (started) => {
|
|
|
|
|
+ if (!started) resetCompletionFallback();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
/** 记录上一次看到的 AI 状态,用于把变化差量写进处理日志(SSE 推送与启动同步共用) */
|
|
/** 记录上一次看到的 AI 状态,用于把变化差量写进处理日志(SSE 推送与启动同步共用) */
|
|
@@ -992,6 +1105,7 @@
|
|
|
const applyAiStage = (res: SmartCleanStatusVO) => {
|
|
const applyAiStage = (res: SmartCleanStatusVO) => {
|
|
|
aiStage.value = res;
|
|
aiStage.value = res;
|
|
|
aiStageTimeout.value = false;
|
|
aiStageTimeout.value = false;
|
|
|
|
|
+ lastTaskActivityAt = Date.now();
|
|
|
logAiStageEvents(res);
|
|
logAiStageEvents(res);
|
|
|
// AI 判定/入库属于「清洗入库」阶段:有在跑的 AI 任务就把步骤条推进到第 2 步
|
|
// AI 判定/入库属于「清洗入库」阶段:有在跑的 AI 任务就把步骤条推进到第 2 步
|
|
|
if (!aiStageFinished.value && activeStep.value < 1) {
|
|
if (!aiStageFinished.value && activeStep.value < 1) {
|
|
@@ -1078,6 +1192,7 @@
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
|
|
+ clearCompletionFallbackTimer();
|
|
|
if (isComplete.value) {
|
|
if (isComplete.value) {
|
|
|
closeSse();
|
|
closeSse();
|
|
|
} else {
|
|
} else {
|