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:
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user