+
-
+
提示:以下已分配的车辆 ID 不在当前在册车辆中:{{ unknownCarIds.join('、') }}
@@ -85,15 +92,19 @@
diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue
new file mode 100644
index 0000000..c81720e
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+ {{ vehicle.name }}
+ {{ vehicle.id }}{{ missionIdSuffix }}
+
+
+
{{ statusDisplayLabel }}
+
+
+
+
{{ vehicle.ip ?? '—' }}
+
{{ latencyLabel }}
+
{{ faultLabel }}
+
{{ vehicle.group ?? '—' }}
+
+
+ 报警
+ 不可达
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue
new file mode 100644
index 0000000..0e4f226
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts b/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts
new file mode 100644
index 0000000..c483e69
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts
@@ -0,0 +1,145 @@
+import { computed, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries'
+import {
+ defaultQuickKeys, getQuickEntryCatalog, MAX_QUICK_ENTRIES,
+ normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef
+} from '@/config/quickEntries'
+import { useAuthStore } from '@/stores/auth'
+import type { Scope } from '@/types/auth'
+
+export function useDashboardQuickEntries() {
+ const auth = useAuthStore()
+ const keys = ref
([])
+ const loading = ref(false)
+ const usingDefaults = ref(true)
+ const pickerOpen = ref(false)
+
+ const scope = computed(() => auth.scope ?? 'Platform')
+ const userId = computed(() => auth.user?.id ?? '')
+
+ /** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */
+ function effectiveKeys(): string[] {
+ if (keys.value.length > 0) return keys.value
+ return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : []
+ }
+
+ function filterByPermission(list: string[]): string[] {
+ return list.filter((key) => {
+ const def = resolveQuickEntry(key, scope.value as Scope)
+ return def && auth.hasPage(def.pageKey)
+ })
+ }
+
+ const resolvedEntries = computed(() => {
+ return filterByPermission(effectiveKeys())
+ .slice(0, MAX_QUICK_ENTRIES)
+ .map((k) => resolveQuickEntry(k, scope.value as Scope))
+ .filter((d): d is QuickEntryDef => !!d)
+ })
+
+ const pinnedKeys = computed(() => filterByPermission(effectiveKeys()))
+
+ /** 弹窗展示全部可访问菜单(含已固定项,已固定项在弹窗内置灰不可选) */
+ const pickerCatalog = computed(() =>
+ getQuickEntryCatalog(scope.value as Scope).filter((item) => auth.hasPage(item.pageKey))
+ )
+
+ const canAddMore = computed(() => pinnedKeys.value.length < MAX_QUICK_ENTRIES)
+
+ const availableToAdd = computed(() => {
+ const current = new Set(pinnedKeys.value)
+ return pickerCatalog.value.filter((item) => !current.has(item.key))
+ })
+
+ async function load() {
+ if (!userId.value) {
+ keys.value = defaultQuickKeys(scope.value as Scope)
+ usingDefaults.value = true
+ return
+ }
+ loading.value = true
+ try {
+ const dto = await fetchQuickEntryKeys(userId.value, scope.value)
+ const loaded = normalizeQuickKeys(dto.keys)
+ keys.value = loaded.length > 0
+ ? loaded
+ : (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : [])
+ usingDefaults.value = dto.usingDefaults
+ } catch (e) {
+ keys.value = defaultQuickKeys(scope.value as Scope)
+ usingDefaults.value = true
+ ElMessage.warning(`加载快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function persist(nextKeys: string[]) {
+ if (!userId.value) {
+ keys.value = nextKeys
+ return
+ }
+ try {
+ const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys))
+ keys.value = normalizeQuickKeys(dto.keys)
+ usingDefaults.value = dto.usingDefaults
+ } catch (e) {
+ ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
+ throw e
+ }
+ }
+
+ async function addKey(key: string) {
+ const base = [...effectiveKeys()]
+ if (base.includes(key)) {
+ ElMessage.info('该菜单已在快捷入口中')
+ return
+ }
+ if (base.length >= MAX_QUICK_ENTRIES) {
+ ElMessage.warning(`快捷入口最多 ${MAX_QUICK_ENTRIES} 个`)
+ return
+ }
+ await persist([...base, key])
+ ElMessage.success('已添加快捷入口')
+ }
+
+ async function removeKey(key: string) {
+ const base = [...effectiveKeys()]
+ await persist(base.filter((k) => k !== key))
+ ElMessage.success('已移除快捷入口')
+ }
+
+ async function swapKeys(keyA: string, keyB: string) {
+ if (keyA === keyB || keyA === 'add' || keyB === 'add') return
+ const list = [...pinnedKeys.value]
+ const i = list.indexOf(keyA)
+ const j = list.indexOf(keyB)
+ if (i < 0 || j < 0 || i === j) return
+ ;[list[i], list[j]] = [list[j], list[i]]
+ await persist(list)
+ }
+
+ function openPicker() {
+ pickerOpen.value = true
+ }
+
+ watch([userId, scope], () => { void load() }, { immediate: true })
+
+ return {
+ keys,
+ loading,
+ usingDefaults,
+ pickerOpen,
+ resolvedEntries,
+ pickerCatalog,
+ pinnedKeys,
+ availableToAdd,
+ canAddMore,
+ load,
+ addKey,
+ removeKey,
+ swapKeys,
+ openPicker
+ }
+}
diff --git a/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts b/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts
new file mode 100644
index 0000000..e41e188
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts
@@ -0,0 +1,126 @@
+import { ref } from 'vue'
+
+const LONG_PRESS_MS = 450
+const PRE_DRAG_MOVE_PX = 10
+
+export interface QuickDragTile {
+ key: string
+ label: string
+ icon: unknown
+ primary?: boolean
+}
+
+export function useQuickEntryDragSwap(
+ swapKeys: (keyA: string, keyB: string) => Promise
+) {
+ const dragKey = ref(null)
+ const hoverTargetKey = ref(null)
+ const ghostPos = ref({ x: 0, y: 0 })
+
+ let pressTimer: ReturnType | null = null
+ let suppressClick = false
+ let active = false
+
+ function clearPressTimer() {
+ if (pressTimer) {
+ clearTimeout(pressTimer)
+ pressTimer = null
+ }
+ }
+
+ function findTargetKey(clientX: number, clientY: number, sourceKey: string): string | null {
+ const el = document.elementFromPoint(clientX, clientY)
+ const tile = el?.closest('[data-quick-key]') as HTMLElement | null
+ const key = tile?.dataset.quickKey
+ if (!key || key === 'add' || key === sourceKey) return null
+ return key
+ }
+
+ function onPointerDown(item: QuickDragTile, e: PointerEvent) {
+ if (item.key === 'add' || e.button !== 0) return
+
+ const target = e.currentTarget as HTMLElement
+ const startX = e.clientX
+ const startY = e.clientY
+ let dragging = false
+
+ clearPressTimer()
+ active = true
+
+ const cleanup = () => {
+ clearPressTimer()
+ active = false
+ dragging = false
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp)
+ window.removeEventListener('pointercancel', onUp)
+ }
+
+ const onMove = (ev: PointerEvent) => {
+ if (!dragging) {
+ const dx = ev.clientX - startX
+ const dy = ev.clientY - startY
+ if (dx * dx + dy * dy > PRE_DRAG_MOVE_PX * PRE_DRAG_MOVE_PX) {
+ clearPressTimer()
+ }
+ return
+ }
+
+ ghostPos.value = { x: ev.clientX, y: ev.clientY }
+ hoverTargetKey.value = findTargetKey(ev.clientX, ev.clientY, item.key)
+ }
+
+ const onUp = async (ev: PointerEvent) => {
+ clearPressTimer()
+
+ if (dragging) {
+ suppressClick = true
+ const from = item.key
+ const to = hoverTargetKey.value ?? findTargetKey(ev.clientX, ev.clientY, from)
+ dragKey.value = null
+ hoverTargetKey.value = null
+ if (to) {
+ try {
+ await swapKeys(from, to)
+ } catch {
+ /* persist failed */
+ }
+ }
+ }
+
+ cleanup()
+ }
+
+ pressTimer = setTimeout(() => {
+ pressTimer = null
+ dragging = true
+ suppressClick = false
+ dragKey.value = item.key
+ ghostPos.value = { x: e.clientX, y: e.clientY }
+ hoverTargetKey.value = null
+ try {
+ target.setPointerCapture(e.pointerId)
+ } catch {
+ /* ignore */
+ }
+ }, LONG_PRESS_MS)
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp)
+ window.addEventListener('pointercancel', onUp)
+ }
+
+ function shouldSuppressClick(): boolean {
+ if (!suppressClick) return false
+ suppressClick = false
+ return true
+ }
+
+ return {
+ dragKey,
+ hoverTargetKey,
+ ghostPos,
+ onPointerDown,
+ shouldSuppressClick
+ }
+}
diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts
new file mode 100644
index 0000000..56ba4ca
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts
@@ -0,0 +1,147 @@
+import { computed, toValue, type MaybeRefOrGetter } from 'vue'
+import type { VehicleCardModel } from '@/types/car'
+
+export interface VehicleCardStateOptions {
+ vehicle: MaybeRefOrGetter
+}
+
+const stateLabels: Record = {
+ idle: '空闲',
+ running: '运行',
+ charging: '充电',
+ paused: '暂停',
+ fault: '故障',
+ offline: '离线'
+}
+
+function formatMissionIdSuffix(missionId?: string | number | null): string {
+ if (missionId == null) return ''
+ const id = String(missionId).trim()
+ if (!id || id === '0') return ''
+ return `-${id}`
+}
+
+function appendMissionId(base: string, missionId?: string | number | null): string {
+ const suffix = formatMissionIdSuffix(missionId)
+ return suffix ? `${base}${suffix}` : base
+}
+
+export function useVehicleCardState(opts: VehicleCardStateOptions) {
+ const vehicle = computed(() => toValue(opts.vehicle))
+
+ const stateLabel = computed(() => stateLabels[vehicle.value.state] ?? vehicle.value.state)
+
+ const batteryPct = computed(() => {
+ const raw = vehicle.value.batterySoc ?? 0
+ const pct = raw > 1 ? raw : raw * 100
+ return Math.max(0, Math.min(100, Math.round(pct)))
+ })
+
+ const batteryTone = computed(() => {
+ const p = batteryPct.value
+ if (p < 20) return 'tone-danger'
+ if (p < 50) return 'tone-warning'
+ return 'tone-success'
+ })
+
+ const switchOn = computed(() => vehicle.value.maintenanceMode === 'online')
+
+ const statusPill = computed(() => {
+ const v = vehicle.value
+ if (v.reachable === false) return { label: '不可达', tone: 'tone-danger' }
+ if (v.isAlarmActive) return { label: '报警中', tone: 'tone-danger' }
+ if (v.maintenanceMode === 'offline') return { label: '下线维护', tone: 'tone-warning' }
+ if (v.maintenanceMode === 'repair') return { label: '现场检修', tone: 'tone-warning' }
+ if (v.maintenanceMode === 'blown') return { label: '返厂检修', tone: 'tone-danger' }
+ if (v.state === 'fault') return { label: '故障', tone: 'tone-danger' }
+ if (v.state === 'running') return { label: '运行中', tone: 'tone-success' }
+ if (v.state === 'charging') return { label: '充电中', tone: 'tone-info' }
+ if (v.state === 'offline') return { label: '离线', tone: 'tone-idle' }
+ return { label: stateLabel.value, tone: 'tone-idle' }
+ })
+
+ const missionIdSuffix = computed(() => formatMissionIdSuffix(vehicle.value.missionId))
+
+ const statusDisplayLabel = computed(() =>
+ appendMissionId(statusPill.value.label, vehicle.value.missionId)
+ )
+
+ const runtimeStatusLabel = computed(() =>
+ appendMissionId(vehicle.value.lstatus ?? stateLabel.value, vehicle.value.missionId)
+ )
+
+ const accentTone = computed(() => statusPill.value.tone)
+
+ const latencyLabel = computed(() => {
+ const ms = vehicle.value.latencyMs
+ if (ms == null) return '—'
+ if (vehicle.value.reachable === false) return '超时'
+ return `${ms} ms`
+ })
+
+ const latencyClass = computed(() => {
+ const ms = vehicle.value.latencyMs
+ if (vehicle.value.reachable === false) return 'val-danger'
+ if (ms != null && ms > 80) return 'val-warn'
+ return ''
+ })
+
+ const faultLabel = computed(() => {
+ const v = vehicle.value.faultRatePercent
+ if (v == null) return '—'
+ return `${v.toFixed(2)}%`
+ })
+
+ const faultClass = computed(() => {
+ const v = vehicle.value.faultRatePercent ?? 0
+ if (v >= 5) return 'val-danger'
+ if (v >= 1) return 'val-warn'
+ return ''
+ })
+
+ const cpuLabel = computed(() => {
+ const v = vehicle.value.cpuPercent
+ return v != null ? `${Math.round(v)}%` : '—'
+ })
+
+ const memLabel = computed(() => {
+ const v = vehicle.value.memPercent
+ return v != null ? `${Math.round(v)}%` : '—'
+ })
+
+ const cpuChipClass = computed(() => {
+ const v = vehicle.value.cpuPercent
+ if (v == null) return ''
+ if (v >= 90) return 'val-danger'
+ if (v >= 75) return 'val-warn'
+ return 'val-ok'
+ })
+
+ const memChipClass = computed(() => {
+ const v = vehicle.value.memPercent
+ if (v == null) return ''
+ if (v >= 90) return 'val-danger'
+ if (v >= 75) return 'val-warn'
+ return 'val-ok'
+ })
+
+ return {
+ stateLabel,
+ batteryPct,
+ batteryTone,
+ switchOn,
+ statusPill,
+ missionIdSuffix,
+ statusDisplayLabel,
+ runtimeStatusLabel,
+ accentTone,
+ latencyLabel,
+ latencyClass,
+ faultLabel,
+ faultClass,
+ cpuLabel,
+ memLabel,
+ cpuChipClass,
+ memChipClass
+ }
+}
diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts
index 2b123d3..eb63631 100644
--- a/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts
+++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleHub.ts
@@ -1,8 +1,9 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
-import { listCars } from '@/api/projection'
+import { listCars, listMissions } from '@/api/projection'
import { fetchFleetHealth } from '@/api/fleetHealth'
import { useProjectionStream } from '@/composables/useProjectionStream'
import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car'
+import type { Mission, MissionStatus } from '@/types/mission'
const CAR_POLL_MS = 5000
const HEALTH_POLL_MS = 20000
@@ -15,9 +16,41 @@ function inferMaintenanceMode(car: Car): VehicleCardModel['maintenanceMode'] {
return 'online'
}
-function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
+function missionStatusOrder(status: MissionStatus): number {
+ if (status === 'running') return 0
+ if (status === 'paused') return 1
+ if (status === 'assigned') return 2
+ return 3
+}
+
+function resolveMissionId(car: Car, missions: Mission[]): string | undefined {
+ if (car.missionId) {
+ const id = String(car.missionId).trim()
+ if (id && id !== '0') return id
+ }
+
+ const rawKey = car.rawId != null ? String(car.rawId) : undefined
+ let best: Mission | undefined
+
+ for (const mission of missions) {
+ if (!mission.carId) continue
+ if (mission.carId !== car.id && mission.carId !== rawKey) continue
+ if (mission.status !== 'running' && mission.status !== 'paused' && mission.status !== 'assigned') {
+ continue
+ }
+ if (!best || missionStatusOrder(mission.status) < missionStatusOrder(best.status)) {
+ best = mission
+ }
+ }
+
+ return best?.id
+}
+
+function mergeCarHealth(car: Car, health?: FleetHealthRow, missions: Mission[] = []): VehicleCardModel {
+ const missionId = resolveMissionId(car, missions)
return {
...car,
+ missionId,
ip: health?.ip ?? car.ip,
onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined),
latencyMs: health?.latencyMs,
@@ -32,6 +65,7 @@ function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
export function useVehicleHub() {
const cars = shallowRef([])
+ const missions = shallowRef([])
const healthRows = shallowRef([])
const loading = ref(false)
const healthLoading = ref(false)
@@ -52,7 +86,7 @@ export function useVehicleHub() {
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)
+ return mergeCarHealth(car, health, missions.value)
})
)
@@ -67,7 +101,9 @@ export function useVehicleHub() {
async function loadCars() {
loading.value = true
try {
- cars.value = await listCars()
+ const [carList, missionList] = await Promise.all([listCars(), listMissions()])
+ cars.value = carList
+ missions.value = missionList
} finally {
loading.value = false
}
@@ -101,12 +137,13 @@ export function useVehicleHub() {
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 p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string; missionId?: string }
const rawId = p.rawId
if (rawId != null) {
patchCarFromStream(rawId, {
state: p.state as Car['state'],
- lstatus: p.lstatus
+ lstatus: p.lstatus,
+ missionId: p.missionId
})
}
}
diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts
new file mode 100644
index 0000000..f1acbd7
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts
@@ -0,0 +1,54 @@
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
+
+export const MAINTENANCE_OPTIONS: { value: VehicleMaintenanceMode; label: string }[] = [
+ { value: 'online', label: '上线' },
+ { value: 'offline', label: '下线维护' },
+ { value: 'repair', label: '现场检修' },
+ { value: 'blown', label: '返厂检修' }
+]
+
+export function maintenanceModeLabel(mode?: VehicleMaintenanceMode): string {
+ return MAINTENANCE_OPTIONS.find((o) => o.value === mode)?.label ?? '上线'
+}
+
+export async function confirmAndApplyMaintenance(
+ rawId: number | undefined,
+ mode: VehicleMaintenanceMode,
+ prevMode: VehicleMaintenanceMode
+): Promise {
+ if (!Number.isFinite(rawId)) return false
+ if (mode === prevMode) return false
+
+ try {
+ if (mode === 'blown') {
+ await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
+ type: 'error',
+ confirmButtonText: '确定',
+ cancelButtonText: '取消'
+ })
+ } else if (mode === 'repair') {
+ await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
+ type: 'warning',
+ confirmButtonText: '确定',
+ cancelButtonText: '取消'
+ })
+ } else if (mode === 'online' || mode === 'offline') {
+ await ElMessageBox.confirm(
+ mode === 'online' ? '确认将车辆上线?' : '确认将车辆下线维护?',
+ '维护确认',
+ { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
+ )
+ }
+
+ const ok = await setVehicleMaintenance(rawId!, mode)
+ if (ok) {
+ ElMessage.success('维护状态已更新')
+ return true
+ }
+ ElMessage.error('维护操作失败')
+ return false
+ } catch {
+ return false
+ }
+}
diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts
new file mode 100644
index 0000000..1223619
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts
@@ -0,0 +1,62 @@
+import {
+ Collection, Connection, Cpu, Document, DocumentCopy, EditPen,
+ Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding,
+ Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera
+} from '@element-plus/icons-vue'
+import type { Component } from 'vue'
+
+export interface NavMenuItem {
+ path: string
+ label: string
+ icon?: Component
+ key?: string
+ group?: string
+ children?: NavMenuItem[]
+}
+
+export const ADMIN_MENU: NavMenuItem[] = [
+ { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
+ { path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor', group: '概览' },
+ {
+ path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排',
+ children: [
+ { path: '/admin/maps', label: '地图管理', icon: MapLocation, key: 'admin-maps', group: '设计与编排' },
+ { path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' },
+ { path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' },
+ { path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' },
+ { path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' },
+ { path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' },
+ { path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编排' },
+ { path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' },
+ { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' }
+ ]
+ },
+ {
+ path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
+ children: [
+ { path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' },
+ { path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' },
+ { path: '/admin/config/facility', label: '设备与库位', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' },
+ { path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' },
+ { path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' },
+ { path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' }
+ ]
+ }
+]
+
+export const MONITOR_MENU: NavMenuItem[] = [
+ { path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' },
+ { path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' },
+ { path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' },
+ { path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
+ { path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
+]
+
+export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
+ const out: NavMenuItem[] = []
+ for (const item of items) {
+ if (item.children?.length) out.push(...flattenNavMenu(item.children))
+ else if (item.key) out.push(item)
+ }
+ return out
+}
diff --git a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts
new file mode 100644
index 0000000..4cad02b
--- /dev/null
+++ b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts
@@ -0,0 +1,110 @@
+import { Setting } from '@element-plus/icons-vue'
+import type { Component } from 'vue'
+import type { Scope } from '@/types/auth'
+import {
+ ADMIN_MENU, MONITOR_MENU, flattenNavMenu, type NavMenuItem
+} from '@/config/navMenu'
+
+export const MAX_QUICK_ENTRIES = 16
+export const QUICK_GRID_COLUMNS = 8
+
+export interface QuickEntryDef {
+ key: string
+ label: string
+ path: string
+ icon: Component
+ hint?: string
+ primary?: boolean
+ pageKey: string
+ group?: string
+}
+
+/** 当前页即总览,不作为快捷入口候选 */
+const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
+
+/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */
+const LEGACY_KEY_ALIASES: Record = {
+ 'platform-config': 'admin-map-editor',
+ mission: 'admin-task-templates',
+ cars: 'admin-cars',
+ auth: 'admin-config-system-center',
+ system: 'admin-config-system-center',
+ ops: 'admin-config-ops-center',
+ tasks: 'admin-config-strategy'
+}
+
+/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */
+export const DEFAULT_PLATFORM_QUICK_KEYS = [
+ 'admin-map-editor',
+ 'admin-task-templates',
+ 'admin-cars',
+ 'admin-config-system-center',
+ 'admin-config-ops-center',
+ 'admin-config-strategy'
+] as const
+
+export const DEFAULT_MONITOR_QUICK_KEYS = [
+ 'monitor-vehicle-hub', 'monitor-map', 'monitor-ops'
+] as const
+
+export function normalizeQuickKey(key: string): string {
+ return LEGACY_KEY_ALIASES[key] ?? key
+}
+
+export function normalizeQuickKeys(keys: string[]): string[] {
+ const seen = new Set()
+ const out: string[] = []
+ for (const raw of keys) {
+ const k = normalizeQuickKey(raw.trim())
+ if (!k || seen.has(k) || EXCLUDED_QUICK_ENTRY_KEYS.has(k)) continue
+ seen.add(k)
+ out.push(k)
+ }
+ return out
+}
+
+function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null {
+ if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null
+ return {
+ key: item.key,
+ label: item.label,
+ path: item.path,
+ icon: item.icon ?? Setting,
+ pageKey: item.key,
+ group: item.group
+ }
+}
+
+function buildCatalog(scope: Scope): Map {
+ const map = new Map()
+ const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
+ for (const item of flattenNavMenu(menu)) {
+ const q = menuItemToQuick(item)
+ if (q) map.set(q.key, q)
+ }
+ return map
+}
+
+export function getQuickEntryCatalog(scope: Scope): QuickEntryDef[] {
+ return [...buildCatalog(scope).values()]
+}
+
+export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | undefined {
+ return buildCatalog(scope).get(normalizeQuickKey(key))
+}
+
+export function defaultQuickKeys(scope: Scope): string[] {
+ return scope === 'RCSMonitor'
+ ? [...DEFAULT_MONITOR_QUICK_KEYS]
+ : [...DEFAULT_PLATFORM_QUICK_KEYS]
+}
+
+export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] {
+ const groups = new Map()
+ for (const item of items) {
+ const g = item.group ?? '其他'
+ if (!groups.has(g)) groups.set(g, [])
+ groups.get(g)!.push(item)
+ }
+ return [...groups.entries()].map(([group, list]) => ({ group, items: list }))
+}
diff --git a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue
index 76e8828..26ba643 100644
--- a/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue
+++ b/frontends/apps/simple-platform-vue/src/layouts/AppShell.vue
@@ -107,10 +107,8 @@