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