罗广辉
12 hours ago 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214
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("");
@@ -33,7 +34,8 @@
  rgb_std_r: "红色波动", rgb_std_g: "绿色波动", rgb_std_b: "蓝色波动",
  hsv_mean_h: "色相均值", hsv_mean_s: "饱和度均值", hsv_mean_v: "亮度均值",
  hsv_std_h: "色相波动", hsv_std_s: "饱和度波动", hsv_std_v: "亮度波动",
  gray_entropy: "灰度熵", edge_density: "边缘密度", laplacian_variance: "清晰度", dark_ratio: "暗像素比例", bright_ratio: "亮像素比例"
  gray_entropy: "灰度熵", edge_density: "边缘密度", laplacian_variance: "清晰度", dark_ratio: "暗像素比例", bright_ratio: "亮像素比例",
  local_appearance_change: "局部外观变化", local_structure_change: "局部结构变化"
};
function addFile(target: "reference" | "input", file: File) {
@@ -47,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; }
@@ -112,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>
@@ -145,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%; }