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