<script setup lang="ts">
|
import { computed, onMounted, ref } from "vue";
|
import { FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
|
import { artifactUrl, createRiskRuleRun, readFileAsPayload, type RiskRuleCase, type RiskScoreFeature } from "@/api/artifacts";
|
import ArtifactState from "@/components/ArtifactState.vue";
|
import { useArtifactStore } from "@/stores/artifacts";
|
|
const store = useArtifactStore();
|
const caseId = ref("");
|
const observations = ref<File | null>(null);
|
const zones = ref<File | null>(null);
|
const rules = ref<File | null>(null);
|
const showRunForm = ref(false);
|
const running = ref(false);
|
const runError = ref<string | null>(null);
|
const searchText = ref("");
|
const currentCase = computed<RiskRuleCase | undefined>(() => store.riskRuleCases[caseId.value] ?? Object.values(store.riskRuleCases)[0]);
|
const caseOptions = computed(() => Object.values(store.riskRuleCases).map((item) => ({ value: item.id, label: item.label })));
|
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
|
const levelColor = (level: string) => ({ low: "green", medium: "gold", high: "orange", critical: "red" }[level] ?? "default");
|
const vectorViewBox = computed(() => {
|
const bounds = currentCase.value?.run.raster.bounds ?? [0, 0, 1, 1];
|
const [minX, minY, maxX, maxY] = bounds;
|
return `${minX} ${-maxY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
|
});
|
const points = computed(() => (currentCase.value?.features ?? []).filter((feature): feature is RiskScoreFeature => feature.geometry.type === "Point" && Array.isArray(feature.geometry.coordinates)));
|
|
function beforeUpload(kind: "observations" | "zones" | "rules") {
|
return (file: File) => {
|
if (kind === "observations") observations.value = file;
|
if (kind === "zones") zones.value = file;
|
if (kind === "rules") rules.value = file;
|
return false;
|
};
|
}
|
function clearFile(kind: "observations" | "zones" | "rules") {
|
if (kind === "observations") observations.value = null;
|
if (kind === "zones") zones.value = null;
|
if (kind === "rules") rules.value = null;
|
}
|
async function submitRun() {
|
if (!observations.value || !zones.value || !rules.value) {
|
runError.value = "请同时选择观测对象 GeoJSON、风险分区 GeoJSON 和规则 JSON。";
|
return;
|
}
|
running.value = true;
|
runError.value = null;
|
try {
|
const files = await Promise.all([readFileAsPayload(observations.value), readFileAsPayload(zones.value), readFileAsPayload(rules.value)]);
|
const { run } = await createRiskRuleRun({ observations: files[0], zones: files[1], rules: files[2] });
|
await store.loadRiskRule(true);
|
caseId.value = run.id;
|
observations.value = null; zones.value = null; rules.value = null; showRunForm.value = false;
|
} catch (error) {
|
runError.value = error instanceof Error ? error.message : "空间规则与风险评分运行失败。";
|
} finally {
|
running.value = false;
|
}
|
}
|
|
onMounted(async () => {
|
await store.loadRiskRule();
|
caseId.value = Object.keys(store.riskRuleCases)[0] ?? "";
|
});
|
</script>
|
|
<template>
|
<ArtifactState :loading="store.loading" :error="store.error" />
|
<section class="workspace-command risk-command">
|
<div><h2>新建空间规则与风险评分运行</h2><p>上传带 CRS 的观测对象、风险分区和规则配置,在本机 CPU 上生成可审计的评分结果。</p></div>
|
<a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
|
</section>
|
|
<section v-if="showRunForm" class="surface-section risk-run-form">
|
<a-alert type="info" show-icon message="需要 3 个文件:观测对象 GeoJSON(object_id、confidence、投影 CRS)、风险分区 GeoJSON(zone_type)和规则 JSON。" />
|
<div class="risk-upload-grid">
|
<div><label>观测对象 GeoJSON</label><a-upload accept=".geojson" :max-count="1" :file-list="observations ? [{ uid: observations.name, name: observations.name, status: 'done' as const }] : []" :before-upload="beforeUpload('observations')" @remove="clearFile('observations')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
|
<div><label>风险分区 GeoJSON</label><a-upload accept=".geojson" :max-count="1" :file-list="zones ? [{ uid: zones.name, name: zones.name, status: 'done' as const }] : []" :before-upload="beforeUpload('zones')" @remove="clearFile('zones')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
|
<div><label>规则 JSON</label><a-upload accept=".json" :max-count="1" :file-list="rules ? [{ uid: rules.name, name: rules.name, status: 'done' as const }] : []" :before-upload="beforeUpload('rules')" @remove="clearFile('rules')"><a-button><UploadOutlined />选择文件</a-button></a-upload></div>
|
</div>
|
<a-alert v-if="runError" type="error" show-icon :message="runError" />
|
<a-button type="primary" :loading="running" :disabled="!observations || !zones || !rules" @click="submitRun"><PlayCircleOutlined />开始评分</a-button>
|
</section>
|
|
<template v-if="currentCase">
|
<a-row :gutter="[18, 18]" class="risk-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">{{ item.label }}</a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><div><h2>栅格与矢量结果</h2><span class="toolbar-note">{{ currentCase.run.crs }} · {{ currentCase.run.raster.width }} x {{ currentCase.run.raster.height }} · {{ currentCase.run.parameters.raster_resolution }} m 像元</span></div></div><div class="comparison-grid risk-images"><figure><figcaption>风险评分栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_preview}`)" /></figure><figure><figcaption>对象评分矢量</figcaption><svg class="risk-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><g transform="scale(1,-1)"><circle v-for="feature in points" :key="feature.properties.object_id" :cx="feature.geometry.coordinates[0]" :cy="feature.geometry.coordinates[1]" :r="Math.max(currentCase.run.parameters.raster_resolution * 1.25, 6)" :class="`risk-${feature.properties.risk_level}`"><title>{{ feature.properties.object_id }}: {{ feature.properties.risk_score }}</title></circle></g></svg></figure></div></section></a-col>
|
</a-row>
|
|
<a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="10"><section class="surface-section"><div class="section-heading"><div><h2>风险汇总</h2><p>规则分数为可复核的候选优先级,不是自动处置结论。</p></div><a-tag color="red">最高 {{ currentCase.run.summary.maximum_score }} 分</a-tag></div><a-descriptions size="small" :column="2"><a-descriptions-item label="评分对象">{{ currentCase.run.summary.object_count }}</a-descriptions-item><a-descriptions-item label="平均分">{{ currentCase.run.summary.mean_score }}</a-descriptions-item><a-descriptions-item label="低风险">{{ currentCase.run.summary.risk_level_counts.low }}</a-descriptions-item><a-descriptions-item label="中风险">{{ currentCase.run.summary.risk_level_counts.medium }}</a-descriptions-item><a-descriptions-item label="高风险">{{ currentCase.run.summary.risk_level_counts.high }}</a-descriptions-item><a-descriptions-item label="严重风险">{{ currentCase.run.summary.risk_level_counts.critical }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="14"><section class="surface-section"><h2>对象评分明细</h2><a-table :data-source="currentCase.features" :pagination="{ pageSize: 6 }" row-key="properties.object_id" size="small" :scroll="{ x: 760 }"><a-table-column title="对象" key="object"><template #default="{ record }">{{ record.properties.object_id }}</template></a-table-column><a-table-column title="分数" key="score" align="right"><template #default="{ record }">{{ record.properties.risk_score }}</template></a-table-column><a-table-column title="等级" key="level"><template #default="{ record }"><a-tag :color="levelColor(record.properties.risk_level)">{{ record.properties.risk_level }}</a-tag></template></a-table-column><a-table-column title="命中规则" key="rules"><template #default="{ record }">{{ record.properties.hit_rule_ids.join(" · ") || "无" }}</template></a-table-column><a-table-column title="建议" key="suggestions"><template #default="{ record }">{{ record.properties.suggestions.join(";") || "常规监测" }}</template></a-table-column></a-table></section></a-col></a-row>
|
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="处理耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="规则数">{{ currentCase.run.parameters.rule_count }}</a-descriptions-item><a-descriptions-item label="分数上限">{{ currentCase.run.parameters.score_cap }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_raster}`)" target="_blank"><FileImageOutlined />风险 GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_vector}`)" target="_blank"><FileOutlined />评分 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_scores_csv}`)" target="_blank"><FileOutlined />评分 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.summary}`)" target="_blank"><FileOutlined />汇总 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
|
</template>
|
</template>
|
|
<style scoped>
|
.risk-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
|
.risk-upload-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
.risk-upload-grid label { display: block; margin-bottom: 7px; color: #5d6a62; font-size: 12px; }
|
.risk-workspace, .result-band { margin-bottom: 24px; }
|
.risk-images figure { min-width: 0; }
|
.risk-images :deep(.ant-image), .risk-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
|
.risk-vector { width: 100%; height: 360px; border: 1px solid #d9e0da; background: #f7f8f7; }
|
.risk-vector circle { stroke: #ffffff; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
.risk-low { fill: #3f8600; }.risk-medium { fill: #d89614; }.risk-high { fill: #d46b08; }.risk-critical { fill: #cf1322; }
|
@media (max-width: 1199px) { .risk-images :deep(img), .risk-vector { height: 300px; } }
|
@media (max-width: 640px) { .risk-upload-grid { grid-template-columns: 1fr; } }
|
</style>
|