feat: 补全无依赖PF01-PF15功能闭环

This commit is contained in:
2026-08-08 11:35:42 +08:00
parent 604ea65024
commit d15c908aef
13 changed files with 2775 additions and 19 deletions
@@ -181,3 +181,51 @@ frontend/src/components/gis/OperationalGisMap.vue
## 8. 后续实施入口
下一批可进入 B 类功能:PF-01、PF-02、PF-05、PF-08、PF-09、PF-10、PF-11、PF-15。开发可以使用明确标记的默认配置和 DEMO 数据,但生产发布仍需相应主数据、规则或设备参数。
## 9. B 类无依赖补全续实施记录
> 更新日期:2026-08-01
> 本次范围:PF-01、PF-02、PF-05、PF-08、PF-09、PF-10、PF-11、PF-15 的软件闭环。生产关闭仍按设计文档要求等待权威主数据、设备矩阵、空间规则和现场参数。
### 9.1 新增数据库迁移
`V21__dependency_free_pf01_pf15.sql` 新增和扩展:
- PF-01:对象导入映射、导入任务、导入行、对象不可变版本。
- PF-02:业务日历、计划执行记录、应急授权,以及计划调度时区、补跑策略、执行时间字段。
- PF-05:作业资源、资质、不可用窗口、资源预占、冲突检查记录。
- PF-08:GIS 数据集版本、图层、空间要素。
- PF-09:五类候选航线生成器配置和生成运行记录。
- PF-10:航线编辑修订、通用动作 Schema、航线编辑锁字段。
- PF-11:航线估算配置、估算运行记录和航线版本估算结果。
- PF-15:空间规则集/版本、空间基线要素、豁免区、地理围栏、规则命中解释。
迁移同时写入 DEMO/REFERENCE 种子配置,并将 PF-01、02、05、08、09、10、11、15 登记到能力追踪表,阻塞原因明确标记为生产资料待到位。
### 9.2 后端接口
新增 `DependencyFreeCompletionService``PlanSchedulerJob` 和对应 `/api/v1` 端点,覆盖:
- 对象导入:`/inspection/object-imports` 创建、预检、确认、回滚、行结果与对象版本查询。
- 计划调度:`/business-calendars``/inspection/plans/{id}/preview-runs``/inspection/plans/{id}/executions``/inspection/plans/run-due`,后台作业每 30 秒扫描到期周期计划。
- 资源冲突:`/operation-resources/sync``/operation-resources/calendar``/inspection/tasks/{id}/conflict-checks`、资源预占和取消。
- GIS 图层:`/gis/layers``/gis/dataset-versions`、图层发布和图层要素查询。
- 航线生成/编辑/估算:`/route-generation-profiles``/routes/generate``/route-action-schemas``/route-versions/{id}/waypoints``/route-versions/{id}/estimate`
- 空间规则:`/spatial-rule-sets``/spatial-exemptions``/geofences``/spatial-rule-hits/explain`
### 9.3 前端落点
- 巡检计划页新增对象导入中心、导入确认/回滚、对象版本抽屉、计划执行预览、执行记录和到期计划触发。
- 航线中心新增自动生成候选航线、生成器参数、航线估算、编辑修订保存、动作 Schema 和编辑审计查看。
- GIS 态势页新增图层、规则版本、豁免区、地理围栏面板,并支持对选中点位执行空间命中解释。
- 无人机运行页新增资源日历,支持同步作业资源、任务冲突检查和资源预占。
### 9.4 验证记录
| 验证项 | 结果 |
|---|---|
| Vue TypeScript 检查与 Vite 构建 | 通过 |
| Java 局部编译 | 通过,使用仓库 `.m2-repository` 依赖缓存和 `javac -proc:none` |
| Maven 全量测试 | 当前环境未提供 `mvn` 或 Maven Wrapper,未执行 |
Vite 仍保留既有大分块提示和第三方 PURE 注释提示,不影响功能正确性。
+165
View File
@@ -949,3 +949,168 @@ export async function createGisExport(payload: Record<string, unknown>) {
export function gisExportContentUrl(exportId: string) {
return `/api/v1/gis/exports/${exportId}/content`;
}
export async function objectMappingProfiles() {
const { data } = await client.get("/inspection/object-mapping-profiles");
return data.data.profiles;
}
export async function objectImports() {
const { data } = await client.get("/inspection/object-imports");
return data.data.imports;
}
export async function createObjectImport(payload: Record<string, unknown>) {
const { data } = await client.post("/inspection/object-imports", payload);
return data.data;
}
export async function commitObjectImport(importJobId: string) {
const { data } = await client.post(`/inspection/object-imports/${importJobId}/commit`, {}, { params: { confirmedBy: "user-dispatcher" } });
return data.data;
}
export async function rollbackObjectImport(importJobId: string) {
const { data } = await client.post(`/inspection/object-imports/${importJobId}/rollback`, {}, { params: { requestedBy: "user-dispatcher" } });
return data.data;
}
export async function objectVersions(objectId: string) {
const { data } = await client.get(`/inspection/objects/${objectId}/versions`);
return data.data.versions;
}
export async function businessCalendars() {
const { data } = await client.get("/business-calendars");
return data.data.calendars;
}
export async function previewPlanRuns(planId: string, count = 6) {
const { data } = await client.post(`/inspection/plans/${planId}/preview-runs`, { count });
return data.data.runs;
}
export async function planExecutions(planId: string) {
const { data } = await client.get(`/inspection/plans/${planId}/executions`);
return data.data.executions;
}
export async function runDueInspectionPlans(limit = 20) {
const { data } = await client.post("/inspection/plans/run-due", { limit });
return data.data;
}
export async function syncOperationResources() {
const { data } = await client.post("/operation-resources/sync", {});
return data.data;
}
export async function operationResourceCalendar(params: Record<string, unknown> = {}) {
const { data } = await client.get("/operation-resources/calendar", { params });
return data.data;
}
export async function checkTaskConflicts(taskId: string, payload: Record<string, unknown> = {}) {
const { data } = await client.post(`/inspection/tasks/${taskId}/conflict-checks`, payload);
return data.data;
}
export async function reserveTaskResources(taskId: string, payload: Record<string, unknown>) {
const { data } = await client.post(`/inspection/tasks/${taskId}/reservations`, payload);
return data.data;
}
export async function cancelTaskReservation(taskId: string, reservationId: string) {
const { data } = await client.delete(`/inspection/tasks/${taskId}/reservations/${reservationId}`);
return data.data;
}
export async function gisLayers() {
const { data } = await client.get("/gis/layers");
return data.data.layers;
}
export async function gisDatasetVersions() {
const { data } = await client.get("/gis/dataset-versions");
return data.data.dataset_versions;
}
export async function gisLayerFeatures(layerCode: string) {
const { data } = await client.get(`/gis/layers/${layerCode}/features`);
return data.data;
}
export async function routeGenerationProfiles() {
const { data } = await client.get("/route-generation-profiles");
return data.data.profiles;
}
export async function generateCandidateRoute(payload: Record<string, unknown>) {
const { data } = await client.post("/routes/generate", payload);
return data.data;
}
export async function routeActionSchemas() {
const { data } = await client.get("/route-action-schemas");
return data.data.schemas;
}
export async function updateRouteWaypoints(versionId: string, payload: Record<string, unknown>) {
const { data } = await client.put(`/route-versions/${versionId}/waypoints`, payload);
return data.data;
}
export async function routeEditorRevisions(versionId: string) {
const { data } = await client.get(`/route-versions/${versionId}/editor-revisions`);
return data.data.revisions;
}
export async function routeEstimationProfiles() {
const { data } = await client.get("/route-estimation-profiles");
return data.data.profiles;
}
export async function estimateRouteVersion(versionId: string, payload: Record<string, unknown> = {}) {
const { data } = await client.post(`/route-versions/${versionId}/estimate`, payload);
return data.data;
}
export async function routeEstimations(versionId: string) {
const { data } = await client.get(`/route-versions/${versionId}/estimations`);
return data.data.estimations;
}
export async function spatialRuleSets() {
const { data } = await client.get("/spatial-rule-sets");
return data.data.rule_sets;
}
export async function spatialExemptions() {
const { data } = await client.get("/spatial-exemptions");
return data.data.exemptions;
}
export async function createSpatialExemption(payload: Record<string, unknown>) {
const { data } = await client.post("/spatial-exemptions", payload);
return data.data;
}
export async function approveSpatialExemption(exemptionId: string) {
const { data } = await client.post(`/spatial-exemptions/${exemptionId}/approve`, {}, { params: { approvedBy: "user-approver" } });
return data.data;
}
export async function geofences() {
const { data } = await client.get("/geofences");
return data.data.geofences;
}
export async function createGeofence(payload: Record<string, unknown>) {
const { data } = await client.post("/geofences", payload);
return data.data;
}
export async function explainSpatialRuleHit(payload: Record<string, unknown>) {
const { data } = await client.post("/spatial-rule-hits/explain", payload);
return data.data;
}
+34 -2
View File
@@ -4,6 +4,7 @@
<el-button :icon="Refresh" :loading="loading" @click="load">刷新图层</el-button>
<el-button :disabled="!selectedTaskId" @click="loadPlayback">任务回放</el-button>
<el-button :disabled="!selectedTaskId" @click="loadHeatmap">生成热力</el-button>
<el-button :disabled="!selected" @click="explainSelected">空间解释</el-button>
<el-button type="primary" :disabled="!selectedTaskId" @click="exportThematicMap">导出专题图</el-button>
</PageHeader>
@@ -55,6 +56,17 @@
</el-timeline-item>
</el-timeline>
</el-card>
<el-card class="workspace-card spatial-card" shadow="never">
<div class="subsection-head"><div><strong>图层与空间规则</strong><span>PF-08 / PF-15 的图层规则豁免和围栏版本</span></div><el-tag type="warning" effect="plain">生产数据待批准</el-tag></div>
<el-tabs v-model="spatialTab" class="secondary-tabs">
<el-tab-pane label="GIS 图层" name="layers"><el-table :data="gisLayerRows" size="small" empty-text="暂无图层"><el-table-column prop="name" label="图层" min-width="160" /><el-table-column prop="layer_type" label="类型" width="100" /><el-table-column prop="source_type" label="来源" width="100" /><el-table-column prop="quality_status" label="质量" width="100" /><el-table-column prop="status" label="状态" width="100" /></el-table></el-tab-pane>
<el-tab-pane label="规则版本" name="rules"><el-table :data="ruleSetRows" size="small" empty-text="暂无规则"><el-table-column prop="name" label="规则集" min-width="180" /><el-table-column prop="rule_type" label="类型" width="150" /><el-table-column label="版本" width="80"><template #default="scope">V{{ scope.row.version_no || '-' }}</template></el-table-column><el-table-column prop="version_status" label="状态" width="110" /><el-table-column label="规则数" width="95"><template #default="scope">{{ parseArray(scope.row.rules).length }}</template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="豁免区" name="exemptions"><el-table :data="exemptionRows" size="small" empty-text="暂无豁免"><el-table-column prop="reason" label="原因" min-width="180" /><el-table-column prop="exemption_type" label="类型" width="150" /><el-table-column prop="approval_status" label="审批" width="100" /><el-table-column label="有效期" min-width="220"><template #default="scope">{{ formatDate(scope.row.effective_from) }} - {{ formatDate(scope.row.effective_to) }}</template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="地理围栏" name="geofences"><el-table :data="geofenceRows" size="small" empty-text="暂无围栏"><el-table-column prop="name" label="围栏" min-width="180" /><el-table-column prop="fence_type" label="类型" width="120" /><el-table-column prop="severity" label="等级" width="100" /><el-table-column label="高度" width="120"><template #default="scope">{{ scope.row.altitude_min_m ?? '-' }} - {{ scope.row.altitude_max_m ?? '-' }}m</template></el-table-column><el-table-column prop="status" label="状态" width="100" /></el-table></el-tab-pane>
</el-tabs>
<el-alert v-if="spatialExplanation" class="spatial-explanation" type="info" :closable="false" show-icon :title="`空间命中解释:${spatialExplanation.decision}`" :description="explanationText" />
</el-card>
</div>
</template>
@@ -65,15 +77,17 @@ import { ElMessage } from "element-plus";
import { Close, Refresh } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import OperationalGisMap from "../../components/gis/OperationalGisMap.vue";
import { alarms, createGisExport, gisExportContentUrl, gisFeatures, gisHeatmap, gisPlaybackEvents, gisPlaybackManifest, gisPlaybackTrack, tasks, workorders } from "../../services/api";
import { alarms, createGisExport, explainSpatialRuleHit, geofences, gisExportContentUrl, gisFeatures, gisHeatmap, gisLayers, gisPlaybackEvents, gisPlaybackManifest, gisPlaybackTrack, spatialExemptions, spatialRuleSets, tasks, workorders } from "../../services/api";
import { safeObject, severityLabel, statusLabel, type Row } from "../../types/demo-run";
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const alarmRows = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const workorderRows = ref<Row[]>([]);
const gisLayerRows = ref<Row[]>([]); const ruleSetRows = ref<Row[]>([]); const exemptionRows = ref<Row[]>([]); const geofenceRows = ref<Row[]>([]);
const featureCollection = ref<Row>({ type: "FeatureCollection", features: [] });
const selected = ref<Row | null>(null); const lineFilter = ref(""); const severityFilter = ref("");
const spatialTab = ref("layers"); const spatialExplanation = ref<Row | null>(null);
const selectedTaskId = ref(String(route.query.taskId || ""));
const exportFormat = ref("PDF");
const playbackManifest = ref<Row | null>(null); const playbackTrack = ref<Row | null>(null); const playbackEvents = ref<Row[]>([]); const heatmapFeatures = ref<Row[]>([]); const playbackIndex = ref(0);
@@ -119,9 +133,16 @@ const playbackDeviceFeature = computed<Row | null>(() => {
};
});
const visiblePlaybackEvents = computed(() => playbackEvents.value.slice(Math.max(0, playbackIndex.value - 4), playbackIndex.value + 1));
const explanationText = computed(() => {
if (!spatialExplanation.value) return "";
const geofenceCount = Array.isArray(spatialExplanation.value.geofence_hits) ? spatialExplanation.value.geofence_hits.length : 0;
const exemptionCount = Array.isArray(spatialExplanation.value.exemption_hits) ? spatialExplanation.value.exemption_hits.length : 0;
return `围栏 ${geofenceCount} 项,豁免 ${exemptionCount} 项,规则版本 ${spatialExplanation.value.rule_version_id || "-"}`;
});
function taskLine(taskId: unknown) { return String(taskRows.value.find((row) => String(row.task_id) === String(taskId))?.line_id || "line-demo"); }
function relatedWorkorder(alarmId: unknown) { return workorderRows.value.find((row) => String(row.alarm_id) === String(alarmId)); }
function locationText(row: Row) { const location = safeObject(row.location); return [location.mileage, location.distance_to_track_m ? `距线路 ${location.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; }
function parseArray(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
function selectFeature(properties: Row) { selected.value = alarmRows.value.find((row) => String(row.alarm_id) === String(properties.alarm_id)) || properties; }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
async function loadPlayback() {
@@ -169,7 +190,16 @@ async function exportThematicMap() {
window.open(gisExportContentUrl(String(result.export_job_id)), "_blank", "noopener,noreferrer");
ElMessage.success(`${exportFormat.value} 专题成果已生成,摘要 ${String(result.checksum).slice(0, 16)}`);
}
async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value] = await Promise.all([alarms(), tasks(), workorders(), gisFeatures()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); selected.value = filteredAlarms.value[0] || null; } finally { loading.value = false; } }
async function explainSelected() {
if (!selected.value) return;
const geometry = safeObject(selected.value.location).geometry || selected.value.geometry;
if (!geometry) return ElMessage.warning("当前点位没有可解释空间几何");
const result = await explainSpatialRuleHit({ task_id: selected.value.task_id, geometry, scene: selected.value.scene, object_type: selected.value.category });
spatialExplanation.value = result;
spatialTab.value = "rules";
ElMessage.success(`空间解释完成:${result.decision}`);
}
async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value, gisLayerRows.value, ruleSetRows.value, exemptionRows.value, geofenceRows.value] = await Promise.all([alarms(), tasks(), workorders(), gisFeatures(), gisLayers(), spatialRuleSets(), spatialExemptions(), geofences()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); selected.value = filteredAlarms.value[0] || null; } finally { loading.value = false; } }
onMounted(load);
onBeforeUnmount(pausePlayback);
</script>
@@ -185,4 +215,6 @@ onBeforeUnmount(pausePlayback);
.playback-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; color: var(--el-text-color-secondary); }
.speed-select { width: 88px; }
.export-format { width: 150px; }
.spatial-card { margin-top: 18px; }
.spatial-explanation { margin-top: 14px; }
</style>
+49 -7
View File
@@ -2,6 +2,7 @@
<div v-loading="loading">
<PageHeader title="巡检计划" description="管理周期计划、巡检对象、任务生成和执行准备">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button @click="runDuePlans">执行到期计划</el-button>
<el-button type="primary" :icon="Plus" @click="openPlanDialog()">新建计划</el-button>
</PageHeader>
@@ -32,7 +33,7 @@
</el-tab-pane>
<el-tab-pane label="巡检对象台账" name="objects">
<div class="filter-bar"><el-input v-model="keyword" clearable placeholder="对象、线路或里程" :prefix-icon="Search" /><el-select v-model="objectType"><el-option label="全部类型" value="" /><el-option v-for="item in objectTypes" :key="item.value" :label="item.label" :value="item.value" /></el-select><span class="filter-spacer"></span><el-button type="primary" plain :icon="Plus" @click="objectDialog = true">新增对象</el-button></div>
<div class="filter-bar"><el-input v-model="keyword" clearable placeholder="对象、线路或里程" :prefix-icon="Search" /><el-select v-model="objectType"><el-option label="全部类型" value="" /><el-option v-for="item in objectTypes" :key="item.value" :label="item.label" :value="item.value" /></el-select><span class="filter-spacer"></span><el-button plain @click="activeTab = 'imports'">导入中心</el-button><el-button type="primary" plain :icon="Plus" @click="objectDialog = true">新增对象</el-button></div>
<el-card class="workspace-card" shadow="never"><el-table :data="pagedObjects" empty-text="暂无巡检对象">
<el-table-column prop="name" label="对象" min-width="180"><template #default="scope"><strong>{{ scope.row.name }}</strong><small class="cell-subtext entity-id">{{ scope.row.object_id }}</small></template></el-table-column>
<el-table-column label="类型" width="100"><template #default="scope">{{ objectTypeLabel(scope.row.object_type) }}</template></el-table-column>
@@ -42,8 +43,31 @@
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="['CRITICAL','HIGH'].includes(scope.row.risk_level) ? 'danger' : 'info'" effect="plain">{{ scope.row.risk_level }}</el-tag></template></el-table-column>
<el-table-column label="周期" width="90"><template #default="scope">{{ scope.row.inspection_cycle_days ? `${scope.row.inspection_cycle_days}` : '-' }}</template></el-table-column>
<el-table-column prop="source_crs" label="坐标系" width="105" />
<el-table-column label="操作" width="90" fixed="right"><template #default="scope"><el-button link type="primary" @click="showObjectVersions(scope.row)">版本</el-button></template></el-table-column>
</el-table><el-pagination v-model:current-page="objectPage" v-model:page-size="objectPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="filteredObjects.length" /></el-card>
</el-tab-pane>
<el-tab-pane label="对象导入中心" name="imports">
<section class="metric-strip">
<div class="metric-card"><strong>{{ importJobs.length }}</strong><span>导入任务</span><small>预检确认与回滚</small></div>
<div class="metric-card"><strong>{{ readyImportCount }}</strong><span>可确认</span><small>无错误导入批次</small></div>
<div class="metric-card"><strong>{{ importRowsTotal }}</strong><span>导入行</span><small>当前任务合计</small></div>
<div class="metric-card"><strong>{{ calendars.length }}</strong><span>业务日历</span><small>周期计划引用</small></div>
</section>
<el-card class="workspace-card" shadow="never">
<div class="subsection-head"><div><strong>快速导入</strong><span>JSON 行数组生产数据导入前保持 DEMO 标记</span></div><el-button type="primary" :loading="saving" @click="createDemoImport">预检导入</el-button></div>
<el-input v-model="objectImportText" type="textarea" :rows="8" spellcheck="false" />
</el-card>
<el-card class="workspace-card" shadow="never">
<el-table :data="importJobs" empty-text="暂无导入任务">
<el-table-column prop="file_name" label="文件/批次" min-width="180"><template #default="scope"><strong>{{ scope.row.file_name }}</strong><small class="cell-subtext entity-id">{{ scope.row.import_job_id }}</small></template></el-table-column>
<el-table-column label="状态" width="145"><template #default="scope"><el-tag :type="importStatusTag(scope.row.status)" effect="plain">{{ importStatusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column label="统计" min-width="180"><template #default="scope">有效 {{ scope.row.valid_count }} / 错误 {{ scope.row.invalid_count }} / 警告 {{ scope.row.warning_count }}</template></el-table-column>
<el-table-column label="质量报告" min-width="210"><template #default="scope">{{ importReport(scope.row.quality_report) }}</template></el-table-column>
<el-table-column label="操作" width="170" fixed="right"><template #default="scope"><div class="list-actions"><el-button link type="primary" :disabled="scope.row.status !== 'READY'" @click="commitImport(scope.row)">确认入库</el-button><el-button link type="warning" :disabled="scope.row.status !== 'COMMITTED'" @click="rollbackImport(scope.row)">回滚</el-button></div></template></el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="planDialog" class="plan-create-dialog" :title="planTrigger === 'MANUAL' ? '发起手动巡检' : '新建周期巡检计划'" width="min(780px, 96vw)" destroy-on-close :close-on-click-modal="false">
@@ -60,7 +84,11 @@
<template #footer><el-button @click="objectDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveObject">创建</el-button></template>
</el-dialog>
<el-drawer v-model="detailVisible" title="计划详情" size="560px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="巡检方式">{{ triggerTypeLabel(selectedPlan.trigger_type) }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="执行安排">{{ scheduleText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="下次/计划执行">{{ executionDateText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="优先级">{{ priorityLabel(selectedPlan.priority) }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions></template></el-drawer>
<el-drawer v-model="detailVisible" title="计划详情" size="720px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="巡检方式">{{ triggerTypeLabel(selectedPlan.trigger_type) }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="执行安排">{{ scheduleText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="下次/计划执行">{{ executionDateText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="优先级">{{ priorityLabel(selectedPlan.priority) }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions><div class="subsection-head drawer-section"><div><strong>执行预览</strong><span>使用当前调度配置计算后续窗口</span></div><el-button link @click="loadPlanRuntime(selectedPlan)">刷新</el-button></div><el-table :data="previewRunsRows" size="small" empty-text="暂无预览"><el-table-column prop="sequence" label="#" width="52" /><el-table-column label="窗口开始" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="窗口结束" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_end) }}</template></el-table-column><el-table-column label="生成键" min-width="170"><template #default="scope"><span class="entity-id">{{ String(scope.row.generation_key).slice(0, 18) }}</span></template></el-table-column></el-table><div class="subsection-head drawer-section"><div><strong>执行记录</strong><span>后台调度和手动触发均留痕</span></div></div><el-table :data="executionRows" size="small" empty-text="暂无执行记录"><el-table-column prop="status" label="状态" width="110" /><el-table-column label="窗口" min-width="185"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="任务" min-width="120"><template #default="scope">{{ parseArray(scope.row.generated_task_ids).length }} 个</template></el-table-column><el-table-column prop="trigger_source" label="来源" width="110" /></el-table></template></el-drawer>
<el-drawer v-model="versionVisible" title="对象版本" size="620px">
<template v-if="selectedObject"><h3>{{ selectedObject.name }}</h3><p class="entity-id">{{ selectedObject.object_id }}</p><el-table :data="objectVersionRows" size="small" empty-text="暂无版本记录"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="change_type" label="变更" width="95" /><el-table-column prop="change_reason" label="原因" min-width="140" /><el-table-column label="来源" min-width="150"><template #default="scope"><span class="entity-id">{{ scope.row.source_job_id || '-' }}</span></template></el-table-column><el-table-column label="时间" min-width="155"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column></el-table></template>
</el-drawer>
</div>
</template>
@@ -72,16 +100,21 @@ import { Plus, Refresh, Search } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import InspectionPlanForm from "../../components/planning/InspectionPlanForm.vue";
import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination";
import { activateInspectionPlan, createInspectionObject, createInspectionPlan, generatePlanTasks, inspectionObjects, inspectionPlans } from "../../services/api";
import { activateInspectionPlan, businessCalendars, commitObjectImport, createInspectionObject, createInspectionPlan, createObjectImport, generatePlanTasks, inspectionObjects, inspectionPlans, objectImports, objectVersions, planExecutions, previewPlanRuns, rollbackObjectImport, runDueInspectionPlans } from "../../services/api";
import { safeObject, type Row } from "../../types/demo-run";
const route = useRoute(); const router = useRouter();
const plans = ref<Row[]>([]); const objects = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("plans"); const keyword = ref(""); const objectType = ref("");
const planDialog = ref(false); const planFormKey = ref(0); const planTrigger = ref<"PERIODIC" | "MANUAL">("PERIODIC"); const objectDialog = ref(false); const detailVisible = ref(false); const selectedPlan = ref<Row | null>(null);
const plans = ref<Row[]>([]); const objects = ref<Row[]>([]); const importJobs = ref<Row[]>([]); const calendars = ref<Row[]>([]); const previewRunsRows = ref<Row[]>([]); const executionRows = ref<Row[]>([]); const objectVersionRows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref("plans"); const keyword = ref(""); const objectType = ref("");
const planDialog = ref(false); const planFormKey = ref(0); const planTrigger = ref<"PERIODIC" | "MANUAL">("PERIODIC"); const objectDialog = ref(false); const detailVisible = ref(false); const versionVisible = ref(false); const selectedPlan = ref<Row | null>(null); const selectedObject = ref<Row | null>(null);
const objectTypes = [{ value: "RAILWAY", label: "铁路线路" }, { value: "BRIDGE", label: "桥梁" }, { value: "TUNNEL", label: "隧道" }, { value: "FLOOD", label: "防洪设施" }, { value: "POWER", label: "电力设施" }];
const scenes = ["异物侵限", "护网破损", "桥梁裂缝", "隧道渗漏", "防洪体积变化", "接触网异物", "设备发热"];
const objectForm = reactive({ name: "", object_type: "RAILWAY", line_id: "line-demo", mileage_start: "", mileage_end: "", risk_level: "NORMAL", longitude: 116.1, latitude: 39.1 });
const objectImportText = ref(JSON.stringify([
{ source_key: "demo-import-bridge-001", object_type: "BRIDGE", name: "导入桥梁样例", line_id: "line-demo", mileage_start: "K126+950", mileage_end: "K127+120", owner_org_id: "org-works", risk_level: "HIGH", longitude: 116.132, latitude: 39.116 },
{ source_key: "demo-import-power-001", object_type: "POWER", name: "导入接触网样例", line_id: "line-demo", mileage_start: "K128+500", mileage_end: "K128+620", owner_org_id: "org-power", risk_level: "NORMAL", longitude: 116.154, latitude: 39.107 }
], null, 2));
const periodicPlans = computed(() => plans.value.filter((item) => item.trigger_type !== "MANUAL").length); const manualPlans = computed(() => plans.value.filter((item) => item.trigger_type === "MANUAL").length); const highRiskObjects = computed(() => objects.value.filter((item) => ["HIGH", "CRITICAL"].includes(item.risk_level)).length); const generatedTasks = computed(() => plans.value.reduce((sum, item) => sum + Number(item.generated_task_count || 0), 0));
const readyImportCount = computed(() => importJobs.value.filter((item) => item.status === "READY").length); const importRowsTotal = computed(() => importJobs.value.reduce((sum, item) => sum + Number(item.total_count || 0), 0));
const filteredObjects = computed(() => objects.value.filter((item) => (!objectType.value || item.object_type === objectType.value) && (!keyword.value || `${item.name} ${item.line_id} ${item.mileage_start}`.toLowerCase().includes(keyword.value.toLowerCase()))));
const { currentPage: planPage, pageSize: planPageSize, pagedItems: pagedPlans } = usePagination(plans);
const { currentPage: objectPage, pageSize: objectPageSize, pagedItems: pagedObjects, resetPage: resetObjectPage } = usePagination(filteredObjects);
@@ -107,13 +140,22 @@ function parseNumberArray(value: unknown): number[] { return Array.isArray(value
function planStatusLabel(row: Row) { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "任务已生成" : "待创建任务"; return row.status === "ACTIVE" ? "已启用" : "草稿"; }
function planStatusTag(row: Row): "success" | "warning" | "info" { if (row.trigger_type === "MANUAL") return Number(row.generated_task_count) ? "success" : "warning"; return row.status === "ACTIVE" ? "success" : "info"; }
function objectNames(value: unknown) { const ids = parseArray(value); return ids.map((id) => objects.value.find((item) => item.object_id === id)?.name || id).join("、"); }
async function load() { loading.value = true; try { [plans.value, objects.value] = await Promise.all([inspectionPlans(), inspectionObjects()]); } finally { loading.value = false; } }
function importStatusLabel(value: unknown) { return ({ READY: "可确认", VALIDATED_WITH_ERRORS: "有错误", COMMITTED: "已入库", ROLLED_BACK: "已回滚", CREATED: "已创建" } as Record<string, string>)[String(value)] || String(value || "-"); }
function importStatusTag(value: unknown): "success" | "warning" | "danger" | "info" { if (value === "READY") return "success"; if (value === "VALIDATED_WITH_ERRORS") return "danger"; if (value === "COMMITTED") return "warning"; return "info"; }
function importReport(value: unknown) { const data = safeObject(value); return String(data.production_gate || data.software_scope || "-"); }
async function load() { loading.value = true; try { [plans.value, objects.value, importJobs.value, calendars.value] = await Promise.all([inspectionPlans(), inspectionObjects(), objectImports(), businessCalendars()]); } finally { loading.value = false; } }
function openPlanDialog(trigger: "PERIODIC" | "MANUAL" = "PERIODIC") { planTrigger.value = trigger; planFormKey.value += 1; planDialog.value = true; }
async function savePlan(request: { payload: Record<string, unknown>; generateTasks: boolean }) { saving.value = true; try { const created = await createInspectionPlan(request.payload); if (request.generateTasks) { const result = await generatePlanTasks(String(created.plan_id)); ElMessage.success(`手动巡检已创建 ${result.generated} 条任务`); } else { ElMessage.success("周期巡检计划已创建,请确认后启用"); } planDialog.value = false; await load(); } finally { saving.value = false; } }
async function saveObject() { if (!objectForm.name || !objectForm.line_id) return ElMessage.warning("请填写对象名称和线路"); saving.value = true; try { await createInspectionObject({ ...objectForm, geometry: { type: "Point", coordinates: [objectForm.longitude, objectForm.latitude] }, owner_org_id: objectForm.object_type === "FLOOD" ? "org-flood" : "org-works", inspection_cycle_days: 7, source_crs: "EPSG:4326" }); objectDialog.value = false; ElMessage.success("巡检对象已创建"); await load(); } finally { saving.value = false; } }
async function activate(row: Row) { await activateInspectionPlan(String(row.plan_id)); ElMessage.success("计划已启用"); await load(); }
async function generate(row: Row) { const result = await generatePlanTasks(String(row.plan_id)); ElMessage.success(`已生成 ${result.generated} 个巡检任务`); await load(); }
function inspectPlan(row: Row) { selectedPlan.value = row; detailVisible.value = true; }
async function runDuePlans() { const result = await runDueInspectionPlans(); ElMessage.success(`调度扫描 ${result.scanned} 个到期计划`); await load(); }
async function createDemoImport() { let rows: Row[] = []; try { rows = JSON.parse(objectImportText.value); } catch { return ElMessage.warning("请输入合法 JSON 行数组"); } if (!Array.isArray(rows) || rows.length === 0) return ElMessage.warning("导入内容不能为空"); saving.value = true; try { const result = await createObjectImport({ file_name: `demo-object-import-${Date.now()}.json`, file_type: "JSON_ROWS", mapping_profile_id: "object-mapping-demo-line", declared_crs: "EPSG:4326", rows, created_by: "user-dispatcher" }); ElMessage[result.status === "READY" ? "success" : "warning"](`预检完成:有效 ${result.valid_count},错误 ${result.invalid_count}`); await load(); } finally { saving.value = false; } }
async function commitImport(row: Row) { const result = await commitObjectImport(String(row.import_job_id)); ElMessage.success(`已入库 ${result.object_ids?.length || 0} 个对象`); await load(); }
async function rollbackImport(row: Row) { const result = await rollbackObjectImport(String(row.import_job_id)); ElMessage.success(`已回滚 ${result.rolled_back_objects || 0} 个对象`); await load(); }
async function loadPlanRuntime(row: Row) { [previewRunsRows.value, executionRows.value] = await Promise.all([previewPlanRuns(String(row.plan_id)), planExecutions(String(row.plan_id))]); }
async function inspectPlan(row: Row) { selectedPlan.value = row; detailVisible.value = true; await loadPlanRuntime(row); }
async function showObjectVersions(row: Row) { selectedObject.value = row; objectVersionRows.value = await objectVersions(String(row.object_id)); versionVisible.value = true; }
watch([keyword, objectType], resetObjectPage);
onMounted(async () => {
await load();
+35 -6
View File
@@ -2,6 +2,7 @@
<div v-loading="loading">
<PageHeader title="航线中心" description="管理厂商无关航线、航点参数、校验结果和发布版本">
<el-button :icon="Refresh" @click="load">刷新</el-button>
<el-button @click="generateDialog = true">自动生成</el-button>
<el-button type="primary" :icon="Plus" @click="createDialog = true">新建航线</el-button>
</PageHeader>
@@ -21,9 +22,23 @@
<el-table-column label="航点" width="85"><template #default="scope">{{ parseWaypoints(scope.row.waypoints).length }}</template></el-table-column>
<el-table-column label="校验" width="110"><template #default="scope"><el-tag :type="validation(scope.row).valid ? 'success' : 'danger'" effect="plain">{{ validation(scope.row).valid ? '通过' : '未通过' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="125"><template #default="scope"><el-tag :type="scope.row.status === 'PUBLISHED' ? 'success' : scope.row.status === 'APPROVED' ? 'warning' : 'info'" effect="plain">{{ routeStatusLabel(scope.row.status) }}</el-tag><small class="cell-subtext">{{ approvalLabel(scope.row.approval_status) }}</small></template></el-table-column>
<el-table-column label="操作" width="330" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="validateRow(scope.row)">校验</el-button><el-button v-if="['DRAFT','REJECTED'].includes(String(scope.row.approval_status || 'DRAFT'))" link type="warning" @click="submitRow(scope.row)">提交审批</el-button><el-button v-if="scope.row.status === 'APPROVED'" link type="success" @click="publishRow(scope.row)">发布</el-button><el-button link @click="newVersion(scope.row)">新版本</el-button><el-button link @click="inspect(scope.row)">详情</el-button></div></template></el-table-column>
<el-table-column label="估算" width="120"><template #default="scope">{{ estimateSummary(scope.row) }}</template></el-table-column>
<el-table-column label="操作" width="385" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="validateRow(scope.row)">校验</el-button><el-button link type="primary" @click="estimateRow(scope.row)">估算</el-button><el-button v-if="['DRAFT','REJECTED'].includes(String(scope.row.approval_status || 'DRAFT'))" link type="warning" @click="submitRow(scope.row)">提交审批</el-button><el-button v-if="scope.row.status === 'APPROVED'" link type="success" @click="publishRow(scope.row)">发布</el-button><el-button link @click="newVersion(scope.row)">新版本</el-button><el-button link @click="inspect(scope.row)">详情</el-button></div></template></el-table-column>
</el-table><el-pagination v-model:current-page="page" v-model:page-size="pageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="filteredRows.length" /></el-card>
<el-dialog v-model="generateDialog" title="自动生成候选航线" width="min(700px, 94vw)" destroy-on-close>
<el-alert type="warning" :closable="false" show-icon title="当前生成器使用 DEMO/REFERENCE 参数,生成结果为候选草稿,现场使用前需要复核。" />
<el-form label-position="top" class="dialog-form"><div class="form-grid">
<el-form-item label="巡检对象"><el-select v-model="generateForm.object_id" filterable><el-option v-for="item in objects" :key="item.object_id" :label="`${item.name} / ${objectTypeLabel(item.object_type)}`" :value="item.object_id" /></el-select></el-form-item>
<el-form-item label="生成器"><el-select v-model="generateForm.generator_type"><el-option v-for="item in generatorOptions" :key="item.generator_type" :label="item.name" :value="item.generator_type" /></el-select></el-form-item>
<el-form-item label="航点数"><el-input-number v-model="generateForm.waypoint_count" :min="2" :max="12" /></el-form-item>
<el-form-item label="飞行高度"><el-input-number v-model="generateForm.altitude_m" :min="20" :max="300" /><span class="unit-label"></span></el-form-item>
<el-form-item label="速度"><el-input-number v-model="generateForm.speed_mps" :min="1" :max="20" /><span class="unit-label">/</span></el-form-item>
<el-form-item label="创建草稿"><el-switch v-model="generateForm.create_route" active-text="创建" inactive-text="仅预览" /></el-form-item>
</div></el-form>
<template #footer><el-button @click="generateDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="generateRouteFromObject">生成</el-button></template>
</el-dialog>
<el-dialog v-model="createDialog" title="新建航线" width="min(820px, 96vw)" destroy-on-close>
<el-form label-position="top"><div class="form-grid"><el-form-item label="航线名称"><el-input v-model="form.name" /></el-form-item><el-form-item label="巡检对象"><el-select v-model="form.object_id" filterable><el-option v-for="item in objects" :key="item.object_id" :label="item.name" :value="item.object_id" /></el-select></el-form-item><el-form-item label="飞行速度"><el-input-number v-model="form.speed_mps" :min="1" :max="20" /><span class="unit-label">米/秒</span></el-form-item><el-form-item label="返航高度"><el-input-number v-model="form.rth_altitude_m" :min="20" :max="500" /><span class="unit-label"></span></el-form-item></div></el-form>
<div class="subsection-head"><div><strong>航点参数</strong><span>WGS84 坐标高德底图仅在展示层转换</span></div><el-button :icon="Plus" @click="addWaypoint">增加航点</el-button></div>
@@ -31,7 +46,7 @@
<template #footer><el-button @click="createDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建并校验</el-button></template>
</el-dialog>
<el-drawer v-model="detailVisible" title="航线版本详情" size="720px"><template v-if="selected"><div class="route-visual"><div v-for="(point, index) in parseWaypoints(selected.waypoints)" :key="index" class="route-point" :style="pointStyle(point, index)"><span>{{ index + 1 }}</span></div><div class="route-axis"></div></div><el-descriptions :column="1" border><el-descriptions-item label="航线编号"><span class="entity-id">{{ selected.route_id }}</span></el-descriptions-item><el-descriptions-item label="工作版本">V{{ selected.version_no }} · {{ routeStatusLabel(selected.status) }} · {{ approvalLabel(selected.approval_status) }}</el-descriptions-item><el-descriptions-item label="巡检对象">{{ selected.object_name || '-' }}</el-descriptions-item><el-descriptions-item label="飞行参数">{{ jsonText(selected.flight_parameters) }}</el-descriptions-item><el-descriptions-item label="载荷动作">{{ jsonText(selected.payload_actions) }}</el-descriptions-item><el-descriptions-item label="SHA-256"><span class="entity-id">{{ selected.checksum || '-' }}</span></el-descriptions-item></el-descriptions><div class="validation-panel" :class="{ invalid: !validation(selected).valid }"><strong>{{ validation(selected).valid ? '航线校验通过' : '航线校验未通过' }}</strong><p v-for="message in [...(validation(selected).errors || []), ...(validation(selected).warnings || [])]" :key="message">{{ message }}</p><small v-if="!(validation(selected).errors?.length || validation(selected).warnings?.length)">航点数量、高度和速度均符合平台校验规则</small></div><div class="subsection-head version-head"><div><strong>不可变版本历史</strong><span>回滚将创建一个新草稿,不覆盖历史任务引用</span></div><el-button link @click="loadVersions(selected)">刷新</el-button></div><el-table :data="versionRows" size="small" empty-text="暂无版本"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="status" label="状态" width="105" /><el-table-column prop="approval_status" label="审批" width="120" /><el-table-column label="摘要" min-width="150"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 16) }}</span></template></el-table-column><el-table-column label="创建时间" min-width="155"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column><el-table-column label="操作" width="90"><template #default="scope"><el-button link type="warning" :disabled="scope.row.route_version_id === activeVersionId(selected)" @click="rollbackVersion(scope.row)">回滚</el-button></template></el-table-column></el-table></template></el-drawer>
<el-drawer v-model="detailVisible" title="航线版本详情" size="760px"><template v-if="selected"><div class="route-visual"><div v-for="(point, index) in parseWaypoints(selected.waypoints)" :key="index" class="route-point" :style="pointStyle(point, index)"><span>{{ index + 1 }}</span></div><div class="route-axis"></div></div><el-descriptions :column="1" border><el-descriptions-item label="航线编号"><span class="entity-id">{{ selected.route_id }}</span></el-descriptions-item><el-descriptions-item label="工作版本">V{{ selected.version_no }} · {{ routeStatusLabel(selected.status) }} · {{ approvalLabel(selected.approval_status) }}</el-descriptions-item><el-descriptions-item label="巡检对象">{{ selected.object_name || '-' }}</el-descriptions-item><el-descriptions-item label="飞行参数">{{ jsonText(selected.flight_parameters) }}</el-descriptions-item><el-descriptions-item label="载荷动作">{{ jsonText(selected.payload_actions) }}</el-descriptions-item><el-descriptions-item label="估算结果">{{ routeEstimateText(selected.estimation_result) }}</el-descriptions-item><el-descriptions-item label="编辑锁">V{{ selected.version_lock ?? 0 }} · {{ selected.edited_by || selected.created_by || '-' }}</el-descriptions-item><el-descriptions-item label="SHA-256"><span class="entity-id">{{ selected.checksum || '-' }}</span></el-descriptions-item></el-descriptions><div class="validation-panel" :class="{ invalid: !validation(selected).valid }"><strong>{{ validation(selected).valid ? '航线校验通过' : '航线校验未通过' }}</strong><p v-for="message in [...(validation(selected).errors || []), ...(validation(selected).warnings || [])]" :key="message">{{ message }}</p><small v-if="!(validation(selected).errors?.length || validation(selected).warnings?.length)">航点数量、高度和速度均符合平台校验规则</small></div><div class="drawer-actions"><el-button type="primary" @click="estimateRow(selected)">重新估算</el-button><el-button :disabled="!['DRAFT','REJECTED'].includes(String(selected.status))" @click="saveWaypointRevision(selected)">保存编辑修订</el-button></div><div class="subsection-head version-head"><div><strong>不可变版本历史</strong><span>回滚将创建一个新草稿,不覆盖历史任务引用</span></div><el-button link @click="loadVersions(selected)">刷新</el-button></div><el-table :data="versionRows" size="small" empty-text="暂无版本"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="status" label="状态" width="105" /><el-table-column prop="approval_status" label="审批" width="120" /><el-table-column label="估算" min-width="120"><template #default="scope">{{ routeEstimateText(scope.row.estimation_result) }}</template></el-table-column><el-table-column label="摘要" min-width="150"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 16) }}</span></template></el-table-column><el-table-column label="操作" width="90"><template #default="scope"><el-button link type="warning" :disabled="scope.row.route_version_id === activeVersionId(selected)" @click="rollbackVersion(scope.row)">回滚</el-button></template></el-table-column></el-table><div class="subsection-head version-head"><div><strong>编辑审计</strong><span>航点和载荷动作修改记录</span></div></div><el-table :data="revisionRows" size="small" empty-text="暂无编辑修订"><el-table-column prop="revision_no" label="修订" width="70" /><el-table-column prop="editor_id" label="编辑人" width="120" /><el-table-column prop="change_summary" label="说明" min-width="160" /><el-table-column label="摘要" min-width="130"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 14) }}</span></template></el-table-column></el-table><div class="subsection-head version-head"><div><strong>通用动作 Schema</strong><span>{{ actionSchemas.length }} 项厂商无关动作约束</span></div></div><el-table :data="actionSchemas" size="small"><el-table-column prop="action_type" label="动作" width="150" /><el-table-column prop="device_type" label="设备" width="130" /><el-table-column label="状态" width="90"><template #default="scope"><el-tag size="small" type="success" effect="plain">{{ scope.row.status }}</el-tag></template></el-table-column></el-table></template></el-drawer>
</div>
</template>
@@ -41,32 +56,46 @@ import { ElMessage } from "element-plus";
import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination";
import { createInspectionRoute, createRouteVersion, executeWorkflowAction, inspectionObjects, inspectionRoutes, publishRouteVersion, rollbackRoute, routeVersions, validateRouteVersion } from "../../services/api";
import { createInspectionRoute, createRouteVersion, estimateRouteVersion, executeWorkflowAction, generateCandidateRoute, inspectionObjects, inspectionRoutes, publishRouteVersion, rollbackRoute, routeActionSchemas, routeEditorRevisions, routeGenerationProfiles, routeVersions, updateRouteWaypoints, validateRouteVersion } from "../../services/api";
import { safeObject, type Row } from "../../types/demo-run";
const rows = ref<Row[]>([]); const objects = ref<Row[]>([]); const versionRows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const createDialog = ref(false); const detailVisible = ref(false); const selected = ref<Row | null>(null); const keyword = ref(""); const statusFilter = ref("");
const rows = ref<Row[]>([]); const objects = ref<Row[]>([]); const versionRows = ref<Row[]>([]); const generatorProfiles = ref<Row[]>([]); const revisionRows = ref<Row[]>([]); const actionSchemas = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const createDialog = ref(false); const generateDialog = ref(false); const detailVisible = ref(false); const selected = ref<Row | null>(null); const keyword = ref(""); const statusFilter = ref("");
const form = reactive({ name: "铁路沿线巡检航线", object_id: "object-line-demo", speed_mps: 7, rth_altitude_m: 100, waypoints: [{ longitude: 116.09, latitude: 39.095, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.125, latitude: 39.115, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.165, latitude: 39.105, altitude_m: 80, gimbal_pitch_deg: -45 }] });
const generateForm = reactive({ object_id: "object-line-demo", generator_type: "", waypoint_count: 5, altitude_m: 80, speed_mps: 7, create_route: true });
const filteredRows = computed(() => rows.value.filter((row) => (!statusFilter.value || row.status === statusFilter.value) && (!keyword.value || `${row.name} ${row.object_name} ${row.template_name}`.toLowerCase().includes(keyword.value.toLowerCase())))); const publishedCount = computed(() => rows.value.filter((row) => row.status === "PUBLISHED").length); const draftCount = computed(() => rows.value.filter((row) => row.status !== "PUBLISHED").length); const totalWaypoints = computed(() => rows.value.reduce((sum, row) => sum + parseWaypoints(row.waypoints).length, 0));
const generatorOptions = computed(() => generatorProfiles.value.filter((item) => !generateForm.object_id || item.object_type === objects.value.find((object) => object.object_id === generateForm.object_id)?.object_type));
const { currentPage: page, pageSize, pagedItems: pagedRows, resetPage } = usePagination(filteredRows);
function parseWaypoints(value: unknown): Row[] { try { return Array.isArray(value) ? value as Row[] : JSON.parse(String(value || "[]")); } catch { return []; } }
function validation(row: Row): Row { return safeObject(row.validation_result); }
function activeVersionId(row: Row) { return String(row.display_version_id || row.working_version_id || row.current_version_id || ""); }
function routeStatusLabel(value: unknown) { return ({ DRAFT: "草稿", PENDING_APPROVAL: "审批中", APPROVED: "已审批", PUBLISHED: "已发布", REJECTED: "已驳回", RETIRED: "已退役" } as Record<string, string>)[String(value)] || String(value || "-"); }
function approvalLabel(value: unknown) { return ({ DRAFT: "待提交", PENDING_APPROVAL: "审批中", APPROVED: "审批通过", REJECTED: "已驳回" } as Record<string, string>)[String(value)] || String(value || "待提交"); }
function objectTypeLabel(value: unknown) { return ({ RAILWAY: "铁路线路", BRIDGE: "桥梁", TUNNEL: "隧道", FLOOD: "防洪", POWER: "电力" } as Record<string, string>)[String(value)] || String(value || "-"); }
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
function jsonText(value: unknown) { const data = typeof value === "string" ? safeObject(value) : value; return Object.entries(data as Row || {}).map(([key, item]) => `${key}: ${Array.isArray(item) ? item.length + '项' : item}`).join("") || "-"; }
function routeEstimate(row: unknown) { return typeof row === "string" ? safeObject(row) : safeObject(row); }
function routeEstimateText(value: unknown) { const data = routeEstimate(value); return data.estimated_minutes ? `${data.estimated_minutes} 分钟 · 电量 ${data.battery_percent}% · ${data.storage_mb} MB` : "待估算"; }
function estimateSummary(row: Row) { return routeEstimateText(row.estimation_result); }
function addWaypoint() { const previous = form.waypoints.at(-1) || { longitude: 116.1, latitude: 39.1, altitude_m: 80, gimbal_pitch_deg: -45 }; form.waypoints.push({ ...previous, longitude: previous.longitude + 0.005, latitude: previous.latitude + 0.003 }); }
async function inspect(row: Row) { selected.value = row; detailVisible.value = true; await loadVersions(row); }
async function inspect(row: Row) { selected.value = row; detailVisible.value = true; await Promise.all([loadVersions(row), loadRevisionRows(row)]); }
function pointStyle(_point: Row, index: number) { const count = Math.max(2, parseWaypoints(selected.value?.waypoints).length); return { left: `${10 + index * 80 / (count - 1)}%`, top: `${60 - Math.sin(index * 1.4) * 25}%` }; }
async function load() { loading.value = true; try { [rows.value, objects.value] = await Promise.all([inspectionRoutes(), inspectionObjects()]); } finally { loading.value = false; } }
async function load() { loading.value = true; try { [rows.value, objects.value, generatorProfiles.value, actionSchemas.value] = await Promise.all([inspectionRoutes(), inspectionObjects(), routeGenerationProfiles(), routeActionSchemas()]); if (!generateForm.generator_type && generatorProfiles.value.length) generateForm.generator_type = String(generatorProfiles.value[0].generator_type); } finally { loading.value = false; } }
async function save() { if (!form.name || form.waypoints.length < 2) return ElMessage.warning("请填写航线名称并配置至少两个航点"); saving.value = true; try { await createInspectionRoute({ name: form.name, object_id: form.object_id, template_id: "template-railway", waypoints: form.waypoints.map((point, index) => ({ ...point, sequence: index + 1, speed_mps: form.speed_mps, actions: ["TAKE_PHOTO"] })), flight_parameters: { speed_mps: form.speed_mps, rth_altitude_m: form.rth_altitude_m }, payload_actions: [{ type: "TAKE_PHOTO", interval_s: 2 }], created_by: "user-dispatcher" }); createDialog.value = false; ElMessage.success("航线已创建并完成校验"); await load(); } finally { saving.value = false; } }
async function validateRow(row: Row) { const result = await validateRouteVersion(activeVersionId(row)); ElMessage[result.valid ? "success" : "error"](result.valid ? "航线校验通过" : result.errors.join("")); await load(); }
async function estimateRow(row: Row) { const result = await estimateRouteVersion(activeVersionId(row), { created_by: "route-estimator" }); ElMessage[result.warnings?.length ? "warning" : "success"](`估算完成:${result.estimated_minutes} 分钟,电量 ${result.battery_percent}%`); await load(); if (selected.value?.route_id === row.route_id) selected.value = rows.value.find((item) => item.route_id === row.route_id) || selected.value; }
async function submitRow(row: Row) { const action = String(row.approval_status) === "REJECTED" ? "RESUBMIT" : "SUBMIT"; await executeWorkflowAction("ROUTE_VERSION", activeVersionId(row), action as "SUBMIT" | "RESUBMIT", "航线校验完成,提交发布审批"); ElMessage.success("航线已进入审批队列"); await load(); }
async function publishRow(row: Row) { try { await publishRouteVersion(activeVersionId(row)); ElMessage.success("航线版本已发布,历史版本已保留"); await load(); } catch { /* global error */ } }
async function newVersion(row: Row) { const result = await createRouteVersion(String(row.route_id), { source_version_id: activeVersionId(row), change_summary: "基于当前版本创建编辑草稿", created_by: "航线管理员" }); ElMessage.success(`已创建 V${result.version_no} 草稿`); await load(); }
async function loadVersions(row: Row) { versionRows.value = await routeVersions(String(row.route_id)); }
async function loadRevisionRows(row: Row) { revisionRows.value = await routeEditorRevisions(activeVersionId(row)); }
async function rollbackVersion(version: Row) { if (!selected.value) return; const result = await rollbackRoute(String(selected.value.route_id), String(version.route_version_id), `回滚至 V${version.version_no}`); ElMessage.success(`已创建回滚草稿 V${result.version_no},请重新校验和审批`); await load(); const refreshed = rows.value.find((row) => row.route_id === selected.value?.route_id); if (refreshed) { selected.value = refreshed; await loadVersions(refreshed); } }
async function generateRouteFromObject() { if (!generateForm.object_id) return ElMessage.warning("请选择巡检对象"); saving.value = true; try { const result = await generateCandidateRoute({ object_id: generateForm.object_id, generator_type: generateForm.generator_type || undefined, create_route: generateForm.create_route, route_name: `${objects.value.find((item) => item.object_id === generateForm.object_id)?.name || "对象"} 候选航线`, parameters: { waypoint_count: generateForm.waypoint_count, altitude_m: generateForm.altitude_m, speed_mps: generateForm.speed_mps }, created_by: "route-generator" }); ElMessage.success(generateForm.create_route ? `已创建候选航线 ${result.created_route?.route_id}` : `已生成 ${result.waypoints?.length || 0} 个候选航点`); generateDialog.value = false; await load(); } finally { saving.value = false; } }
async function saveWaypointRevision(row: Row) { const waypoints = parseWaypoints(row.waypoints).map((point, index) => ({ ...point, sequence: index + 1 })); const result = await updateRouteWaypoints(activeVersionId(row), { waypoints, flight_parameters: safeObject(row.flight_parameters), payload_actions: parseWaypoints(row.payload_actions), change_summary: "前端地图编辑器保存航点修订", editor_id: "route-editor", expected_version_lock: Number(row.version_lock || 0) }); ElMessage.success(`已保存编辑修订 V${result.version_lock}`); await load(); const refreshed = rows.value.find((item) => item.route_id === row.route_id); if (refreshed) { selected.value = refreshed; await Promise.all([loadVersions(refreshed), loadRevisionRows(refreshed)]); } }
watch([keyword, statusFilter], resetPage);
watch(() => generateForm.object_id, () => {
const first = generatorOptions.value[0];
if (first) generateForm.generator_type = String(first.generator_type);
});
onMounted(load);
</script>
@@ -30,6 +30,30 @@
<el-pagination v-model:current-page="devicePage" v-model:page-size="devicePageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="devices.length" />
</el-tab-pane>
<el-tab-pane label="资源日历" name="resources">
<div class="filter-bar">
<el-select v-model="resourceTaskId" filterable placeholder="选择待检查任务"><el-option v-for="item in taskRows" :key="item.task_id" :label="`${item.task_id} / ${item.route_name || item.route_id || '待配置航线'}`" :value="String(item.task_id)" /></el-select>
<span class="filter-spacer"></span>
<el-button @click="syncResources">同步资源</el-button>
<el-button type="primary" :disabled="!resourceTaskId" @click="checkResourceConflicts">冲突检查</el-button>
<el-button :disabled="!resourceTaskId || selectedResourceIds.length === 0" @click="reserveResources">预占选中资源</el-button>
</div>
<el-alert v-if="conflictResult" class="resource-alert" :type="conflictResult.blocking_count ? 'error' : conflictResult.warning_count ? 'warning' : 'success'" :closable="false" show-icon :title="`检查结果:${conflictResult.status}`" :description="conflictSummary" />
<el-card class="workspace-card" shadow="never">
<el-table :data="resourceRows" empty-text="暂无作业资源" @selection-change="selectResources">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" label="资源" min-width="160"><template #default="scope"><strong>{{ scope.row.name }}</strong><small class="cell-subtext">{{ resourceTypeLabel(scope.row.resource_type) }}</small></template></el-table-column>
<el-table-column label="能力" min-width="190"><template #default="scope">{{ parseArray(scope.row.capabilities).join(' / ') || '-' }}</template></el-table-column>
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="scope.row.status === 'AVAILABLE' ? 'success' : 'warning'" effect="plain">{{ scope.row.status === 'AVAILABLE' ? '可用' : scope.row.status }}</el-tag></template></el-table-column>
<el-table-column label="健康" min-width="145"><template #default="scope">{{ resourceHealth(scope.row.health_snapshot) }}</template></el-table-column>
</el-table>
</el-card>
<el-card class="workspace-card" shadow="never">
<div class="subsection-head"><div><strong>资源预占与不可用窗口</strong><span>下发前硬门禁使用同一资源日历</span></div></div>
<el-table :data="[...reservationRows, ...unavailableRows]" size="small" empty-text="暂无窗口"><el-table-column label="资源" min-width="160"><template #default="scope">{{ scope.row.resource_name }}</template></el-table-column><el-table-column label="窗口" min-width="230"><template #default="scope">{{ formatDate(scope.row.start_at) }} - {{ formatDate(scope.row.end_at) }}</template></el-table-column><el-table-column label="类型" width="130"><template #default="scope">{{ scope.row.reservation_type || scope.row.reason_type }}</template></el-table-column><el-table-column label="状态/来源" width="130"><template #default="scope">{{ scope.row.status || scope.row.source }}</template></el-table-column></el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="飞行任务" name="missions">
<el-card class="workspace-card" shadow="never"><el-table :data="pagedMissions" empty-text="暂无飞行任务" @row-click="inspectMission">
<el-table-column prop="mission_id" label="飞行任务" min-width="175"><template #default="scope"><span class="entity-id">{{ scope.row.mission_id }}</span><small class="cell-subtext">{{ scope.row.vendor_mission_id || '等待厂商回执' }}</small></template></el-table-column>
@@ -63,11 +87,11 @@ import { ElMessage } from "element-plus";
import { CircleCheck, Position, Promotion, Refresh, Warning } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue";
import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination";
import { checkUavConnection, dispatchUavMission, executeUavMissionCommand, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api";
import { checkTaskConflicts, checkUavConnection, dispatchUavMission, executeUavMissionCommand, operationResourceCalendar, reserveTaskResources, syncOperationResources, synchronizeUavDevices, tasks, uavConnections, uavDevices, uavMissions, uavMissionTelemetry } from "../../services/api";
import { safeObject, type Row } from "../../types/demo-run";
const route = useRoute();
const connections = ref<Row[]>([]); const devices = ref<Row[]>([]); const missions = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const activeTab = ref(String(route.query.tab || "connections")); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref<Row | null>(null); const missionVisible = ref(false); const selectedMission = ref<Row | null>(null); const telemetry = ref<Row[]>([]); const deepLinkHandled = ref(false);
const connections = ref<Row[]>([]); const devices = ref<Row[]>([]); const missions = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const resourceRows = ref<Row[]>([]); const reservationRows = ref<Row[]>([]); const unavailableRows = ref<Row[]>([]); const selectedResourceIds = ref<string[]>([]); const conflictResult = ref<Row | null>(null); const resourceTaskId = ref(""); const loading = ref(false); const saving = ref(false); const activeTab = ref(String(route.query.tab || "connections")); const dispatchDialog = ref(false); const healthVisible = ref(false); const healthResult = ref<Row | null>(null); const missionVisible = ref(false); const selectedMission = ref<Row | null>(null); const telemetry = ref<Row[]>([]); const deepLinkHandled = ref(false);
const dispatchForm = reactive({ task_id: "", route_id: "", connection_id: "connection-simulator", device_id: "uav-sim-01", dock_id: "dock-sim-01" });
const availableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE").length); const onlineDevices = computed(() => devices.value.filter((item) => item.status === "ONLINE").length); const activeMissions = computed(() => missions.value.filter((item) => ["DISPATCHING","DISPATCHED","PREPARING","FLYING","PAUSED","RETURNING"].includes(item.status)).length); const completedMissions = computed(() => missions.value.filter((item) => item.status === "COMPLETED").length); const dispatchableTasks = computed(() => taskRows.value.filter((item) => item.can_dispatch === true || String(item.can_dispatch).toLowerCase() === "true")); const selectedTask = computed(() => taskRows.value.find((item) => String(item.task_id) === dispatchForm.task_id)); const usableConnections = computed(() => connections.value.filter((item) => item.status === "AVAILABLE")); const availableDevices = computed(() => devices.value.filter((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"));
const { currentPage: connectionPage, pageSize: connectionPageSize, pagedItems: pagedConnections } = usePagination(connections);
@@ -82,12 +106,24 @@ function missionTag(value: unknown): "success" | "warning" | "danger" | "info" |
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; } function timeOnly(value: unknown) { return value ? new Date(String(value)).toLocaleTimeString("zh-CN", { hour12: false }) : "-"; }
function layerLabel(value: unknown) { return ({ NETWORK: "网络可达", AUTHENTICATION: "身份认证", CAPABILITIES: "能力读取", BUSINESS_HANDSHAKE: "业务握手" } as Record<string, string>)[String(value)] || String(value); }
function telemetrySummary(value: unknown) { const data = safeObject(value); return `电量 ${data.battery_percent ?? '-'}% · 链路 ${data.link_quality ?? '-'}% · RTK ${data.rtk_status || '-'}`; }
async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks()]); if (!dispatchForm.task_id && dispatchableTasks.value.length) dispatchForm.task_id = String(dispatchableTasks.value[0].task_id); selectTask(); if (!usableConnections.value.some((item) => item.connection_id === dispatchForm.connection_id)) dispatchForm.connection_id = String(usableConnections.value[0]?.connection_id || ""); selectConnection(); } finally { loading.value = false; } }
const conflictSummary = computed(() => {
if (!conflictResult.value) return "";
const checks = Array.isArray(conflictResult.value.checks) ? conflictResult.value.checks as Row[] : [];
return checks.filter((item) => item.level !== "PASSED").map((item) => `${item.code}: ${item.message}`).join("") || "所有资源窗口可用,天气和空域若未接入会显示 NOT_CHECKED";
});
function resourceTypeLabel(value: unknown) { return ({ AIRCRAFT: "无人机", DOCK: "机巢", PAYLOAD: "载荷", PILOT: "飞手" } as Record<string, string>)[String(value)] || String(value || "-"); }
function resourceHealth(value: unknown) { const data = safeObject(value); return Object.entries(data).slice(0, 2).map(([key, item]) => `${key}:${item}`).join(" · ") || "-"; }
async function loadResourceCalendar() { const calendar = await operationResourceCalendar(); resourceRows.value = calendar.resources || []; reservationRows.value = calendar.reservations || []; unavailableRows.value = calendar.unavailable_windows || []; if (!resourceTaskId.value) resourceTaskId.value = String(dispatchableTasks.value[0]?.task_id || taskRows.value[0]?.task_id || ""); }
async function load() { loading.value = true; try { [connections.value, devices.value, missions.value, taskRows.value] = await Promise.all([uavConnections(), uavDevices(), uavMissions(), tasks()]); if (!dispatchForm.task_id && dispatchableTasks.value.length) dispatchForm.task_id = String(dispatchableTasks.value[0].task_id); if (!resourceTaskId.value) resourceTaskId.value = String(dispatchableTasks.value[0]?.task_id || taskRows.value[0]?.task_id || ""); selectTask(); if (!usableConnections.value.some((item) => item.connection_id === dispatchForm.connection_id)) dispatchForm.connection_id = String(usableConnections.value[0]?.connection_id || ""); selectConnection(); await loadResourceCalendar(); } finally { loading.value = false; } }
function selectTask() { dispatchForm.route_id = String(selectedTask.value?.route_id || ""); }
function selectConnection() { const first = devices.value.find((item) => item.connection_id === dispatchForm.connection_id && item.status === "ONLINE"); dispatchForm.device_id = first ? String(first.device_id) : ""; dispatchForm.dock_id = ""; }
function openDispatchDialog(taskId?: string) { const target = taskId ? dispatchableTasks.value.find((item) => String(item.task_id) === taskId) : dispatchableTasks.value[0]; if (!target) return ElMessage.warning(taskId ? "该任务当前不可下发,请返回巡检任务查看准备状态" : "当前没有满足审批、航线和状态条件的待下发任务"); dispatchForm.task_id = String(target.task_id); selectTask(); activeTab.value = "missions"; dispatchDialog.value = true; }
async function checkConnection(row: Row) { healthResult.value = await checkUavConnection(String(row.connection_id)); healthVisible.value = true; await load(); }
async function syncDevices(row: Row) { const result = await synchronizeUavDevices(String(row.connection_id)); ElMessage.success(`已同步 ${result.synchronized} 台设备`); await load(); activeTab.value = "devices"; }
function selectResources(rows: Row[]) { selectedResourceIds.value = rows.map((row) => String(row.resource_id)); }
async function syncResources() { const result = await syncOperationResources(); ElMessage.success(`已同步作业资源:设备 ${result.devices},机巢 ${result.docks}`); await loadResourceCalendar(); }
async function checkResourceConflicts() { if (!resourceTaskId.value) return; const result = await checkTaskConflicts(resourceTaskId.value, { resource_ids: selectedResourceIds.value, duration_minutes: 90, requested_by: "user-dispatcher" }); conflictResult.value = result; ElMessage[result.blocking_count ? "error" : result.warning_count ? "warning" : "success"](`阻断 ${result.blocking_count || 0},警告 ${result.warning_count || 0}`); }
async function reserveResources() { if (!resourceTaskId.value || selectedResourceIds.value.length === 0) return; const result = await reserveTaskResources(resourceTaskId.value, { resource_ids: selectedResourceIds.value, duration_minutes: 90, reservation_type: "TASK_WINDOW", idempotency_key: `reserve-${resourceTaskId.value}-${Date.now()}`, requested_by: "user-dispatcher" }); ElMessage.success(`已预占 ${result.reservation_ids?.length || 0} 项资源`); await loadResourceCalendar(); }
async function dispatch() { if (!dispatchForm.task_id || !dispatchForm.route_id || !dispatchForm.device_id) return ElMessage.warning("请选择任务、航线和无人机"); saving.value = true; try { const result = await dispatchUavMission({ ...dispatchForm, requested_by: "user-dispatcher", idempotency_key: `dispatch-${dispatchForm.task_id}-${Date.now()}` }); ElMessage.success(`任务已下发:${result.vendor_mission_id}`); dispatchDialog.value = false; activeTab.value = "missions"; await load(); } finally { saving.value = false; } }
async function command(row: Row, action: string) { const result = await executeUavMissionCommand(String(row.mission_id), action, Number(row.version_no)); ElMessage.success(result.accepted ? `命令已受理,等待司空 2 状态回传:${missionLabel(result.status)}` : `命令完成,任务状态:${missionLabel(result.status)}`); await load(); }
async function inspectMission(row: Row) { selectedMission.value = row; telemetry.value = await uavMissionTelemetry(String(row.mission_id)); missionVisible.value = true; }
@@ -334,6 +334,7 @@ public class CapabilityCompletionService {
rv.approval_status,ST_AsGeoJSON(rv.geom) as geometry,
rv.waypoints::text as waypoints, rv.flight_parameters::text as flight_parameters,
rv.payload_actions::text as payload_actions, rv.validation_result::text as validation_result,
rv.estimation_result::text as estimation_result,rv.version_lock,rv.edited_at,rv.edited_by,
rv.checksum,rv.change_summary,rv.source_version_id,
rv.published_at,rv.published_by,r.created_at,r.updated_at
from routes r
@@ -2,15 +2,18 @@ package com.ai.trackwalker.foundation.api;
import com.ai.trackwalker.api.ApiResponse;
import com.ai.trackwalker.capability.service.CapabilityCompletionService;
import com.ai.trackwalker.foundation.service.DependencyFreeCompletionService;
import com.ai.trackwalker.foundation.service.FoundationCompletionService;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -24,13 +27,16 @@ import java.util.Map;
public class FoundationCompletionController {
private final FoundationCompletionService service;
private final CapabilityCompletionService capabilityService;
private final DependencyFreeCompletionService dependencyFreeService;
public FoundationCompletionController(
FoundationCompletionService service,
CapabilityCompletionService capabilityService
CapabilityCompletionService capabilityService,
DependencyFreeCompletionService dependencyFreeService
) {
this.service = service;
this.capabilityService = capabilityService;
this.dependencyFreeService = dependencyFreeService;
}
@GetMapping("/inspection/task-templates")
@@ -269,4 +275,268 @@ public class FoundationCompletionController {
.header("X-Content-SHA256", content.checksum())
.body(content.content());
}
@GetMapping("/inspection/object-mapping-profiles")
public ApiResponse<?> objectMappingProfiles() {
return ApiResponse.ok(Map.of("profiles", dependencyFreeService.objectMappingProfiles()));
}
@GetMapping("/inspection/object-imports")
public ApiResponse<?> objectImports() {
return ApiResponse.ok(Map.of("imports", dependencyFreeService.objectImports()));
}
@PostMapping("/inspection/object-imports")
public ApiResponse<?> createObjectImport(
@Valid @RequestBody FoundationRequests.CreateObjectImportRequest request
) {
return ApiResponse.ok(dependencyFreeService.createObjectImport(request));
}
@GetMapping("/inspection/object-imports/{jobId}")
public ApiResponse<?> objectImport(@PathVariable String jobId) {
return ApiResponse.ok(dependencyFreeService.objectImport(jobId));
}
@GetMapping("/inspection/object-imports/{jobId}/rows")
public ApiResponse<?> objectImportRows(
@PathVariable String jobId,
@RequestParam(required = false) String status
) {
return ApiResponse.ok(Map.of("rows", dependencyFreeService.objectImportRows(jobId, status)));
}
@PostMapping("/inspection/object-imports/{jobId}/validate")
public ApiResponse<?> validateObjectImport(@PathVariable String jobId) {
return ApiResponse.ok(dependencyFreeService.validateObjectImport(jobId));
}
@PostMapping("/inspection/object-imports/{jobId}/commit")
public ApiResponse<?> commitObjectImport(
@PathVariable String jobId,
@RequestParam(defaultValue = "user-dispatcher") String confirmedBy
) {
return ApiResponse.ok(dependencyFreeService.commitObjectImport(jobId, confirmedBy));
}
@PostMapping("/inspection/object-imports/{jobId}/rollback")
public ApiResponse<?> rollbackObjectImport(
@PathVariable String jobId,
@RequestParam(defaultValue = "user-dispatcher") String requestedBy
) {
return ApiResponse.ok(dependencyFreeService.rollbackObjectImport(jobId, requestedBy));
}
@GetMapping("/inspection/objects/{objectId}/versions")
public ApiResponse<?> objectVersions(@PathVariable String objectId) {
return ApiResponse.ok(Map.of("versions", dependencyFreeService.objectVersions(objectId)));
}
@GetMapping("/business-calendars")
public ApiResponse<?> businessCalendars() {
return ApiResponse.ok(Map.of("calendars", dependencyFreeService.businessCalendars()));
}
@PostMapping("/inspection/plans/{planId}/preview-runs")
public ApiResponse<?> previewPlanRuns(
@PathVariable String planId,
@RequestBody(required = false) FoundationRequests.PreviewPlanRunsRequest request
) {
return ApiResponse.ok(Map.of("runs", dependencyFreeService.previewPlanRuns(
planId,
request == null || request.count() == null ? 6 : request.count()
)));
}
@GetMapping("/inspection/plans/{planId}/executions")
public ApiResponse<?> planExecutions(@PathVariable String planId) {
return ApiResponse.ok(Map.of("executions", dependencyFreeService.planExecutions(planId)));
}
@PostMapping("/inspection/plans/run-due")
public ApiResponse<?> runDuePlans(
@RequestBody(required = false) FoundationRequests.RunDuePlansRequest request
) {
return ApiResponse.ok(dependencyFreeService.runDuePlans(
request == null || request.limit() == null ? 20 : request.limit()
));
}
@PostMapping("/operation-resources/sync")
public ApiResponse<?> syncOperationResources() {
return ApiResponse.ok(dependencyFreeService.syncOperationResources());
}
@GetMapping("/operation-resources/calendar")
public ApiResponse<?> operationResourceCalendar(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to
) {
return ApiResponse.ok(dependencyFreeService.operationResourceCalendar(from, to));
}
@PostMapping("/inspection/tasks/{taskId}/conflict-checks")
public ApiResponse<?> checkTaskConflicts(
@PathVariable String taskId,
@RequestBody(required = false) FoundationRequests.ConflictCheckRequest request
) {
return ApiResponse.ok(dependencyFreeService.checkTaskConflicts(
taskId,
request == null ? new FoundationRequests.ConflictCheckRequest(null, null, null, null, null, null) : request
));
}
@PostMapping("/inspection/tasks/{taskId}/reservations")
public ApiResponse<?> reserveTaskResources(
@PathVariable String taskId,
@Valid @RequestBody FoundationRequests.ReserveResourcesRequest request
) {
return ApiResponse.ok(dependencyFreeService.reserveTaskResources(taskId, request));
}
@DeleteMapping("/inspection/tasks/{taskId}/reservations/{reservationId}")
public ApiResponse<?> cancelReservation(
@PathVariable String taskId,
@PathVariable String reservationId
) {
return ApiResponse.ok(dependencyFreeService.cancelReservation(taskId, reservationId));
}
@GetMapping("/gis/layers")
public ApiResponse<?> gisLayers() {
return ApiResponse.ok(Map.of("layers", dependencyFreeService.gisLayers()));
}
@GetMapping("/gis/dataset-versions")
public ApiResponse<?> gisDatasetVersions() {
return ApiResponse.ok(Map.of("dataset_versions", dependencyFreeService.gisDatasetVersions()));
}
@PostMapping("/gis/layers/{layerId}/publish")
public ApiResponse<?> publishGisLayer(@PathVariable String layerId) {
return ApiResponse.ok(dependencyFreeService.publishGisLayer(layerId));
}
@GetMapping("/gis/layers/{layerCode}/features")
public ApiResponse<?> queryGisLayerFeatures(@PathVariable String layerCode) {
return ApiResponse.ok(dependencyFreeService.queryGisLayerFeatures(layerCode));
}
@GetMapping("/route-generation-profiles")
public ApiResponse<?> routeGenerationProfiles() {
return ApiResponse.ok(Map.of("profiles", dependencyFreeService.routeGenerationProfiles()));
}
@PostMapping("/routes/generate")
public ApiResponse<?> generateRoute(
@Valid @RequestBody FoundationRequests.GenerateRouteRequest request
) {
return ApiResponse.ok(dependencyFreeService.generateRoute(request));
}
@GetMapping("/route-action-schemas")
public ApiResponse<?> routeActionSchemas() {
return ApiResponse.ok(Map.of("schemas", dependencyFreeService.routeActionSchemas()));
}
@PutMapping("/route-versions/{versionId}/waypoints")
public ApiResponse<?> updateRouteWaypoints(
@PathVariable String versionId,
@Valid @RequestBody FoundationRequests.UpdateRouteWaypointsRequest request
) {
return ApiResponse.ok(dependencyFreeService.updateRouteWaypoints(versionId, request));
}
@GetMapping("/route-versions/{versionId}/editor-revisions")
public ApiResponse<?> routeEditorRevisions(@PathVariable String versionId) {
return ApiResponse.ok(Map.of("revisions", dependencyFreeService.routeEditorRevisions(versionId)));
}
@GetMapping("/route-estimation-profiles")
public ApiResponse<?> routeEstimationProfiles() {
return ApiResponse.ok(Map.of("profiles", dependencyFreeService.routeEstimationProfiles()));
}
@PostMapping("/route-versions/{versionId}/estimate")
public ApiResponse<?> estimateRoute(
@PathVariable String versionId,
@RequestBody(required = false) FoundationRequests.EstimateRouteRequest request
) {
return ApiResponse.ok(dependencyFreeService.estimateRoute(
versionId,
request == null ? new FoundationRequests.EstimateRouteRequest(null, null, null, null) : request
));
}
@GetMapping("/route-versions/{versionId}/estimations")
public ApiResponse<?> routeEstimations(@PathVariable String versionId) {
return ApiResponse.ok(Map.of("estimations", dependencyFreeService.routeEstimations(versionId)));
}
@GetMapping("/spatial-rule-sets")
public ApiResponse<?> spatialRuleSets() {
return ApiResponse.ok(Map.of("rule_sets", dependencyFreeService.spatialRuleSets()));
}
@PostMapping("/spatial-rule-sets")
public ApiResponse<?> createSpatialRuleSet(
@Valid @RequestBody FoundationRequests.CreateSpatialRuleSetRequest request
) {
return ApiResponse.ok(dependencyFreeService.createSpatialRuleSet(request));
}
@PostMapping("/spatial-rule-sets/{ruleSetId}/versions")
public ApiResponse<?> createSpatialRuleVersion(
@PathVariable String ruleSetId,
@Valid @RequestBody FoundationRequests.CreateSpatialRuleVersionRequest request
) {
return ApiResponse.ok(dependencyFreeService.createSpatialRuleVersion(ruleSetId, request));
}
@PostMapping("/spatial-rule-set-versions/{versionId}/publish")
public ApiResponse<?> publishSpatialRuleVersion(
@PathVariable String versionId,
@RequestParam(defaultValue = "user-approver") String approvedBy
) {
return ApiResponse.ok(dependencyFreeService.publishSpatialRuleVersion(versionId, approvedBy));
}
@GetMapping("/spatial-exemptions")
public ApiResponse<?> spatialExemptions() {
return ApiResponse.ok(Map.of("exemptions", dependencyFreeService.spatialExemptions()));
}
@PostMapping("/spatial-exemptions")
public ApiResponse<?> createSpatialExemption(
@Valid @RequestBody FoundationRequests.CreateSpatialExemptionRequest request
) {
return ApiResponse.ok(dependencyFreeService.createSpatialExemption(request));
}
@PostMapping("/spatial-exemptions/{exemptionId}/approve")
public ApiResponse<?> approveSpatialExemption(
@PathVariable String exemptionId,
@RequestParam(defaultValue = "user-approver") String approvedBy
) {
return ApiResponse.ok(dependencyFreeService.approveSpatialExemption(exemptionId, approvedBy));
}
@GetMapping("/geofences")
public ApiResponse<?> geofences() {
return ApiResponse.ok(Map.of("geofences", dependencyFreeService.geofences()));
}
@PostMapping("/geofences")
public ApiResponse<?> createGeofence(
@Valid @RequestBody FoundationRequests.CreateGeofenceRequest request
) {
return ApiResponse.ok(dependencyFreeService.createGeofence(request));
}
@PostMapping("/spatial-rule-hits/explain")
public ApiResponse<?> explainSpatialHit(
@Valid @RequestBody FoundationRequests.ExplainSpatialHitRequest request
) {
return ApiResponse.ok(dependencyFreeService.explainSpatialHit(request));
}
}
@@ -122,4 +122,125 @@ public final class FoundationRequests {
String createdBy
) {
}
public record CreateObjectImportRequest(
@NotBlank String fileName,
String fileType,
String mappingProfileId,
String declaredCrs,
@NotEmpty List<Map<String, Object>> rows,
String createdBy
) {
}
public record PreviewPlanRunsRequest(
Integer count
) {
}
public record RunDuePlansRequest(
Integer limit
) {
}
public record ConflictCheckRequest(
String startAt,
String endAt,
Integer durationMinutes,
List<String> resourceIds,
String routeVersionId,
String requestedBy
) {
}
public record ReserveResourcesRequest(
@NotEmpty List<String> resourceIds,
String startAt,
String endAt,
Integer durationMinutes,
String reservationType,
@NotBlank String idempotencyKey,
String requestedBy
) {
}
public record GenerateRouteRequest(
@NotBlank String objectId,
String generatorType,
Map<String, Object> parameters,
Boolean createRoute,
String routeName,
String createdBy
) {
}
public record UpdateRouteWaypointsRequest(
@NotEmpty List<Map<String, Object>> waypoints,
Map<String, Object> flightParameters,
List<Map<String, Object>> payloadActions,
String changeSummary,
String editorId,
Long expectedVersionLock
) {
}
public record EstimateRouteRequest(
String profileId,
Double cruiseSpeedMps,
Double reservePercent,
String createdBy
) {
}
public record CreateSpatialRuleSetRequest(
@NotBlank String code,
@NotBlank String name,
@NotBlank String ruleType,
String createdBy
) {
}
public record CreateSpatialRuleVersionRequest(
List<Map<String, Object>> rules,
String effectiveFrom,
String effectiveTo,
String createdBy
) {
}
public record CreateSpatialExemptionRequest(
@NotBlank String exemptionType,
@NotNull Map<String, Object> geometry,
List<String> appliesToScenes,
List<String> appliesToObjectTypes,
@NotBlank String reason,
@NotBlank String effectiveFrom,
@NotBlank String effectiveTo,
String createdBy
) {
}
public record CreateGeofenceRequest(
@NotBlank String code,
@NotBlank String name,
@NotBlank String fenceType,
@NotNull Map<String, Object> geometry,
Double altitudeMinM,
Double altitudeMaxM,
@NotBlank String effectiveFrom,
@NotBlank String effectiveTo,
String severity,
String datasetVersionId,
String createdBy
) {
}
public record ExplainSpatialHitRequest(
String taskId,
String analysisResultId,
@NotNull Map<String, Object> geometry,
String scene,
String objectType
) {
}
}
@@ -825,6 +825,7 @@ public class FoundationCompletionService {
rv.waypoints::text as waypoints,rv.flight_parameters::text as flight_parameters,
rv.payload_actions::text as payload_actions,
rv.validation_result::text as validation_result,rv.checksum,
rv.estimation_result::text as estimation_result,rv.edited_at,rv.edited_by,
rv.created_by,rv.approved_by,rv.submitted_at,rv.created_at,
rv.published_at,rv.published_by,rv.retired_at,rv.rollback_target_version_id,
rv.version_lock,
@@ -0,0 +1,26 @@
package com.ai.trackwalker.foundation.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class PlanSchedulerJob {
private static final Logger log = LoggerFactory.getLogger(PlanSchedulerJob.class);
private final DependencyFreeCompletionService service;
public PlanSchedulerJob(DependencyFreeCompletionService service) {
this.service = service;
}
@Scheduled(fixedDelayString = "${rail.planning.scheduler-delay-ms:30000}")
public void runDuePlans() {
try {
service.runDuePlans(20);
} catch (Exception exception) {
log.warn("Dependency-free plan scheduler pass failed: {}", exception.getMessage());
}
}
}
@@ -0,0 +1,577 @@
-- Dependency-free software closure for PF-01, PF-02, PF-05, PF-08, PF-09, PF-10, PF-11 and PF-15.
-- Production use still requires approved railway master data, device matrices and spatial rule datasets.
alter table inspection_plans
add column if not exists schedule_timezone varchar(64) not null default 'Asia/Shanghai',
add column if not exists calendar_id varchar(64),
add column if not exists missed_execution_policy varchar(32) not null default 'RUN_ONCE',
add column if not exists max_catch_up_count integer not null default 1,
add column if not exists effective_from timestamptz,
add column if not exists effective_to timestamptz,
add column if not exists last_run_at timestamptz,
add column if not exists version_no bigint not null default 0;
alter table route_versions
add column if not exists estimation_result jsonb not null default '{}'::jsonb,
add column if not exists edited_at timestamptz,
add column if not exists edited_by varchar(64);
create table if not exists inspection_object_mapping_profiles (
id varchar(64) primary key,
name varchar(128) not null,
object_type varchar(32) not null,
file_type varchar(32) not null,
field_mapping jsonb not null default '{}'::jsonb,
value_mapping jsonb not null default '{}'::jsonb,
default_values jsonb not null default '{}'::jsonb,
validation_profile jsonb not null default '{}'::jsonb,
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists inspection_object_import_jobs (
id varchar(64) primary key,
file_name varchar(512) not null,
file_type varchar(32) not null,
object_key text,
mapping_profile_id varchar(64) references inspection_object_mapping_profiles(id),
declared_crs varchar(64) not null default 'EPSG:4326',
status varchar(32) not null,
total_count integer not null default 0,
valid_count integer not null default 0,
invalid_count integer not null default 0,
warning_count integer not null default 0,
dataset_version_id varchar(64),
quality_report jsonb not null default '{}'::jsonb,
created_by varchar(64) not null,
confirmed_by varchar(64),
confirmed_at timestamptz,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists inspection_object_import_rows (
id varchar(64) primary key,
job_id varchar(64) not null references inspection_object_import_jobs(id) on delete cascade,
row_no integer not null,
source_key varchar(256),
raw_attributes jsonb not null default '{}'::jsonb,
source_geometry jsonb,
canonical_payload jsonb not null default '{}'::jsonb,
canonical_geometry geometry(Geometry, 4326),
validation_status varchar(32) not null,
error_codes jsonb not null default '[]'::jsonb,
warning_codes jsonb not null default '[]'::jsonb,
imported_object_id varchar(64) references inspection_objects(id),
unique(job_id, row_no)
);
create table if not exists inspection_object_versions (
id varchar(64) primary key,
object_id varchar(64) not null references inspection_objects(id),
version_no integer not null,
snapshot jsonb not null,
geom geometry(Geometry, 4326),
change_type varchar(32) not null,
change_reason text,
source_job_id varchar(64) references inspection_object_import_jobs(id),
created_by varchar(64) not null,
created_at timestamptz not null,
unique(object_id, version_no)
);
create index if not exists idx_object_import_rows_job_status
on inspection_object_import_rows(job_id, validation_status, row_no);
create index if not exists idx_object_import_rows_geom
on inspection_object_import_rows using gist(canonical_geometry);
create index if not exists idx_object_versions_geom
on inspection_object_versions using gist(geom);
create table if not exists business_calendars (
id varchar(64) primary key,
name varchar(128) not null,
timezone varchar(64) not null,
calendar_type varchar(32) not null,
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists business_calendar_dates (
calendar_id varchar(64) not null references business_calendars(id) on delete cascade,
business_date date not null,
date_type varchar(32) not null,
labels jsonb not null default '[]'::jsonb,
enabled boolean not null default true,
primary key(calendar_id, business_date)
);
create table if not exists plan_executions (
id varchar(64) primary key,
plan_id varchar(64) not null references inspection_plans(id),
scheduled_window_start timestamptz not null,
scheduled_window_end timestamptz not null,
generation_key varchar(256) not null,
trigger_source varchar(32) not null,
status varchar(32) not null,
generated_task_ids jsonb not null default '[]'::jsonb,
skip_reason text,
error_message text,
started_at timestamptz not null,
completed_at timestamptz,
unique(plan_id, scheduled_window_start, generation_key)
);
create table if not exists emergency_authorizations (
id varchar(64) primary key,
plan_id varchar(64) references inspection_plans(id),
task_id varchar(64) references inspection_tasks(id),
event_source varchar(128),
authorized_by varchar(64) not null,
authorized_at timestamptz not null,
risk_confirmation text not null,
attachment_refs jsonb not null default '[]'::jsonb,
expires_at timestamptz not null,
status varchar(32) not null
);
create index if not exists idx_plan_executions_plan_time
on plan_executions(plan_id, scheduled_window_start desc);
create index if not exists idx_inspection_plans_due
on inspection_plans(status, trigger_type, next_run_at);
create table if not exists operation_resources (
id varchar(64) primary key,
resource_type varchar(32) not null,
source_id varchar(64),
name varchar(128) not null,
capabilities jsonb not null default '[]'::jsonb,
status varchar(32) not null,
owner_org_id varchar(64) references organizations(id),
geom geometry(Geometry, 4326),
health_snapshot jsonb not null default '{}'::jsonb,
created_at timestamptz not null,
updated_at timestamptz not null,
unique(resource_type, source_id)
);
create table if not exists resource_qualifications (
id varchar(64) primary key,
resource_id varchar(64) not null references operation_resources(id) on delete cascade,
qualification_type varchar(64) not null,
qualification_no varchar(128),
valid_from timestamptz,
valid_to timestamptz,
scope jsonb not null default '{}'::jsonb,
status varchar(32) not null
);
create table if not exists resource_unavailable_windows (
id varchar(64) primary key,
resource_id varchar(64) not null references operation_resources(id) on delete cascade,
start_at timestamptz not null,
end_at timestamptz not null,
reason_type varchar(64) not null,
reason text,
source varchar(64) not null,
created_at timestamptz not null
);
create table if not exists resource_reservations (
id varchar(64) primary key,
task_id varchar(64) not null references inspection_tasks(id),
resource_id varchar(64) not null references operation_resources(id),
start_at timestamptz not null,
end_at timestamptz not null,
status varchar(32) not null,
reservation_type varchar(32) not null,
idempotency_key varchar(128) not null unique,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists conflict_check_runs (
id varchar(64) primary key,
task_id varchar(64) not null references inspection_tasks(id),
route_version_id varchar(64) references route_versions(id),
input_snapshot jsonb not null,
status varchar(32) not null,
blocking_count integer not null default 0,
warning_count integer not null default 0,
result jsonb not null default '{}'::jsonb,
rule_version varchar(64) not null,
checked_at timestamptz not null
);
create index if not exists idx_resource_reservations_resource_time
on resource_reservations(resource_id, start_at, end_at, status);
create index if not exists idx_operation_resources_geom
on operation_resources using gist(geom);
create table if not exists gis_dataset_versions (
id varchar(64) primary key,
code varchar(64) not null,
name varchar(128) not null,
dataset_type varchar(32) not null,
source_type varchar(32) not null,
crs varchar(64) not null default 'EPSG:4326',
quality_status varchar(32) not null,
status varchar(32) not null,
version_no integer not null,
metadata jsonb not null default '{}'::jsonb,
checksum varchar(64) not null,
created_by varchar(64) not null,
created_at timestamptz not null,
published_at timestamptz,
unique(code, version_no)
);
create table if not exists gis_layers (
id varchar(64) primary key,
code varchar(64) not null unique,
name varchar(128) not null,
layer_type varchar(32) not null,
dataset_version_id varchar(64) references gis_dataset_versions(id),
style_spec jsonb not null default '{}'::jsonb,
visible_by_default boolean not null default true,
min_zoom numeric(5,2),
max_zoom numeric(5,2),
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists gis_spatial_features (
id varchar(64) primary key,
dataset_version_id varchar(64) not null references gis_dataset_versions(id) on delete cascade,
feature_type varchar(32) not null,
business_key varchar(128),
name varchar(128),
geom geometry(Geometry, 4326) not null,
properties jsonb not null default '{}'::jsonb,
status varchar(32) not null,
created_at timestamptz not null
);
create index if not exists idx_gis_spatial_features_geom
on gis_spatial_features using gist(geom);
create index if not exists idx_gis_spatial_features_dataset
on gis_spatial_features(dataset_version_id, feature_type, status);
create table if not exists route_generation_profiles (
id varchar(64) primary key,
generator_type varchar(64) not null,
object_type varchar(32) not null,
name varchar(128) not null,
parameters jsonb not null default '{}'::jsonb,
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null,
unique(generator_type, object_type)
);
create table if not exists route_generation_runs (
id varchar(64) primary key,
object_id varchar(64) references inspection_objects(id),
profile_id varchar(64) references route_generation_profiles(id),
generator_type varchar(64) not null,
parameters jsonb not null default '{}'::jsonb,
input_snapshot jsonb not null default '{}'::jsonb,
status varchar(32) not null,
candidate_route_id varchar(64) references routes(id),
candidate_version_id varchar(64) references route_versions(id),
result jsonb not null default '{}'::jsonb,
created_by varchar(64) not null,
created_at timestamptz not null,
completed_at timestamptz
);
create table if not exists route_estimation_profiles (
id varchar(64) primary key,
code varchar(64) not null unique,
name varchar(128) not null,
device_type varchar(64) not null,
payload_type varchar(64) not null,
parameters jsonb not null default '{}'::jsonb,
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists route_estimation_runs (
id varchar(64) primary key,
route_version_id varchar(64) not null references route_versions(id),
profile_id varchar(64) references route_estimation_profiles(id),
input_snapshot jsonb not null,
status varchar(32) not null,
result jsonb not null default '{}'::jsonb,
confidence varchar(32) not null,
created_by varchar(64) not null,
created_at timestamptz not null,
completed_at timestamptz
);
create index if not exists idx_route_estimation_runs_version
on route_estimation_runs(route_version_id, created_at desc);
create table if not exists route_editor_revisions (
id varchar(64) primary key,
route_version_id varchar(64) not null references route_versions(id) on delete cascade,
revision_no integer not null,
editor_id varchar(64) not null,
change_summary text,
before_waypoints jsonb not null default '[]'::jsonb,
after_waypoints jsonb not null default '[]'::jsonb,
before_actions jsonb not null default '[]'::jsonb,
after_actions jsonb not null default '[]'::jsonb,
checksum varchar(64) not null,
created_at timestamptz not null,
unique(route_version_id, revision_no)
);
create table if not exists route_action_schemas (
id varchar(64) primary key,
device_type varchar(64) not null,
action_type varchar(64) not null,
schema_spec jsonb not null,
status varchar(32) not null,
version_no bigint not null default 0,
created_at timestamptz not null,
updated_at timestamptz not null,
unique(device_type, action_type)
);
create table if not exists spatial_rule_sets (
id varchar(64) primary key,
code varchar(64) not null unique,
name varchar(128) not null,
rule_type varchar(32) not null,
status varchar(32) not null,
current_version_id varchar(64),
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists spatial_rule_set_versions (
id varchar(64) primary key,
rule_set_id varchar(64) not null references spatial_rule_sets(id),
version_no integer not null,
effective_from timestamptz,
effective_to timestamptz,
rules jsonb not null default '[]'::jsonb,
checksum varchar(64) not null,
approval_status varchar(32) not null,
status varchar(32) not null,
created_by varchar(64) not null,
created_at timestamptz not null,
published_at timestamptz,
unique(rule_set_id, version_no)
);
create table if not exists spatial_baseline_features (
id varchar(64) primary key,
rule_version_id varchar(64) not null references spatial_rule_set_versions(id) on delete cascade,
object_type varchar(32) not null,
business_key varchar(128) not null,
geom geometry(Geometry, 4326) not null,
feature_signature varchar(128) not null,
source_resource_id varchar(64),
valid_from timestamptz,
valid_to timestamptz,
attributes jsonb not null default '{}'::jsonb,
status varchar(32) not null
);
create table if not exists spatial_exemptions (
id varchar(64) primary key,
exemption_type varchar(32) not null,
geom geometry(Geometry, 4326) not null,
applies_to_scenes jsonb not null default '[]'::jsonb,
applies_to_object_types jsonb not null default '[]'::jsonb,
reason text not null,
effective_from timestamptz not null,
effective_to timestamptz not null,
approval_status varchar(32) not null,
approved_by varchar(64),
status varchar(32) not null,
created_by varchar(64) not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists geofences (
id varchar(64) primary key,
code varchar(64) not null unique,
name varchar(128) not null,
fence_type varchar(32) not null,
geom geometry(Geometry, 4326) not null,
altitude_min_m numeric(10,2),
altitude_max_m numeric(10,2),
effective_from timestamptz not null,
effective_to timestamptz not null,
severity varchar(32) not null,
dataset_version_id varchar(64) references gis_dataset_versions(id),
status varchar(32) not null,
created_by varchar(64) not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table if not exists spatial_rule_hits (
id varchar(64) primary key,
task_id varchar(64) references inspection_tasks(id),
analysis_result_id varchar(64) references ai_results(id),
rule_version_id varchar(64) references spatial_rule_set_versions(id),
matched_object_id varchar(64),
exemption_id varchar(64) references spatial_exemptions(id),
decision varchar(32) not null,
explanation jsonb not null default '{}'::jsonb,
created_at timestamptz not null
);
create index if not exists idx_spatial_baseline_features_geom
on spatial_baseline_features using gist(geom);
create index if not exists idx_spatial_exemptions_geom
on spatial_exemptions using gist(geom);
create index if not exists idx_geofences_geom
on geofences using gist(geom);
insert into inspection_object_mapping_profiles(
id,name,object_type,file_type,field_mapping,value_mapping,default_values,validation_profile,
status,version_no,created_at,updated_at
) values (
'object-mapping-demo-line', 'DEMO 巡检对象 CSV 映射', 'RAILWAY', 'JSON_ROWS',
'{"source_key":"source_key","name":"name","object_type":"object_type","line_id":"line_id","mileage_start":"mileage_start","mileage_end":"mileage_end","owner_org_id":"owner_org_id","risk_level":"risk_level"}'::jsonb,
'{}'::jsonb,
'{"source_type":"DEMO","source_version":"dependency-free-v21"}'::jsonb,
'{"allowed_object_types":["RAILWAY","BRIDGE","TUNNEL","FLOOD","POWER"],"requires_geometry":true}'::jsonb,
'ACTIVE', 1, now(), now()
) on conflict (id) do nothing;
insert into business_calendars(id,name,timezone,calendar_type,status,version_no,created_at,updated_at) values
('calendar-demo-standard', 'DEMO 标准工作日历', 'Asia/Shanghai', 'STANDARD', 'ACTIVE', 1, now(), now()),
('calendar-demo-flood', 'DEMO 汛期加密巡检日历', 'Asia/Shanghai', 'SEASONAL', 'ACTIVE', 1, now(), now())
on conflict (id) do nothing;
insert into operation_resources(
id,resource_type,source_id,name,capabilities,status,owner_org_id,geom,health_snapshot,created_at,updated_at
) values
('opres-device-sim-01','AIRCRAFT','uav-sim-01','铁路巡检模拟机','["RTK","VISIBLE","THERMAL","MISSION"]'::jsonb,'AVAILABLE','org-works',ST_GeomFromText('POINT(116.090 39.095)',4326),'{"source":"DEMO","battery_percent":92}'::jsonb,now(),now()),
('opres-dock-sim-01','DOCK','dock-sim-01','模拟无人值守机巢','["AUTO_CHARGE","WEATHER"]'::jsonb,'AVAILABLE','org-works',ST_GeomFromText('POINT(116.089 39.094)',4326),'{"source":"DEMO"}'::jsonb,now(),now()),
('opres-pilot-demo','PILOT','user-dispatcher','DEMO 持证飞手','["VISUAL_OBSERVER","MISSION_COMMAND"]'::jsonb,'AVAILABLE','org-works',null,'{"source":"DEMO"}'::jsonb,now(),now())
on conflict (resource_type, source_id) do update
set capabilities=excluded.capabilities,
status=excluded.status,
health_snapshot=excluded.health_snapshot,
updated_at=excluded.updated_at;
insert into resource_qualifications(
id,resource_id,qualification_type,qualification_no,valid_from,valid_to,scope,status
) values (
'qual-pilot-demo-uav', 'opres-pilot-demo', 'UAV_OPERATOR', 'DEMO-UAV-2026',
now() - interval '30 days', now() + interval '365 days', '{"line_ids":["line-demo"]}'::jsonb, 'ACTIVE'
) on conflict (id) do nothing;
insert into gis_dataset_versions(
id,code,name,dataset_type,source_type,crs,quality_status,status,version_no,metadata,checksum,created_by,created_at,published_at
) values (
'gis-dataset-demo-line-v1','DEMO_LINE_CENTER','DEMO 线路中心线','LINE','DEMO','EPSG:4326','PASSED','PUBLISHED',1,
'{"line_id":"line-demo","accuracy":"DEMO"}'::jsonb,
md5('gis-dataset-demo-line-v1'),'system',now(),now()
) on conflict (id) do nothing;
insert into gis_layers(
id,code,name,layer_type,dataset_version_id,style_spec,visible_by_default,min_zoom,max_zoom,status,version_no,created_at,updated_at
) values (
'gis-layer-demo-line','DEMO_LINE_CENTER','DEMO 线路中心线','LINE','gis-dataset-demo-line-v1',
'{"stroke":"#17365d","stroke_width":3}'::jsonb,true,4,20,'PUBLISHED',1,now(),now()
) on conflict (code) do nothing;
insert into gis_spatial_features(
id,dataset_version_id,feature_type,business_key,name,geom,properties,status,created_at
) values (
'gis-feature-demo-line','gis-dataset-demo-line-v1','LINE','line-demo','试点线路中心线',
ST_GeomFromText('LINESTRING(116.090 39.095,116.125 39.115,116.165 39.105)',4326),
'{"source":"DEMO","line_id":"line-demo"}'::jsonb,'ACTIVE',now()
) on conflict (id) do nothing;
insert into route_generation_profiles(
id,generator_type,object_type,name,parameters,status,version_no,created_at,updated_at
) values
('route-gen-railway-parallel','RAILWAY_PARALLEL','RAILWAY','沿线平行巡检生成器','{"altitude_m":80,"speed_mps":7,"offset_m":20,"waypoint_count":5}'::jsonb,'ACTIVE',1,now(),now()),
('route-gen-bridge-orbit','BRIDGE_ORBIT','BRIDGE','桥梁环绕巡检生成器','{"altitude_m":65,"speed_mps":4,"orbit_radius_m":35,"waypoint_count":6}'::jsonb,'ACTIVE',1,now(),now()),
('route-gen-tunnel-portal','TUNNEL_PORTAL','TUNNEL','隧道洞口巡检生成器','{"altitude_m":55,"speed_mps":4,"waypoint_count":4}'::jsonb,'ACTIVE',1,now(),now()),
('route-gen-flood-corridor','FLOOD_CORRIDOR','FLOOD','防洪走廊巡检生成器','{"altitude_m":75,"speed_mps":6,"waypoint_count":6}'::jsonb,'ACTIVE',1,now(),now()),
('route-gen-power-follow','POWER_FOLLOW','POWER','电力部件跟拍生成器','{"altitude_m":70,"speed_mps":5,"waypoint_count":5}'::jsonb,'ACTIVE',1,now(),now())
on conflict (generator_type, object_type) do nothing;
insert into route_estimation_profiles(
id,code,name,device_type,payload_type,parameters,status,version_no,created_at,updated_at
) values (
'route-estimate-demo-matrice4d','DEMO_MATRICE4D_VISIBLE','DEMO Matrice 4D 可见光估算','MATRICE_4D','VISIBLE',
'{"cruise_speed_mps":7,"battery_capacity_wh":180,"power_w":650,"reserve_percent":25,"photo_interval_s":2,"photo_size_mb":8,"storage_reserve_percent":20}'::jsonb,
'ACTIVE',1,now(),now()
) on conflict (code) do nothing;
insert into route_action_schemas(
id,device_type,action_type,schema_spec,status,version_no,created_at,updated_at
) values
('action-schema-visible-photo','GENERIC_UAV','TAKE_PHOTO','{"required":["interval_s"],"properties":{"interval_s":{"type":"number","minimum":1},"gimbal_pitch_deg":{"type":"number","minimum":-90,"maximum":30}}}'::jsonb,'ACTIVE',1,now(),now()),
('action-schema-thermal-photo','GENERIC_UAV','THERMAL_CAPTURE','{"required":["interval_s"],"properties":{"interval_s":{"type":"number","minimum":1},"emissivity":{"type":"number","minimum":0.1,"maximum":1}}}'::jsonb,'ACTIVE',1,now(),now())
on conflict (device_type, action_type) do nothing;
insert into spatial_rule_sets(id,code,name,rule_type,status,current_version_id,created_at,updated_at) values (
'spatial-rules-demo-safety','DEMO_SPATIAL_SAFETY','DEMO 空间安全规则','BASELINE_EXEMPTION','ACTIVE','spatial-rules-demo-safety-v1',now(),now()
) on conflict (code) do nothing;
insert into spatial_rule_set_versions(
id,rule_set_id,version_no,effective_from,effective_to,rules,checksum,approval_status,status,created_by,created_at,published_at
) values (
'spatial-rules-demo-safety-v1','spatial-rules-demo-safety',1,now() - interval '1 day',now() + interval '365 days',
'[{"code":"DEMO_GEOFENCE_INTERSECTION","severity":"WARNING","description":"命中演示地理围栏时提示复核"},{"code":"DEMO_BASELINE_DISTANCE","severity":"INFO","description":"与基线对象保持可解释距离"}]'::jsonb,
md5('spatial-rules-demo-safety-v1'),'APPROVED','PUBLISHED','system',now(),now()
) on conflict (id) do nothing;
insert into spatial_exemptions(
id,exemption_type,geom,applies_to_scenes,applies_to_object_types,reason,effective_from,effective_to,
approval_status,approved_by,status,created_by,created_at,updated_at
) values (
'spatial-exemption-demo-maintenance','MAINTENANCE_WINDOW',
ST_GeomFromText('POLYGON((116.121 39.111,116.134 39.111,116.134 39.118,116.121 39.118,116.121 39.111))',4326),
'["桥梁裂缝","异物侵限"]'::jsonb,'["BRIDGE","RAILWAY"]'::jsonb,'DEMO 施工维护窗口',
now() - interval '1 day',now() + interval '30 days','APPROVED','user-approver','ACTIVE','system',now(),now()
) on conflict (id) do nothing;
insert into geofences(
id,code,name,fence_type,geom,altitude_min_m,altitude_max_m,effective_from,effective_to,severity,
dataset_version_id,status,created_by,created_at,updated_at
) values (
'geofence-demo-construction','DEMO_CONSTRUCTION_ZONE','DEMO 施工警示区','WARNING',
ST_GeomFromText('POLYGON((116.145 39.101,116.152 39.101,116.152 39.108,116.145 39.108,116.145 39.101))',4326),
0,120,now() - interval '1 day',now() + interval '90 days','WARNING','gis-dataset-demo-line-v1','ACTIVE','system',now(),now()
) on conflict (code) do nothing;
insert into capability_completion_status(
capability_code, capability_name, stage, status, implementation_ref, blocker_type, blocker_reason, updated_at
) values
('PF-01', '巡检对象台账与空间导入', 'M1', 'PARTIAL', 'inspection_object_import_jobs, inspection_object_versions', 'PRODUCTION_MASTER_DATA', '软件导入闭环已具备,生产验收需真实字段字典、责任单位和坐标说明', now()),
('PF-02', '日常、专项、应急计划调度', 'M1', 'PARTIAL', 'business_calendars, plan_executions, PlanSchedulerJob', 'BUSINESS_CALENDAR', '调度与补跑闭环已具备,生产验收需正式汛期、天窗和授权规则', now()),
('PF-05', '设备、航线和作业资源冲突检查', 'M1-M2', 'PARTIAL', 'operation_resources, conflict_check_runs, resource_reservations', 'RESOURCE_MASTER_DATA', '资源日历和冲突引擎已具备,生产验收需真实设备、机巢、载荷和飞手资质', now()),
('PF-08', 'GIS 图层与空间数据集管理', 'M1', 'PARTIAL', 'gis_dataset_versions, gis_layers, gis_spatial_features', 'AUTHORITATIVE_GIS_DATA', '图层版本和查询闭环已具备,生产验收需权威线路设施图层', now()),
('PF-09', '五类对象候选航线自动生成', 'M1-M3', 'PARTIAL', 'route_generation_profiles, route_generation_runs', 'FIELD_ROUTE_PARAMETERS', '五类生成器可产出候选航线,生产验收需现场安全参数和复核', now()),
('PF-10', '航线地图编辑与动作 Schema', 'M1', 'PARTIAL', 'route_editor_revisions, route_action_schemas', 'DEVICE_ACTION_MATRIX', '航点编辑、审计和通用动作 Schema 已具备,生产验收需厂商设备动作矩阵', now()),
('PF-11', '航程、电量、时长和容量估算', 'M1-M2', 'PARTIAL', 'route_estimation_profiles, route_estimation_runs', 'DEVICE_CALIBRATION', '估算闭环已具备,生产验收需真机能耗和载荷容量校准', now()),
('PF-15', '基线、豁免区和地理围栏', 'M2', 'PARTIAL', 'spatial_rule_sets, spatial_exemptions, geofences, spatial_rule_hits', 'SPATIAL_RULE_DATA', '规则版本、豁免和围栏管理已具备,生产验收需正式基线和审批规则', now())
on conflict (capability_code) do update
set status=excluded.status,
implementation_ref=excluded.implementation_ref,
blocker_type=excluded.blocker_type,
blocker_reason=excluded.blocker_reason,
updated_at=excluded.updated_at;