shuishen
2 days ago 91e2fe47a57cd39b612d54177b548e4ec3432783
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
111
112
113
114
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { DownloadOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { artifactUrl, createTrajectoryRun, readFileAsPayload } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
 
const TrajectoryMap = defineAsyncComponent(() => import("@/components/TrajectoryMap.vue"));
const store = useArtifactStore();
const caseId = ref("");
const searchText = ref("");
const showRunForm = ref(false);
const running = ref(false);
const runError = ref<string | null>(null);
const inputs = ref<{ flight?: File; route?: File; restricted?: File; flyable?: File }>({});
 
const currentCase = computed(() => store.trajectoryCases[caseId.value] ?? Object.values(store.trajectoryCases)[0]);
const caseOptions = computed(() => Object.values(store.trajectoryCases).map((item) => ({ label: item.label, value: item.id })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const eventNames: Record<string, string> = { normal: "正常", stop: "停留", route_deviation: "偏航", restricted_zone: "进入禁入区", gathering: "聚集" };
const currentRules = computed(() => currentCase.value?.run.thresholds ?? {});
const outputFiles = computed(() => {
  const base = ["events.json", "events.geojson", "trajectories.geojson", "reference_routes.geojson", "zones.geojson", "run_metadata.json"];
  return currentCase.value?.showFlyableZones ? [...base.slice(0, 5), "flyable_zones.geojson", "run_metadata.json"] : base;
});
 
function beforeUpload(key: keyof typeof inputs.value) {
  return (file: File) => {
    inputs.value = { ...inputs.value, [key]: file };
    return false;
  };
}
 
function clearInput(key: keyof typeof inputs.value) {
  return () => {
    const copy = { ...inputs.value };
    delete copy[key];
    inputs.value = copy;
  };
}
 
function behaviorTags(value: string) {
  return (value || "normal").split("|").filter(Boolean).map((item) => ({ raw: item, label: eventNames[item] || item }));
}
 
async function submitRun() {
  const { flight, route, restricted, flyable } = inputs.value;
  if (!flight || !route || !restricted) {
    runError.value = "请提供实际飞行 XLSX、规划航线 KMZ 和禁飞区 GeoJSON。";
    return;
  }
  running.value = true;
  runError.value = null;
  try {
    const [flightPayload, routePayload, restrictedPayload, flyablePayload] = await Promise.all([
      readFileAsPayload(flight),
      readFileAsPayload(route),
      readFileAsPayload(restricted),
      flyable ? readFileAsPayload(flyable) : Promise.resolve(undefined)
    ]);
    const { run } = await createTrajectoryRun({ flight: flightPayload, route: routePayload, restricted: restrictedPayload, ...(flyablePayload ? { flyable: flyablePayload } : {}) });
    await store.loadTrajectory(true);
    caseId.value = run.id;
    inputs.value = {};
    showRunForm.value = false;
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "轨迹分析运行失败";
  } finally {
    running.value = false;
  }
}
 
onMounted(async () => {
  await store.loadTrajectory();
  caseId.value = Object.keys(store.trajectoryCases)[0] ?? "";
});
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
 
  <section class="workspace-command">
    <div><h2>新建轨迹分析</h2><p>上传原始航线与飞行日志后,控制台会自动转换为分析输入并保存为新的本地运行。</p></div>
    <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并分析" }}</a-button>
  </section>
 
  <section v-if="showRunForm" class="surface-section run-form">
    <a-alert type="info" show-icon message="适飞区为可选图层,只用于地图展示;禁飞区相交是技术空间结果,不是违规结论。" />
    <div class="upload-grid">
      <div><label>实际飞行轨迹(XLSX)</label><a-upload accept=".xlsx" :max-count="1" :before-upload="beforeUpload('flight')" @remove="clearInput('flight')"><a-button><UploadOutlined />{{ inputs.flight?.name || "选择 XLSX" }}</a-button></a-upload></div>
      <div><label>规划航线(KMZ)</label><a-upload accept=".kmz" :max-count="1" :before-upload="beforeUpload('route')" @remove="clearInput('route')"><a-button><UploadOutlined />{{ inputs.route?.name || "选择 KMZ" }}</a-button></a-upload></div>
      <div><label>禁飞区(GeoJSON)</label><a-upload accept=".geojson" :max-count="1" :before-upload="beforeUpload('restricted')" @remove="clearInput('restricted')"><a-button><UploadOutlined />{{ inputs.restricted?.name || "选择 GeoJSON" }}</a-button></a-upload></div>
      <div><label>适飞区(可选 Gzip)</label><a-upload accept=".gzip" :max-count="1" :before-upload="beforeUpload('flyable')" @remove="clearInput('flyable')"><a-button><UploadOutlined />{{ inputs.flyable?.name || "选择 Gzip" }}</a-button></a-upload></div>
    </div>
    <a-alert v-if="runError" type="error" show-icon :message="runError" />
    <a-button type="primary" :loading="running" @click="submitRun"><PlayCircleOutlined />开始轨迹分析</a-button>
  </section>
 
  <div v-if="currentCase" class="trajectory-workspace">
    <a-row :gutter="[18, 18]" class="workspace-top-row">
      <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 map-panel"><div class="section-toolbar"><span class="toolbar-note">{{ currentCase.note }}</span></div><TrajectoryMap :artifact-root="currentCase.artifactRoot" :show-spatial-context="currentCase.showSpatialContext" :show-flyable-zones="currentCase.showFlyableZones" /></section></a-col>
    </a-row>
 
    <a-row :gutter="[18, 18]" class="result-row">
      <a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="轨迹">{{ currentCase.run.track_count }} 条</a-descriptions-item><a-descriptions-item label="事件">{{ currentCase.run.event_count }} 个</a-descriptions-item><a-descriptions-item label="重复清洗">{{ currentCase.run.dropped_duplicate_observations }} 条</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item></a-descriptions><a-divider /><h3>默认规则</h3><a-descriptions size="small" :column="1"><a-descriptions-item label="停留速度">{{ currentRules.stop_speed_mps }} m/s</a-descriptions-item><a-descriptions-item label="偏航距离">{{ currentRules.route_deviation_m }} m</a-descriptions-item><a-descriptions-item label="聚集距离">{{ currentRules.gathering_radius_m }} m</a-descriptions-item></a-descriptions></section></a-col>
      <a-col :xs="24" :xl="8"><section class="surface-section"><div class="section-heading"><div><h2>事件记录</h2><p>{{ currentCase.events.length }} 个规则事件</p></div></div><a-empty v-if="!currentCase.events.length" description="当前案例未触发规则事件" /><a-timeline v-else><a-timeline-item v-for="event in currentCase.events" :key="event.event_id" :color="event.event_type === 'gathering' ? 'green' : event.event_type === 'restricted_zone' ? 'gold' : event.event_type === 'route_deviation' ? 'purple' : 'red'"><strong>{{ eventNames[event.event_type] || event.event_type }}</strong><p>{{ event.track_ids.join('、') }}</p><small>{{ new Date(event.start_time).toLocaleString('zh-CN', { hour12: false }) }} · {{ event.duration_seconds }} 秒</small></a-timeline-item></a-timeline></section></a-col>
      <a-col :xs="24" :xl="8"><section class="surface-section"><div class="section-heading"><div><h2>轨迹汇总</h2><p>行为标签以中文展示;英文值仍保留在结构化输出中。</p></div></div><a-table :data-source="currentCase.summary" :pagination="false" row-key="track_id" size="small" :scroll="{ x: 720 }"><a-table-column title="轨迹" data-index="track_id" key="track_id" /><a-table-column title="点数" data-index="point_count" key="point_count" align="right" /><a-table-column title="距离" key="distance" align="right"><template #default="{ record }">{{ Number(record.distance_m).toFixed(1) }} m</template></a-table-column><a-table-column title="行为标签" key="behavior_labels"><template #default="{ record }"><a-space wrap><a-tag v-for="tag in behaviorTags(record.behavior_labels)" :key="tag.raw" :color="tag.raw === 'normal' ? 'green' : 'gold'">{{ tag.label }}</a-tag></a-space></template></a-table-column></a-table></section></a-col>
    </a-row>
 
    <section class="surface-section result-files"><a-space wrap><a-button v-for="file in outputFiles" :key="file" :href="artifactUrl(`${currentCase.artifactRoot}/${file}`)" target="_blank"><DownloadOutlined />{{ file }}</a-button></a-space></section>
  </div>
</template>