From 2ae460fc4a4c2419cf44329783d49a739e2a04ea Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Wed, 19 Aug 2026 16:58:50 +0800
Subject: [PATCH] Merge branch 'master' of http://139.196.74.78:10010/r/geoai/geoai-workbench

---
 apps/workbench-console/src/components/AnomalyDetectionPanel.vue |  202 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 202 insertions(+), 0 deletions(-)

diff --git a/apps/workbench-console/src/components/AnomalyDetectionPanel.vue b/apps/workbench-console/src/components/AnomalyDetectionPanel.vue
new file mode 100644
index 0000000..53e674f
--- /dev/null
+++ b/apps/workbench-console/src/components/AnomalyDetectionPanel.vue
@@ -0,0 +1,202 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
+
+import { artifactUrl, createAnomalyRun, loadAnomalyJob, uploadAnomalyFile, type AnomalyCase } from "@/api/artifacts";
+import ArtifactState from "@/components/ArtifactState.vue";
+import { useArtifactStore } from "@/stores/artifacts";
+
+const store = useArtifactStore();
+const caseId = ref("");
+const selectedName = ref("");
+const referenceFiles = ref<File[]>([]);
+const inputFiles = ref<File[]>([]);
+const tileSize = ref(256);
+const stride = ref(128);
+const thresholdQuantile = ref(0.995);
+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("");
+
+const currentCase = computed<AnomalyCase | undefined>(() => store.anomalyCases[caseId.value] ?? Object.values(store.anomalyCases)[0]);
+const selectedImage = computed(() => currentCase.value?.run.images.find((item) => item.file === selectedName.value) ?? currentCase.value?.run.images[0]);
+const candidates = computed(() => selectedImage.value && currentCase.value ? currentCase.value.candidates[selectedImage.value.file] ?? [] : []);
+const candidateRows = computed(() => candidates.value.map((item) => item.properties));
+const caseOptions = computed(() => Object.values(store.anomalyCases).map((item) => ({ value: item.id, label: item.label })));
+const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
+const methodLabel: Record<string, string> = { rule_only: "仅规则", isolation_only: "仅 Isolation Forest", agreement: "两方法一致" };
+const featureLabel: Record<string, string> = {
+  rgb_mean_r: "红色均值", rgb_mean_g: "绿色均值", rgb_mean_b: "蓝色均值",
+  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: "亮像素比例",
+  local_appearance_change: "局部外观变化", local_structure_change: "局部结构变化"
+};
+
+function addFile(target: "reference" | "input", file: File) {
+  const list = target === "reference" ? referenceFiles : inputFiles;
+  if (!list.value.some((item) => item.name.toLowerCase() === file.name.toLowerCase())) list.value = [...list.value, file];
+  return false;
+}
+function removeFile(target: "reference" | "input", name: string) {
+  const list = target === "reference" ? referenceFiles : inputFiles;
+  list.value = list.value.filter((item) => item.name !== name);
+}
+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; }
+  if (stride.value > tileSize.value) { runError.value = "步长不能大于窗口尺寸。"; return; }
+  running.value = true; runError.value = null;
+  try {
+    runStage.value = "正在上传原始影像…";
+    const references = [];
+    const inputs = [];
+    for (const file of referenceFiles.value) references.push(await uploadAnomalyFile(file, "reference"));
+    for (const file of inputFiles.value) inputs.push(await uploadAnomalyFile(file, "input"));
+    runStage.value = "已提交,正在本机 CPU 后台检测…";
+    const { job } = await createAnomalyRun(
+      { reference: references, input: inputs },
+      { tileSize: tileSize.value, stride: stride.value, thresholdQuantile: thresholdQuantile.value, randomState: randomState.value }
+    );
+    let state = job;
+    while (state.status === "queued" || state.status === "running") { await delay(1200); state = await loadAnomalyJob(job.id); }
+    if (state.status === "failed") throw new Error(state.error || "异常检测后台任务失败");
+    runStage.value = "正在加载结果…";
+    await store.loadAnomaly(true);
+    caseId.value = state.runId;
+    syncSelection();
+    referenceFiles.value = []; inputFiles.value = []; showRunForm.value = false;
+  } catch (error) { runError.value = error instanceof Error ? error.message : "异常检测运行失败"; }
+  finally { running.value = false; runStage.value = ""; }
+}
+
+onMounted(async () => { await store.loadAnomaly(); caseId.value = Object.keys(store.anomalyCases)[0] ?? ""; syncSelection(); });
+</script>
+
+<template>
+  <ArtifactState :loading="store.loading" :error="store.error" />
+  <section class="workspace-command anomaly-command">
+    <div><h2>新建异常检测运行</h2><p>固定机位且尺寸一致时自动进行同位置比较,再与 Isolation Forest 结果对照。</p></div>
+    <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
+  </section>
+
+  <section v-if="showRunForm" class="surface-section anomaly-run-form">
+    <a-alert type="warning" show-icon message="建议上传 3~6 张固定机位、相同尺寸的正常图;系统会自动启用同位置规则。机位或尺寸不一致时回退为全局比较;候选不是业务告警。" />
+    <div class="anomaly-upload-grid">
+      <div class="upload-group">
+        <div><strong>正常参考影像</strong><span>1~6 张;合计至少产生 50 个窗口</span></div>
+        <a-upload multiple accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="(file: File) => addFile('reference', file)"><a-button><UploadOutlined />选择参考影像</a-button></a-upload>
+        <div class="file-tags"><a-tag v-for="file in referenceFiles" :key="file.name" closable @close="removeFile('reference', file.name)">{{ file.name }}</a-tag><span v-if="!referenceFiles.length">未选择</span></div>
+      </div>
+      <div class="upload-group">
+        <div><strong>待检测影像</strong><span>1~6 张;每张分别生成结果</span></div>
+        <a-upload multiple accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="(file: File) => addFile('input', file)"><a-button><UploadOutlined />选择待检测影像</a-button></a-upload>
+        <div class="file-tags"><a-tag v-for="file in inputFiles" :key="file.name" closable @close="removeFile('input', file.name)">{{ file.name }}</a-tag><span v-if="!inputFiles.length">未选择</span></div>
+      </div>
+    </div>
+    <div class="anomaly-parameters">
+      <label>窗口尺寸<a-input-number v-model:value="tileSize" :min="128" :max="1024" :step="64" /></label>
+      <label>步长<a-input-number v-model:value="stride" :min="32" :max="tileSize" :step="32" /></label>
+      <label>阈值分位数<a-input-number v-model:value="thresholdQuantile" :min="0.9" :max="0.9999" :step="0.001" :precision="4" /></label>
+      <label>随机种子<a-input-number v-model:value="randomState" :min="0" :max="2147483647" /></label>
+    </div>
+    <a-alert v-if="runError" type="error" show-icon :message="runError" />
+    <a-button type="primary" :loading="running" :disabled="!referenceFiles.length || !inputFiles.length" @click="submitRun"><PlayCircleOutlined />开始检测</a-button>
+    <span v-if="running" class="run-stage">{{ runStage }}</span>
+  </section>
+
+  <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()"><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>
+
+    <section class="surface-section result-band"><div class="section-heading"><div><h2>正常参考输入</h2><p>模型只从这些影像的窗口学习“正常”视觉分布。</p></div><a-tag>{{ currentCase.run.reference_tile_count }} 个参考窗口</a-tag></div><div class="reference-grid"><figure v-for="reference in currentCase.run.reference_images" :key="reference.file"><a-image :src="artifactUrl(`${currentCase.referenceRoot}/${reference.file}`)" /><figcaption>{{ reference.file }} · {{ reference.tile_count }} 窗口</figcaption></figure></div></section>
+
+    <a-row :gutter="[18, 18]" class="metric-row anomaly-metrics"><a-col :xs="12" :lg="6"><a-statistic title="候选区" :value="selectedImage.candidate_count" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="规则覆盖率" :value="(selectedImage.rule_anomaly_coverage * 100).toFixed(2)" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="Isolation 覆盖率" :value="(selectedImage.isolation_anomaly_coverage * 100).toFixed(2)" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="单图耗时" :value="selectedImage.elapsed_seconds" suffix="秒" /></a-col></a-row>
+
+    <a-row :gutter="[18, 18]" class="result-band anomaly-secondary"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>规则异常热力图</h2><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.rule_heatmap_file}`)" /></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>Isolation Forest 热力图</h2><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.isolation_heatmap_file}`)" /></section></a-col></a-row>
+
+    <section class="surface-section result-band"><div class="section-heading"><div><h2>候选区明细</h2><p>原因特征表示规则评分贡献最大项,不是业务原因诊断。</p></div><a-tag>{{ selectedImage.georeferenced ? selectedImage.crs : "像素坐标" }}</a-tag></div><a-table :data-source="candidateRows" :pagination="{ pageSize: 8 }" row-key="feature_id" size="small" :scroll="{ x: 760 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="方法" data-index="method" key="method"><template #default="{ text }">{{ methodLabel[text] || text }}</template></a-table-column><a-table-column title="主要特征" data-index="reason_feature" key="reason_feature"><template #default="{ text }">{{ featureLabel[text] || text || '—' }}</template></a-table-column><a-table-column title="规则分数" data-index="max_rule_score" key="max_rule_score" align="right" /><a-table-column title="Isolation 分数" data-index="max_isolation_score" key="max_isolation_score" align="right" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /></a-table></section>
+
+    <section class="surface-section result-band anomaly-files"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 6 }"><a-descriptions-item label="分类边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="规则模式">{{ currentCase.run.parameters.rule_mode_selected === 'aligned' ? '同位置' : '全局' }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="参考影像">{{ currentCase.run.reference_count }}</a-descriptions-item><a-descriptions-item label="参考窗口">{{ currentCase.run.reference_tile_count }}</a-descriptions-item><a-descriptions-item label="总耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.mask_file}`)" download><FileImageOutlined />编码掩膜 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.vector_file}`)" download><FileOutlined />候选 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.tiles_file}`)" download><FileOutlined />窗口特征 CSV</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><DownloadOutlined />运行元数据</a-button></a-space></section>
+    <a-alert class="anomaly-limit" type="warning" show-icon message="结果表示相对正常参考影像的视觉离群,只是人工复核候选,不能直接形成堵塞、损坏、渗漏或生产告警结论。" />
+  </template>
+  <a-empty v-else-if="!store.loading" description="尚无异常检测案例"><template #description><p>点击“上传并运行”,选择正常参考影像和待检测影像。</p></template></a-empty>
+</template>
+
+<style scoped>
+.anomaly-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
+.anomaly-upload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+.upload-group { display: grid; align-content: start; gap: 12px; padding: 16px; border: 1px solid var(--wb-border); background: #f7f9f7; }
+.upload-group > div:first-child { display: grid; gap: 4px; }
+.upload-group span, .run-stage { color: var(--wb-muted); font-size: 12px; }
+.file-tags { min-height: 30px; }
+.file-tags :deep(.ant-tag) { margin-bottom: 6px; }
+.anomaly-parameters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+.anomaly-parameters label { display: grid; gap: 7px; color: var(--wb-muted); font-size: 12px; font-weight: 700; }
+.anomaly-parameters :deep(.ant-input-number) { width: 100%; }
+.anomaly-workspace, .result-band { margin-bottom: 24px; }
+.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%; }
+.anomaly-secondary :deep(img) { height: 330px; object-fit: contain; background: #18221d; }
+.reference-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
+.reference-grid figure { min-width: 0; margin: 0; }
+.reference-grid :deep(.ant-image), .reference-grid :deep(img) { width: 100%; }
+.reference-grid :deep(img) { height: 150px; object-fit: cover; background: #18221d; }
+.reference-grid figcaption { margin-top: 6px; overflow: hidden; color: var(--wb-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.anomaly-legend { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 12px; color: var(--wb-muted); font-size: 12px; }
+.anomaly-legend span::before { display: inline-block; width: 10px; height: 10px; margin-right: 6px; content: ""; }
+.anomaly-legend .rule::before { background: #ff9f1c; }.anomaly-legend .isolation::before { background: #2196f3; }.anomaly-legend .agreement::before { background: #e53935; }
+.anomaly-limit { margin-bottom: 24px; }
+@media (max-width: 1199px) { .anomaly-workspace > :deep(.ant-col), .anomaly-secondary > :deep(.ant-col) { display: block; } .anomaly-workspace .run-library { max-height: none; } .anomaly-parameters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 760px) { .anomaly-upload-grid, .anomaly-primary, .anomaly-parameters, .reference-grid { grid-template-columns: minmax(0, 1fr); } .section-heading { flex-wrap: wrap; } .anomaly-primary :deep(img), .anomaly-secondary :deep(img) { height: 270px; } }
+</style>

--
Gitblit v1.9.3