| | |
| | | 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(""); |
| | |
| | | } |
| | | 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; } |
| | |
| | | |
| | | <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> |
| | | |
| | |
| | | .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%; } |