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/TrajectoryAnalysisPanel.vue |  104 ++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 92 insertions(+), 12 deletions(-)

diff --git a/apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue b/apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
index d61dff2..957164e 100644
--- a/apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
+++ b/apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
@@ -1,34 +1,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 showRunForm = ref(false); const running = ref(false); const runError = ref<string | null>(null);
+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] ?? ""; });
+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,.json" :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,.gz" :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>
-  <template v-if="currentCase"><a-row :gutter="[18, 18]"><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"><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="7"><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="9"><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="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>
-  </template>
+  </div>
 </template>

--
Gitblit v1.9.3