feat(vehicle-hub): 新增车辆运维页(健康监控 / 维护操作 / 批量管理)

- 新增 fleetHealth / vehicleOps API、useVehicleHub 组合式与 VehicleHealthCard 卡片
- 新增 /admin/config/vehicle-hub 与 /monitor/vehicle-hub 双端路由及侧栏菜单
- car 类型补充 rawId/onboardUrl/lstatus 及 FleetHealthRow/VehicleCardModel
- 后端 PageCatalog 注册「车辆运维」页,原「车辆维护」更名为「车辆维护策略」

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-31 00:16:16 +08:00
co-authored by Cursor
parent 34635dca05
commit 62943e6c42
12 changed files with 926 additions and 10 deletions
+1
View File
@@ -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']
@@ -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<FleetHealthRow[]> {
if (MOCK) {
await new Promise((r) => setTimeout(r, 120))
return mockFleetHealth()
}
const { data } = await http.get<FleetHealthRow[]>('/sl/projection/fleet/health')
return Array.isArray(data) ? data : []
}
@@ -0,0 +1,30 @@
import { reflectionApi } from './reflection'
export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
repair: 'Repair',
blown: 'Blown'
}
export async function setVehicleMaintenance(
carId: number,
mode: VehicleMaintenanceMode
): Promise<boolean> {
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')
}
@@ -0,0 +1,420 @@
<template>
<div
class="vehicle-health-card"
:class="cardClass"
@click="onCardClick"
@dblclick="onCardDblClick"
>
<div class="battery-strip">
<el-progress
:percentage="batteryPct"
:stroke-width="4"
:show-text="false"
:color="batteryColor"
/>
</div>
<div class="card-head">
<div class="title-block">
<span class="vid">{{ vehicle.id }}</span>
<span class="vname" :title="vehicle.name">{{ vehicle.name }}</span>
</div>
<div class="head-actions" @click.stop>
<el-dropdown trigger="click" @command="onMaintenanceCommand">
<el-button size="small" text :icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="online">上线</el-dropdown-item>
<el-dropdown-item command="offline">下线维护</el-dropdown-item>
<el-dropdown-item command="repair">现场检修</el-dropdown-item>
<el-dropdown-item command="blown" divided>返厂检修</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-switch
:model-value="switchOn"
size="small"
:disabled="!canWrite"
inline-prompt
active-text=""
inactive-text=""
@change="(v: string | number | boolean) => onToggleMaintenance(Boolean(v))"
/>
</div>
</div>
<div class="card-body">
<div class="thumb">
<el-icon :size="36"><Van /></el-icon>
</div>
<div class="metrics">
<div class="metric-row">
<span class="label">IP</span>
<span class="value mono">{{ vehicle.ip ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">延迟</span>
<span class="value" :class="latencyClass">{{ latencyLabel }}</span>
</div>
<div class="metric-row">
<span class="label">故障率</span>
<span class="value" :class="faultClass">{{ faultLabel }}</span>
</div>
<div class="metric-row">
<span class="label">群组</span>
<span class="value">{{ vehicle.group ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">状态</span>
<span class="value">{{ vehicle.lstatus ?? stateLabel }}</span>
</div>
</div>
</div>
<div class="icon-grid">
<div class="icon-cell" title="CPU">
<el-icon><Cpu /></el-icon>
<span>{{ cpuLabel }}</span>
</div>
<div class="icon-cell" title="内存">
<el-icon><Coin /></el-icon>
<span>{{ memLabel }}</span>
</div>
<div class="icon-cell" title="报警">
<el-icon :class="{ 'is-alarm': vehicle.isAlarmActive }"><Warning /></el-icon>
<span>{{ vehicle.isAlarmActive ? '报警' : '正常' }}</span>
</div>
<div class="icon-cell" title="连接">
<el-icon :class="{ 'is-down': vehicle.reachable === false }"><Connection /></el-icon>
<span>{{ vehicle.reachable === false ? '不可达' : '可达' }}</span>
</div>
</div>
<div class="card-foot">
<span>电量 {{ batteryPct }}%</span>
<span class="hint">双击打开车载界面</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Van, Cpu, Coin, Warning, Connection, MoreFilled } from '@element-plus/icons-vue'
import type { VehicleCardModel } from '@/types/car'
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
import { openOnboardWeb, setVehicleMaintenance } from '@/api/vehicleOps'
const props = defineProps<{
vehicle: VehicleCardModel
selected?: boolean
canWrite?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
maintenanceChanged: []
}>()
const stateLabels: Record<string, string> = {
idle: '空闲',
running: '运行',
charging: '充电',
paused: '暂停',
fault: '故障',
offline: '离线'
}
const stateLabel = computed(() => stateLabels[props.vehicle.state] ?? props.vehicle.state)
const batteryPct = computed(() => {
const raw = props.vehicle.batterySoc ?? 0
const pct = raw > 1 ? raw : raw * 100
return Math.max(0, Math.min(100, Math.round(pct)))
})
const batteryColor = computed(() => {
const p = batteryPct.value
if (p < 20) return '#f56c6c'
if (p < 50) return '#e6a23c'
return '#67c23a'
})
const switchOn = computed(() => props.vehicle.maintenanceMode === 'online')
const cardClass = computed(() => ({
'is-selected': props.selected,
'is-alarm': props.vehicle.isAlarmActive,
'is-offline': props.vehicle.maintenanceMode === 'offline' || props.vehicle.state === 'offline',
'is-maintenance': props.vehicle.maintenanceMode === 'repair' || props.vehicle.maintenanceMode === 'blown',
'is-unreachable': props.vehicle.reachable === false
}))
const latencyLabel = computed(() => {
const ms = props.vehicle.latencyMs
if (ms == null) return '—'
if (props.vehicle.reachable === false) return '超时'
return `${ms} ms`
})
const latencyClass = computed(() => {
const ms = props.vehicle.latencyMs
if (props.vehicle.reachable === false) return 'danger'
if (ms != null && ms > 80) return 'warn'
return ''
})
const faultLabel = computed(() => {
const v = props.vehicle.faultRatePercent
if (v == null) return '—'
return `${v.toFixed(2)}%`
})
const faultClass = computed(() => {
const v = props.vehicle.faultRatePercent ?? 0
if (v >= 5) return 'danger'
if (v >= 1) return 'warn'
return ''
})
const cpuLabel = computed(() => {
const v = props.vehicle.cpuPercent
return v != null ? `${Math.round(v)}%` : '—'
})
const memLabel = computed(() => {
const v = props.vehicle.memPercent
return v != null ? `${Math.round(v)}%` : '—'
})
function onCardClick() {
emit('select', props.vehicle.id)
}
function onCardDblClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.head-actions')) return
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
}
async function applyMaintenance(mode: VehicleMaintenanceMode) {
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
if (!Number.isFinite(rawId)) return
const ok = await setVehicleMaintenance(rawId, mode)
if (ok) {
ElMessage.success('维护状态已更新')
emit('maintenanceChanged')
} else {
ElMessage.error('维护操作失败')
}
}
async function onToggleMaintenance(on: boolean) {
if (!props.canWrite) return
const mode: VehicleMaintenanceMode = on ? 'online' : 'offline'
try {
await ElMessageBox.confirm(
on ? '确认将车辆上线?' : '确认将车辆下线维护?',
'维护确认',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
await applyMaintenance(mode)
} catch {
/* cancelled */
}
}
async function onMaintenanceCommand(cmd: string) {
if (!props.canWrite) return
const mode = cmd as VehicleMaintenanceMode
if (mode === 'blown') {
try {
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
type: 'error',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
if (mode === 'repair') {
try {
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
type: 'warning'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
await applyMaintenance(mode)
}
</script>
<style scoped>
.vehicle-health-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.vehicle-health-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.vehicle-health-card.is-selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
.vehicle-health-card.is-alarm {
border-color: var(--el-color-danger-light-5);
}
.vehicle-health-card.is-offline,
.vehicle-health-card.is-maintenance {
border-color: var(--el-color-warning-light-5);
}
.vehicle-health-card.is-unreachable {
opacity: 0.85;
}
.battery-strip {
height: 4px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 4px;
gap: 8px;
}
.title-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vid {
font-size: 12px;
color: var(--el-text-color-secondary);
font-family: ui-monospace, monospace;
}
.vname {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.card-body {
display: flex;
gap: 10px;
padding: 4px 10px 8px;
}
.thumb {
width: 56px;
height: 56px;
border-radius: 6px;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.metrics {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.metric-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.metric-row .label {
color: var(--el-text-color-secondary);
}
.metric-row .value {
font-weight: 500;
}
.metric-row .value.mono {
font-family: ui-monospace, monospace;
}
.metric-row .value.danger {
color: var(--el-color-danger);
}
.metric-row .value.warn {
color: var(--el-color-warning);
}
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 0 10px 8px;
}
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.icon-cell .is-alarm {
color: var(--el-color-danger);
}
.icon-cell .is-down {
color: var(--el-color-info);
}
.card-foot {
display: flex;
justify-content: space-between;
padding: 6px 10px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
}
.card-foot .hint {
opacity: 0.7;
}
</style>
@@ -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<Car[]>([])
const healthRows = shallowRef<FleetHealthRow[]>([])
const loading = ref(false)
const healthLoading = ref(false)
const selectedId = ref<string | null>(null)
let carTimer: ReturnType<typeof setInterval> | null = null
let healthTimer: ReturnType<typeof setInterval> | null = null
const healthByCarId = computed(() => {
const map = new Map<number, FleetHealthRow>()
for (const row of healthRows.value) {
map.set(row.carId, row)
}
return map
})
const cardModels = computed<VehicleCardModel[]>(() =>
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<Car>) {
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
}
}
@@ -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' }
@@ -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: '下线' }
]
@@ -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',
@@ -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: '运营备注' } }
@@ -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'
}
@@ -0,0 +1,234 @@
<template>
<div class="vehicle-hub-page">
<div class="hub-toolbar">
<div class="stats-row">
<div class="stat">
<span class="num">{{ totalCount }}</span>
<span class="label">总数</span>
</div>
<div class="stat online">
<span class="num">{{ onlineCount }}</span>
<span class="label">在线</span>
</div>
<div class="stat warn">
<span class="num">{{ maintenanceCount }}</span>
<span class="label">维护中</span>
</div>
<div class="stat danger">
<span class="num">{{ alarmCount }}</span>
<span class="label">报警</span>
</div>
<div class="stat muted">
<span class="num">{{ unreachableCount }}</span>
<span class="label">不可达</span>
</div>
</div>
<div class="filters-row">
<el-input
v-model="search"
size="small"
clearable
placeholder="搜索 ID / 名称 / IP"
class="search-input"
:prefix-icon="Search"
/>
<el-select v-model="filterState" size="small" clearable placeholder="状态" class="filter-select">
<el-option v-for="opt in stateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filterGroup" size="small" clearable placeholder="群组" class="filter-select">
<el-option v-for="g in groupOptions" :key="g" :label="g" :value="g" />
</el-select>
<el-dropdown :disabled="!canWrite || !selectedIds.length" @command="onBatchCommand">
<el-button size="small" :disabled="!canWrite || !selectedIds.length">
批量维护
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="online">批量上线</el-dropdown-item>
<el-dropdown-item command="offline">批量下线</el-dropdown-item>
<el-dropdown-item command="repair">批量现场检修</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" :icon="Refresh" :loading="loading || healthLoading" @click="refreshAll">
刷新
</el-button>
</div>
</div>
<div v-loading="loading && !cardModels.length" class="card-grid">
<VehicleHealthCard
v-for="v in filteredCards"
:key="v.id"
:vehicle="v"
:selected="selectedId === v.id"
:can-write="canWrite"
@select="selectedId = $event"
@maintenance-changed="refreshAll"
/>
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
</div>
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
import type { CarState } from '@/types/car'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const canWrite = computed(() => auth.scope === 'Platform' || (auth.effectivePermissions?.allowedOps ?? []).includes('*'))
const {
cardModels,
loading,
healthLoading,
selectedId,
totalCount,
onlineCount,
maintenanceCount,
alarmCount,
unreachableCount,
refreshAll
} = useVehicleHub()
const search = ref('')
const filterState = ref<CarState | ''>('')
const filterGroup = ref('')
const stateOptions: { value: CarState; label: string }[] = [
{ value: 'idle', label: '空闲' },
{ value: 'running', label: '运行' },
{ value: 'charging', label: '充电' },
{ value: 'paused', label: '暂停' },
{ value: 'fault', label: '故障' },
{ value: 'offline', label: '离线' }
]
const groupOptions = computed(() => {
const set = new Set<string>()
for (const c of cardModels.value) {
if (c.group) set.add(c.group)
}
return [...set]
})
const filteredCards = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return cardModels.value.filter((c) => {
if (filterState.value && c.state !== filterState.value) return false
if (filterGroup.value && c.group !== filterGroup.value) return false
if (!tokens.length) return true
const hay = [c.id, c.name, c.ip, c.group, c.lstatus].filter(Boolean).join(' ').toLowerCase()
return tokens.every((t) => hay.includes(t))
})
})
const selectedIds = computed(() => (selectedId.value ? [selectedId.value] : []))
async function onBatchCommand(cmd: string) {
const mode = cmd as VehicleMaintenanceMode
const targets = filteredCards.value.filter((c) => selectedId.value ? c.id === selectedId.value : true)
if (!targets.length) return
try {
await ElMessageBox.confirm(`${targets.length} 辆车执行「${cmd}」?`, '批量维护', { type: 'warning' })
for (const v of targets) {
const rawId = v.rawId ?? parseInt(v.id.replace(/\D/g, ''), 10)
if (Number.isFinite(rawId)) await setVehicleMaintenance(rawId, mode)
}
ElMessage.success('批量操作已提交')
await refreshAll()
} catch {
/* cancelled */
}
}
</script>
<style scoped>
.vehicle-hub-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.hub-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 64px;
}
.stat .num {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
.stat .label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.stat.online .num { color: var(--el-color-success); }
.stat.warn .num { color: var(--el-color-warning); }
.stat.danger .num { color: var(--el-color-danger); }
.stat.muted .num { color: var(--el-text-color-secondary); }
.filters-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.search-input {
width: 220px;
}
.filter-select {
width: 120px;
}
.card-grid {
flex: 1;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
align-content: start;
padding-bottom: 8px;
}
.footnote {
flex-shrink: 0;
margin: 0;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
</style>