From 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214 Mon Sep 17 00:00:00 2001
From: 罗广辉 <guanghui.luo@foxmail.com>
Date: Wed, 19 Aug 2026 15:43:13 +0800
Subject: [PATCH] feat: 复用检测
---
apps/workbench-console/src/components/AnomalyDetectionPanel.vue | 40 +++++++++++++++++++++++++++++++++++++++-
1 files changed, 39 insertions(+), 1 deletions(-)
diff --git a/apps/workbench-console/src/components/AnomalyDetectionPanel.vue b/apps/workbench-console/src/components/AnomalyDetectionPanel.vue
index 8c9252b..53e674f 100644
--- a/apps/workbench-console/src/components/AnomalyDetectionPanel.vue
+++ b/apps/workbench-console/src/components/AnomalyDetectionPanel.vue
@@ -17,6 +17,7 @@
const randomState = ref(42);
const showRunForm = ref(false);
const running = ref(false);
+const reusingCaseId = ref("");
const runStage = ref("");
const runError = ref<string | null>(null);
const searchText = ref("");
@@ -48,6 +49,40 @@
}
function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; }
const delay = (milliseconds: number) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
+
+async function loadCaseFile(root: string, name: string) {
+ const response = await fetch(artifactUrl(`${root}/${name}`), { cache: "no-store" });
+ if (!response.ok) throw new Error(`无法读取案例原图 ${name}(HTTP ${response.status})`);
+ const blob = await response.blob();
+ return new File([blob], name, { type: blob.type || "application/octet-stream" });
+}
+
+async function reuseCaseInputs(id: string) {
+ const source = store.anomalyCases[id];
+ if (!source || reusingCaseId.value) return;
+ reusingCaseId.value = id;
+ runError.value = null;
+ try {
+ const [references, inputs] = await Promise.all([
+ Promise.all(source.run.reference_images.map((item) => loadCaseFile(source.referenceRoot, item.file))),
+ Promise.all(source.run.images.map((item) => loadCaseFile(source.inputRoot, item.file)))
+ ]);
+ referenceFiles.value = references;
+ inputFiles.value = inputs;
+ tileSize.value = source.run.parameters.tile_size;
+ stride.value = source.run.parameters.stride;
+ thresholdQuantile.value = source.run.parameters.threshold_quantile;
+ randomState.value = source.run.parameters.random_state;
+ caseId.value = id;
+ syncSelection();
+ showRunForm.value = true;
+ } catch (error) {
+ showRunForm.value = true;
+ runError.value = error instanceof Error ? error.message : "案例输入恢复失败";
+ } finally {
+ reusingCaseId.value = "";
+ }
+}
async function submitRun() {
if (!referenceFiles.value.length || !inputFiles.value.length) { runError.value = "请至少选择一张正常参考影像和一张待检测影像。"; return; }
@@ -113,7 +148,7 @@
<template v-if="currentCase && selectedImage">
<a-row :gutter="[18, 18]" class="anomaly-workspace">
- <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
+ <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><a-button class="reuse-case-button" type="link" size="small" :loading="reusingCaseId === item.value" :disabled="Boolean(reusingCaseId) || running" @click.stop="reuseCaseInputs(item.value)">复用输入</a-button></a-list-item></template></a-list></section></a-col>
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} × {{ selectedImage.height }} · {{ selectedImage.tile_count }} 窗口</span></div><div class="comparison-grid anomaly-primary"><figure><figcaption>待检测原图</figcaption><a-image :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>两方法对比结果</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div><div class="anomaly-legend"><span class="rule">仅规则</span><span class="isolation">仅 Isolation Forest</span><span class="agreement">两方法一致</span></div></section></a-col>
</a-row>
@@ -146,6 +181,9 @@
.anomaly-workspace > :deep(.ant-col), .anomaly-secondary > :deep(.ant-col) { display: flex; }
.anomaly-workspace .surface-section, .anomaly-secondary .surface-section { width: 100%; }
.anomaly-workspace .run-library { align-self: flex-start; max-height: clamp(430px, calc(100vh - 260px), 660px); }
+.run-item { display: flex !important; align-items: center; gap: 8px; }
+.run-item-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.reuse-case-button { flex: 0 0 auto; padding-inline: 4px; }
.anomaly-primary :deep(.ant-image), .anomaly-primary :deep(img) { width: 100%; }
.anomaly-primary :deep(img) { height: 390px; object-fit: contain; background: #18221d; }
.anomaly-secondary :deep(.ant-image), .anomaly-secondary :deep(img) { width: 100%; }
--
Gitblit v1.9.3