shuishen
11 hours ago fbb068ec702338d609c1ca6eddbdb9f182d8f211
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<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>