<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>
|