diff --git a/MiGu.Server/Auth/PageCatalog.cs b/MiGu.Server/Auth/PageCatalog.cs index 248329c..06ba9c7 100644 --- a/MiGu.Server/Auth/PageCatalog.cs +++ b/MiGu.Server/Auth/PageCatalog.cs @@ -45,7 +45,8 @@ public static class PageCatalog new("admin-config-system", "系统级配置", "平台配置中心", ScopePlatform), new("admin-config-integrations", "外部系统对接", "平台配置中心", ScopePlatform), new("admin-config-routing", "路径规划", "平台配置中心", ScopePlatform), - new("admin-config-vehicle", "车辆维护", "平台配置中心", ScopePlatform), + new("admin-config-vehicle", "车辆维护策略", "平台配置中心", ScopePlatform), + new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform), new("admin-config-charge", "充电策略", "平台配置中心", ScopePlatform), new("admin-config-task", "任务分配", "平台配置中心", ScopePlatform), new("admin-config-traffic", "交通管制", "平台配置中心", ScopePlatform), @@ -60,6 +61,7 @@ public static class PageCatalog // ── 运营端 / RCSMonitor ── new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor), + new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor), new("monitor-map", "地图监控", "运营监控", ScopeMonitor), new("monitor-ops", "运维操作", "运营监控", ScopeMonitor), new("monitor-notes", "运营备注", "运营监控", ScopeMonitor), diff --git a/frontends/apps/simple-platform-vue/components.d.ts b/frontends/apps/simple-platform-vue/components.d.ts index b36d5d3..b9f08b3 100644 --- a/frontends/apps/simple-platform-vue/components.d.ts +++ b/frontends/apps/simple-platform-vue/components.d.ts @@ -88,6 +88,7 @@ declare module 'vue' { SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default'] ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default'] ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default'] + VehicleHealthCard: typeof import('./src/components/fleet/VehicleHealthCard.vue')['default'] VehicleMonitorPanel: typeof import('./src/components/workbench/VehicleMonitorPanel.vue')['default'] WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default'] Workspace3D: typeof import('./src/components/Workspace3D.vue')['default'] diff --git a/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts b/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts new file mode 100644 index 0000000..7488b73 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/fleetHealth.ts @@ -0,0 +1,37 @@ +import http from './http' +import type { FleetHealthRow } from '@/types/car' +import { CARS } from '@/mock/data/cars' + +const MOCK = import.meta.env.VITE_USE_MOCK === 'true' + +function mockFleetHealth(): FleetHealthRow[] { + return CARS.map((c, i) => { + const rawId = c.rawId ?? (parseInt(c.id.replace(/\D/g, ''), 10) || i + 1) + const ip = c.ip ?? `10.0.1.${10 + i}` + const reachable = c.state !== 'offline' + return { + carId: rawId, + carName: c.name, + ip, + onboardUrl: c.onboardUrl ?? `http://${ip}:8081`, + latencyMs: reachable ? 8 + i * 4 : 2000, + reachable, + probedAt: new Date().toISOString(), + uptimeSecs: 3600 + i * 120, + alarmActiveSecs: c.state === 'fault' ? 120 : i * 5, + faultRatePercent: c.state === 'fault' ? 3.2 : 0.1 * i, + isAlarmActive: c.state === 'fault', + cpuPercent: 20 + i * 8, + memPercent: 40 + i * 5 + } + }) +} + +export async function fetchFleetHealth(): Promise { + if (MOCK) { + await new Promise((r) => setTimeout(r, 120)) + return mockFleetHealth() + } + const { data } = await http.get('/sl/projection/fleet/health') + return Array.isArray(data) ? data : [] +} diff --git a/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts b/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts new file mode 100644 index 0000000..a7cb79d --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/vehicleOps.ts @@ -0,0 +1,30 @@ +import { reflectionApi } from './reflection' + +export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown' + +const METHOD_MAP: Record = { + online: 'OnlineCar', + offline: 'OfflineCar', + repair: 'Repair', + blown: 'Blown' +} + +export async function setVehicleMaintenance( + carId: number, + mode: VehicleMaintenanceMode +): Promise { + const method = METHOD_MAP[mode] + if (!method) return false + try { + await reflectionApi.execute('car', carId, method) + return true + } catch { + return false + } +} + +export function openOnboardWeb(url?: string | null, ip?: string | null): void { + const target = url ?? (ip ? `http://${ip}:8081` : null) + if (!target) return + window.open(target, '_blank', 'noopener,noreferrer') +} diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthCard.vue b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthCard.vue new file mode 100644 index 0000000..c48ba02 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthCard.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts new file mode 100644 index 0000000..2b123d3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts @@ -0,0 +1,154 @@ +import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { listCars } from '@/api/projection' +import { fetchFleetHealth } from '@/api/fleetHealth' +import { useProjectionStream } from '@/composables/useProjectionStream' +import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car' + +const CAR_POLL_MS = 5000 +const HEALTH_POLL_MS = 20000 + +function inferMaintenanceMode(car: Car): VehicleCardModel['maintenanceMode'] { + const s = (car.lstatus ?? '').toLowerCase() + if (/返厂|blown/.test(s)) return 'blown' + if (/现场检修|repair/.test(s)) return 'repair' + if (/下线|offline/.test(s) || car.state === 'offline') return 'offline' + return 'online' +} + +function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel { + return { + ...car, + ip: health?.ip ?? car.ip, + onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined), + latencyMs: health?.latencyMs, + reachable: health?.reachable, + faultRatePercent: health?.faultRatePercent, + isAlarmActive: health?.isAlarmActive ?? car.state === 'fault', + cpuPercent: health?.cpuPercent, + memPercent: health?.memPercent, + maintenanceMode: inferMaintenanceMode(car) + } +} + +export function useVehicleHub() { + const cars = shallowRef([]) + const healthRows = shallowRef([]) + const loading = ref(false) + const healthLoading = ref(false) + const selectedId = ref(null) + + let carTimer: ReturnType | null = null + let healthTimer: ReturnType | null = null + + const healthByCarId = computed(() => { + const map = new Map() + for (const row of healthRows.value) { + map.set(row.carId, row) + } + return map + }) + + const cardModels = computed(() => + cars.value.map((car) => { + const rawId = car.rawId ?? parseInt(car.id.replace(/\D/g, ''), 10) + const health = Number.isFinite(rawId) ? healthByCarId.value.get(rawId) : undefined + return mergeCarHealth(car, health) + }) + ) + + const totalCount = computed(() => cardModels.value.length) + const onlineCount = computed(() => cardModels.value.filter((c) => c.state !== 'offline' && c.maintenanceMode === 'online').length) + const maintenanceCount = computed(() => + cardModels.value.filter((c) => c.maintenanceMode === 'offline' || c.maintenanceMode === 'repair' || c.maintenanceMode === 'blown').length + ) + const alarmCount = computed(() => cardModels.value.filter((c) => c.isAlarmActive).length) + const unreachableCount = computed(() => cardModels.value.filter((c) => c.reachable === false).length) + + async function loadCars() { + loading.value = true + try { + cars.value = await listCars() + } finally { + loading.value = false + } + } + + async function loadHealth() { + healthLoading.value = true + try { + healthRows.value = await fetchFleetHealth() + } finally { + healthLoading.value = false + } + } + + async function refreshAll() { + await Promise.all([loadCars(), loadHealth()]) + } + + function patchCarFromStream(rawId: number, patch: Partial) { + const idx = cars.value.findIndex((c) => (c.rawId ?? parseInt(c.id.replace(/\D/g, ''), 10)) === rawId) + if (idx < 0) return + const next = [...cars.value] + next[idx] = { ...next[idx], ...patch, lastUpdate: new Date().toISOString() } + cars.value = next + } + + const stream = useProjectionStream({ + extraEventKinds: ['alarm', 'car-state'], + autoConnect: import.meta.env.VITE_USE_MOCK !== 'true' + }) + + stream.on((evt) => { + if (evt.kind === 'car-state' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string } + const rawId = p.rawId + if (rawId != null) { + patchCarFromStream(rawId, { + state: p.state as Car['state'], + lstatus: p.lstatus + }) + } + } + if (evt.kind === 'alarm' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { carId?: number; action?: string } + if (p.carId != null) { + const isActive = p.action === 'raise' || p.action === 'update' + const idx = healthRows.value.findIndex((r) => r.carId === p.carId) + if (idx >= 0) { + const next = [...healthRows.value] + next[idx] = { ...next[idx], isAlarmActive: isActive } + healthRows.value = next + } + patchCarFromStream(p.carId, { state: isActive ? 'fault' : undefined }) + } + } + }) + + onMounted(() => { + void refreshAll() + carTimer = setInterval(() => void loadCars(), CAR_POLL_MS) + healthTimer = setInterval(() => void loadHealth(), HEALTH_POLL_MS) + }) + + onUnmounted(() => { + if (carTimer) clearInterval(carTimer) + if (healthTimer) clearInterval(healthTimer) + stream.disconnect() + }) + + return { + cardModels, + loading, + healthLoading, + selectedId, + totalCount, + onlineCount, + maintenanceCount, + alarmCount, + unreachableCount, + refreshAll, + loadCars, + loadHealth + } +} diff --git a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue index 7a067e1..df42307 100644 --- a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue +++ b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue @@ -162,7 +162,8 @@ const ADMIN_MENU: MenuItem[] = [ { path: '/admin/config/system', label: '系统级配置', key: 'admin-config-system' }, { path: '/admin/config/integrations', label: '外部系统对接', key: 'admin-config-integrations' }, { path: '/admin/config/routing', label: '路径规划', key: 'admin-config-routing' }, - { path: '/admin/config/vehicle', label: '车辆维护', key: 'admin-config-vehicle' }, + { path: '/admin/config/vehicle', label: '车辆维护策略', key: 'admin-config-vehicle' }, + { path: '/admin/config/vehicle-hub', label: '车辆运维', key: 'admin-vehicle-hub' }, { path: '/admin/config/charge', label: '充电策略', key: 'admin-config-charge' }, { path: '/admin/config/task', label: '任务分配', key: 'admin-config-task' }, { path: '/admin/config/traffic', label: '交通管制', key: 'admin-config-traffic' }, @@ -179,6 +180,7 @@ const ADMIN_MENU: MenuItem[] = [ const MONITOR_MENU: MenuItem[] = [ { path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard' }, + { path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub' }, { path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map' }, { path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops' }, { path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes' } diff --git a/frontends/apps/simple-platform-vue/src/mock/data/cars.ts b/frontends/apps/simple-platform-vue/src/mock/data/cars.ts index 2e39d39..d400d97 100644 --- a/frontends/apps/simple-platform-vue/src/mock/data/cars.ts +++ b/frontends/apps/simple-platform-vue/src/mock/data/cars.ts @@ -3,10 +3,10 @@ import type { Car } from '@/types/car' const NOW = new Date().toISOString() export const CARS: Car[] = [ - { id: 'C01', name: 'AGV-001', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1200, y: 2100, theta: 0, batterySoc: 0.86, state: 'running', missionId: 'M01', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.11' }, - { id: 'C02', name: 'AGV-002', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 2900, y: 2100, theta: 90, batterySoc: 0.42, state: 'running', missionId: 'M02', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.12' }, - { id: 'C03', name: 'AGV-003', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1000, y: 4900, theta: 180, batterySoc: 1.0, state: 'charging', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.13' }, - { id: 'C04', name: 'AGV-004', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7000, y: 2050, theta: 0, batterySoc: 0.71, state: 'idle', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.14' }, - { id: 'C05', name: 'AGV-005', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7050, y: 4000, theta: 270, batterySoc: 0.18, state: 'fault', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.15' }, - { id: 'C06', name: 'AGV-006', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 9000, y: 2000, theta: 0, batterySoc: 0.95, state: 'offline', lastUpdate: NOW, group: '维护', ip: '10.0.1.16' } + { id: 'C01', rawId: 1, name: 'AGV-001', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1200, y: 2100, theta: 0, batterySoc: 0.86, state: 'running', missionId: 'M01', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.11', onboardUrl: 'http://10.0.1.11:8081', lstatus: '运行' }, + { id: 'C02', rawId: 2, name: 'AGV-002', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 2900, y: 2100, theta: 90, batterySoc: 0.42, state: 'running', missionId: 'M02', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.12', onboardUrl: 'http://10.0.1.12:8081', lstatus: '运行' }, + { id: 'C03', rawId: 3, name: 'AGV-003', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1000, y: 4900, theta: 180, batterySoc: 1.0, state: 'charging', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.13', onboardUrl: 'http://10.0.1.13:8081', lstatus: '充电' }, + { id: 'C04', rawId: 4, name: 'AGV-004', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7000, y: 2050, theta: 0, batterySoc: 0.71, state: 'idle', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.14', onboardUrl: 'http://10.0.1.14:8081', lstatus: '空闲' }, + { id: 'C05', rawId: 5, name: 'AGV-005', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7050, y: 4000, theta: 270, batterySoc: 0.18, state: 'fault', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.15', onboardUrl: 'http://10.0.1.15:8081', lstatus: '故障' }, + { id: 'C06', rawId: 6, name: 'AGV-006', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 9000, y: 2000, theta: 0, batterySoc: 0.95, state: 'offline', lastUpdate: NOW, group: '维护', ip: '10.0.1.16', onboardUrl: 'http://10.0.1.16:8081', lstatus: '下线' } ] diff --git a/frontends/apps/simple-platform-vue/src/mock/rbac.ts b/frontends/apps/simple-platform-vue/src/mock/rbac.ts index aae909f..c929b3f 100644 --- a/frontends/apps/simple-platform-vue/src/mock/rbac.ts +++ b/frontends/apps/simple-platform-vue/src/mock/rbac.ts @@ -23,7 +23,8 @@ const PAGES: PageDef[] = [ { key: 'admin-config-system', label: '系统级配置', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-integrations', label: '外部系统对接', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-routing', label: '路径规划', group: '平台配置中心', scope: 'Platform' }, - { key: 'admin-config-vehicle', label: '车辆维护', group: '平台配置中心', scope: 'Platform' }, + { key: 'admin-config-vehicle', label: '车辆维护策略', group: '平台配置中心', scope: 'Platform' }, + { key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-charge', label: '充电策略', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-task', label: '任务分配', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-traffic', label: '交通管制', group: '平台配置中心', scope: 'Platform' }, @@ -36,6 +37,7 @@ const PAGES: PageDef[] = [ { key: 'admin-config-widget', label: '自定义控件', group: '平台配置中心', scope: 'Platform' }, { key: 'admin-config-map-monitor', label: '地图监控配置', group: '平台配置中心', scope: 'Platform' }, { key: 'monitor-dashboard', label: '运营总览', group: '运营监控', scope: 'RCSMonitor' }, + { key: 'monitor-vehicle-hub', label: '车辆运维', group: '运营监控', scope: 'RCSMonitor' }, { key: 'monitor-map', label: '地图监控', group: '运营监控', scope: 'RCSMonitor' }, { key: 'monitor-ops', label: '运维操作', group: '运营监控', scope: 'RCSMonitor' }, { key: 'monitor-notes', label: '运营备注', group: '运营监控', scope: 'RCSMonitor' } @@ -82,7 +84,7 @@ function seed(): RbacState { { id: 'role-ops', name: '运营人员', description: '运营监控端默认角色:可执行运维操作、查看监控', scope: 'RCSMonitor', - pages: ['monitor-dashboard', 'monitor-map', 'monitor-ops', 'monitor-notes'], + pages: ['monitor-dashboard', 'monitor-map', 'monitor-ops', 'monitor-notes', 'monitor-vehicle-hub'], ops: [ 'ops.car.pause', 'ops.car.resume', 'ops.car.gohome', 'ops.car.resetSession', 'ops.car.manualCharge', 'ops.task.pause', 'ops.task.cancel', 'ops.task.reassign', diff --git a/frontends/apps/simple-platform-vue/src/router/index.ts b/frontends/apps/simple-platform-vue/src/router/index.ts index f703627..626c944 100644 --- a/frontends/apps/simple-platform-vue/src/router/index.ts +++ b/frontends/apps/simple-platform-vue/src/router/index.ts @@ -35,6 +35,8 @@ const routes: RouteRecordRaw[] = [ { path: 'config/integrations', name: 'admin-config-integrations', component: () => import('@/views/admin/config/ExternalIntegrationView.vue'), meta: { title: '外部系统对接' } }, { path: 'config/routing', name: 'admin-config-routing', component: () => import('@/views/admin/config/RoutingPolicyView.vue'), meta: { title: '路径规划策略' } }, { path: 'config/vehicle', name: 'admin-config-vehicle', component: () => import('@/views/admin/config/VehicleMaintenanceView.vue'), meta: { title: '车辆维护策略' } }, + { path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } }, + { path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' }, { path: 'config/charge', name: 'admin-config-charge', component: () => import('@/views/admin/config/ChargePolicyView.vue'), meta: { title: '充电逻辑' } }, { path: 'config/task', name: 'admin-config-task', component: () => import('@/views/admin/config/TaskAllocationView.vue'), meta: { title: '任务分配' } }, { path: 'config/traffic', name: 'admin-config-traffic', component: () => import('@/views/admin/config/TrafficRuleView.vue'), meta: { title: '交通管制' } }, @@ -55,6 +57,7 @@ const routes: RouteRecordRaw[] = [ redirect: '/monitor/map', children: [ { path: 'dashboard', name: 'monitor-dashboard', component: () => import('@/views/monitor/MonitorDashboardView.vue'), meta: { title: '运营总览' } }, + { path: 'vehicle-hub', name: 'monitor-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } }, { path: 'map', name: 'monitor-map', component: () => import('@/views/monitor/MonitorMapView.vue'), meta: { title: '地图监控' } }, { path: 'ops', name: 'monitor-ops', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维操作' } }, { path: 'notes', name: 'monitor-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运营备注' } } diff --git a/frontends/apps/simple-platform-vue/src/types/car.ts b/frontends/apps/simple-platform-vue/src/types/car.ts index f30fcdc..5773369 100644 --- a/frontends/apps/simple-platform-vue/src/types/car.ts +++ b/frontends/apps/simple-platform-vue/src/types/car.ts @@ -19,5 +19,36 @@ export interface Car { missionId?: string lastUpdate: string group?: string + address?: string ip?: string + onboardUrl?: string + lstatus?: string +} + +/** 车队健康探测行(GET /sl/projection/fleet/health) */ +export interface FleetHealthRow { + carId: number + carName?: string + ip?: string + onboardUrl?: string + latencyMs?: number + reachable?: boolean + probedAt?: string + uptimeSecs?: number + alarmActiveSecs?: number + faultRatePercent?: number + isAlarmActive?: boolean + cpuPercent?: number + memPercent?: number +} + +/** 车辆运维卡片合并模型 */ +export interface VehicleCardModel extends Car { + latencyMs?: number + reachable?: boolean + faultRatePercent?: number + isAlarmActive?: boolean + cpuPercent?: number + memPercent?: number + maintenanceMode?: 'online' | 'offline' | 'repair' | 'blown' } diff --git a/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue new file mode 100644 index 0000000..6ba1f6c --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue @@ -0,0 +1,234 @@ + + + + +