feat(platform): 合并车辆运维页并优化 3D 画布嵌入体验

将维护策略与车队生命周期并入车辆运维 Tab,补充旧路由重定向与 RBAC 页面 key 迁移;改进 Workspace3D 就绪探测与 wwwroot 静态资源。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-31 14:39:50 +08:00
co-authored by Cursor
parent be0503c535
commit e2269e430d
70 changed files with 504 additions and 162 deletions
@@ -25,7 +25,7 @@
@load="onReady" />
<div v-if="!loaded" class="workspace-3d-mask">
<el-icon class="is-loading" size="32"><Loading /></el-icon>
<span>正在连接 webVRender (http://{{ resolvedHost }}) ...</span>
<span>{{ loadingHint }}</span>
<span class="muted">如长时间未加载请确认 SimpleLite 已以 Web-Enabled 模式启动且 8223 端口可达</span>
</div>
</div>
@@ -73,6 +73,7 @@ const emit = defineEmits<{
const frame = ref<HTMLIFrameElement>()
const frameWrap = ref<HTMLDivElement>()
const loaded = ref(false)
const loadingHint = ref('正在连接 webVRender ...')
const lastPick = ref<PickEvent | null>(null)
const lastSelect = ref<string[]>([])
const iframeSrc = ref<string>('')
@@ -89,40 +90,118 @@ const vrUrl = computed(() => {
return `http://${resolvedHost.value}/?${qs.toString()}`
})
/**
* 嵌入 / canvas-only 模式必须在 webVRender 主页加载(即发起 WebSocket 连接)之前先打一次
* 对应的 GET 声明端点,让 SimpleLite 把"下一个连入的 WebTerminal"打上对应模式标签。
* 否则 SimpleLite 拿不到任何提示(CycleGUI 不会把 URL query 透传给 Terminal),
* 会默认创建完整工作区面板(工作台 / 浮动条 / CAD / 底栏),违反平台 UI 接管的要求。
*/
async function declareEmbedIfNeeded() {
const endpoint = props.canvasOnly
? '/declareCanvasOnly'
: props.embedUi
? '/declareEmbedUi'
: null
if (!endpoint) return
const FETCH_TIMEOUT_MS = 1800
const DECLARE_MAX_ATTEMPTS = 4
const LOAD_WATCHDOG_MS = 18_000
let loadWatchdog: ReturnType<typeof setTimeout> | null = null
let prepareGeneration = 0
function vrenderBaseUrl() {
return `http://${resolvedHost.value}`
}
function sleep(ms: number) {
return new Promise<void>(r => setTimeout(r, ms))
}
function needsDeclare() {
return props.canvasOnly || props.embedUi
}
function declareEndpoint() {
if (props.canvasOnly) return '/declareCanvasOnly'
if (props.embedUi) return '/declareEmbedUi'
return null
}
/** 单次 HTTP 探测(带超时),用于 declare / vrenderReady。 */
async function pingVrender(path: string): Promise<boolean> {
try {
await fetch(`http://${resolvedHost.value}${endpoint}`, {
const res = await fetch(`${vrenderBaseUrl()}${path}`, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
credentials: 'omit',
keepalive: true
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
})
} catch (err) {
console.warn(`[Workspace3D] ${endpoint} 请求失败,SimpleLite 可能仍以完整面板模式启动:`, err)
return res.ok
} catch {
return false
}
}
async function prepareAndLoad() {
/**
* 尽力 declare(不阻塞 iframe)。SimpleLite 侧 WebTerminal 已默认 canvas-only
* declare 主要用于多 tab 显式标记;失败也不影响平台纯画布模式。
*/
async function postDeclareBestEffort() {
const endpoint = declareEndpoint()
if (!endpoint) return
for (let attempt = 0; attempt < DECLARE_MAX_ATTEMPTS; attempt++) {
if (await pingVrender(endpoint)) return
await sleep(80 * (attempt + 1))
}
console.warn(`[Workspace3D] ${endpoint} 未成功,将依赖 SimpleLite 默认 canvas-only 模式`)
}
/** 非嵌入模式:短轮询等待 8223 就绪后再加载 iframe。 */
async function waitForVrenderReady(maxMs = 10_000): Promise<boolean> {
const deadline = Date.now() + maxMs
let interval = 60
while (Date.now() < deadline) {
if (await pingVrender('/vrenderReady')) return true
await sleep(interval)
interval = Math.min(interval + 40, 280)
}
return false
}
function clearLoadWatchdog() {
if (loadWatchdog != null) {
clearTimeout(loadWatchdog)
loadWatchdog = null
}
}
function startLoadWatchdog(generation: number) {
clearLoadWatchdog()
loadWatchdog = setTimeout(() => {
if (generation !== prepareGeneration || loaded.value) return
loadingHint.value = `连接超时 (http://${resolvedHost.value}),请确认 SimpleLite 已启动`
}, LOAD_WATCHDOG_MS)
}
function buildIframeSrc(forceReload: boolean) {
const base = vrUrl.value
if (!forceReload) return base
const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}_=${Date.now()}`
}
async function prepareAndLoad(forceReload = false) {
const generation = ++prepareGeneration
loaded.value = false
await declareEmbedIfNeeded()
iframeSrc.value = vrUrl.value
loadingHint.value = `正在连接 webVRender (http://${resolvedHost.value}) ...`
startLoadWatchdog(generation)
const embedded = needsDeclare()
if (embedded) {
// 并行:iframe 立即加载,declare 后台尽力发送(不挡首屏)。
void postDeclareBestEffort()
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
} else {
loadingHint.value = '正在等待 SimpleLite webVRender 就绪 ...'
await waitForVrenderReady()
if (generation !== prepareGeneration) return
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
}
}
function onReady() {
loaded.value = true
clearLoadWatchdog()
emit('ready')
}
@@ -145,7 +224,7 @@ function onMessage(ev: MessageEvent) {
}
function reload() {
prepareAndLoad()
prepareAndLoad(true)
}
function enterFullscreen() {
@@ -157,11 +236,12 @@ function enterFullscreen() {
onMounted(() => {
window.addEventListener('message', onMessage)
prepareAndLoad()
prepareAndLoad(false)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
clearLoadWatchdog()
})
defineExpose({ reload, enterFullscreen })
@@ -216,6 +296,7 @@ defineExpose({ reload, enterFullscreen })
color: rgba(232, 220, 255, 0.92);
font-size: 13px;
backdrop-filter: blur(10px);
pointer-events: none;
}
.workspace-3d-mask .muted { color: rgba(var(--mg-accent-rgb), 0.7); font-size: 12px; }
.workspace-3d-mask .el-icon { color: var(--mg-accent); filter: drop-shadow(0 0 10px rgba(var(--mg-accent-rgb), 0.6)); }
@@ -1060,6 +1060,43 @@ function onDelete() {
.defaults-empty { padding: 40px 12px; }
.defaults-form .el-button { margin-top: 8px; }
.defaults-form :deep(.el-form-item) { margin-bottom: 10px; }
.layer-table { margin: 6px 0; }
/* 图层 Tab:白底数据块(深侧栏上的可读「岛」),各主题下表头/单元格均用深字 */
.layer-table {
margin: 6px 0;
border-radius: 8px;
overflow: hidden;
background: #fff;
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.14);
}
.layer-table :deep(.el-table__inner-wrapper) {
background: #fff;
}
.layer-table :deep(th.el-table__cell) {
background: #f5f3ff !important;
color: #2d1b69 !important;
font-weight: 600;
}
.layer-table :deep(td.el-table__cell) {
background: #fff !important;
color: #1a0f3d !important;
font-weight: 500;
}
.layer-table :deep(.cell) {
color: #1a0f3d !important;
}
.layer-table :deep(tr:hover > td.el-table__cell) {
background: #ede9fe !important;
}
.new-layer { margin-top: 8px; }
.new-layer :deep(.el-input__inner) {
color: #1a0f3d;
font-weight: 500;
}
.new-layer :deep(.el-input__inner::placeholder) {
color: #7a6b9a;
}
.new-layer :deep(.el-input-group__append .el-button) {
color: #5b21b6;
font-weight: 600;
}
</style>
@@ -162,14 +162,12 @@ 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-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' },
{ path: '/admin/config/auth', label: '权限与角色', key: 'admin-config-auth' },
{ path: '/admin/config/device', label: '设备接入', key: 'admin-config-device' },
{ path: '/admin/config/fleet', label: '车队生命周期', key: 'admin-config-fleet' },
{ path: '/admin/config/scenario', label: '场景模板', key: 'admin-config-scenario' },
{ path: '/admin/config/location', label: '库位管理', key: 'admin-config-location' },
{ path: '/admin/config/ops', label: '运营维护', key: 'admin-config-ops' },
@@ -23,14 +23,12 @@ 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-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' },
{ key: 'admin-config-auth', label: '权限与角色', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-device', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-fleet', label: '车队生命周期', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-scenario', label: '场景模板', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-location', label: '库位管理', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-ops', label: '运营维护', group: '平台配置中心', scope: 'Platform' },
@@ -144,7 +142,11 @@ function delay(ms: number) { return new Promise((r) => setTimeout(r, ms)) }
function sanitizePages(pages: string[]): string[] {
if (pages.includes('*')) return ['*']
const valid = new Set(PAGES.map((p) => p.key))
return [...new Set(pages.filter((p) => valid.has(p)))]
const legacy: Record<string, string> = {
'admin-config-vehicle': 'admin-vehicle-hub',
'admin-config-fleet': 'admin-vehicle-hub'
}
return [...new Set(pages.map((p) => legacy[p] ?? p).filter((p) => valid.has(p)))]
}
export function mockRbacCatalog(): RbacCatalog {
@@ -34,15 +34,16 @@ const routes: RouteRecordRaw[] = [
{ path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统级配置' } },
{ 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' },
// 旧独立配置页已合并进车辆运维 Tab,保留深链接兼容。
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } },
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } },
{ 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: '交通管制' } },
{ path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限角色' } },
{ path: 'config/device', name: 'admin-config-device', component: () => import('@/views/admin/config/DeviceHubView.vue'), meta: { title: '设备接入' } },
{ path: 'config/fleet', name: 'admin-config-fleet', component: () => import('@/views/admin/config/FleetLifecycleView.vue'), meta: { title: '车队生命周期' } },
{ path: 'config/scenario', name: 'admin-config-scenario', component: () => import('@/views/admin/config/ScenarioTemplateView.vue'), meta: { title: '场景模板' } },
{ path: 'config/location', name: 'admin-config-location', component: () => import('@/views/admin/config/LocationView.vue'), meta: { title: '库位管理' } },
{ path: 'config/ops', name: 'admin-config-ops', component: () => import('@/views/admin/config/OpsConfigView.vue'), meta: { title: '运营维护' } },
@@ -1657,3 +1657,134 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
border-color: #7c3aed !important;
color: #5b21b6 !important;
}
/* ─────────────────────────────────────────────────────────────────────────
* fame-lavender × 地图编辑器深紫沉浸 UIMapEditorView
* 侧栏 / 属性面板 / 工具轨保持深紫底 + 浅字。全局浅紫规则会把 .mg-content 内
* el-form 标签、el-tabs 等改成深紫字 (#4a3d6e),在深底上出现「深底深字」看不清。
* ───────────────────────────────────────────────────────────────────────── */
:root[data-theme="fame-lavender"] .mg-content .map-editor-page {
--el-text-color-primary: rgba(232, 215, 245, 0.95);
--el-text-color-regular: rgba(220, 200, 230, 0.88);
--el-text-color-secondary: rgba(200, 180, 220, 0.72);
color: rgba(232, 215, 245, 0.92);
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-form-item__label {
color: rgba(220, 200, 230, 0.9) !important;
font-weight: 500;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs {
--el-text-color-primary: rgba(232, 215, 245, 0.95) !important;
--el-text-color-regular: rgba(220, 200, 240, 0.75) !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item,
:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item {
color: rgba(220, 200, 240, 0.72) !important;
font-weight: 500;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item:hover,
:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item:hover {
color: rgba(240, 225, 255, 0.95) !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__item.is-active,
:root[data-theme="fame-lavender"] .map-editor-page .el-tabs__item.is-active {
color: #ffffff !important;
font-weight: 600;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__nav-wrap::after {
background-color: rgba(255, 255, 255, 0.1) !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-tabs__active-bar {
background: linear-gradient(90deg, rgba(170, 110, 250, 1) 0%, rgba(255, 110, 220, 1) 100%) !important;
}
/* 图层 Tab:白底数据岛 + 深紫字(与属性面板输入框一致,避免白底叠浅字) */
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table {
--el-table-bg-color: #ffffff;
--el-table-tr-bg-color: #ffffff;
--el-table-header-bg-color: #f5f3ff;
--el-table-text-color: #1a0f3d;
--el-table-header-text-color: #2d1b69;
--el-table-row-hover-bg-color: #ede9fe;
--el-table-border-color: #e0d4f5;
--el-fill-color-blank: #ffffff;
--el-fill-color-light: #faf7fd;
color: #1a0f3d !important;
background: #ffffff !important;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.14);
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table tr,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table .el-table__inner-wrapper {
background: #ffffff !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table th.el-table__cell {
background: #f5f3ff !important;
color: #2d1b69 !important;
font-weight: 600 !important;
border-bottom-color: #e0d4f5 !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table td.el-table__cell {
background: #ffffff !important;
color: #1a0f3d !important;
font-weight: 500 !important;
border-bottom-color: #f0eaf6 !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table .cell {
color: #1a0f3d !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table.el-table--striped .el-table__body tr:nth-child(2n) td.el-table__cell {
background: #faf7fd !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .layer-table tr:hover > td.el-table__cell {
background: #ede9fe !important;
}
/* 新建图层行:白底深字 + 占位符可读 */
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner {
color: #1a0f3d !important;
font-weight: 500;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input__inner::placeholder {
color: #7a6b9a !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button {
background: #f5f3ff !important;
border-color: #ddd6fe !important;
color: #5b21b6 !important;
font-weight: 600 !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .new-layer .el-input-group__append .el-button:hover {
background: #ede9fe !important;
border-color: #a78bfa !important;
color: #5b21b6 !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .el-checkbox__label {
color: rgba(220, 200, 230, 0.88) !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag-text {
color: rgba(200, 220, 255, 0.88) !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag.is-synced,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .prop-card-flag.is-synced .prop-card-flag-text {
color: rgba(220, 255, 230, 0.98) !important;
}
/* 属性面板内输入框仍用白底深字(与组件 scoped 设计一致),仅修正标签/Tab 等说明文字 */
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-input__wrapper,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-select .el-input__wrapper {
background: #ffffff !important;
box-shadow: 0 0 0 1px rgba(170, 110, 250, 0.28) inset !important;
}
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-input__inner,
:root[data-theme="fame-lavender"] .mg-content .map-editor-page .edit-property-panel .el-textarea__inner {
color: #1a0f3d !important;
}
@@ -23,7 +23,8 @@
:scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''"
:read-only="!!readOnly"
:embed-ui="true"
:canvas-only="true"
:hide-toolbar="true"
@pick="onPick"
@select="onSelect"
@ready="onWorkspaceReady"
@@ -1,85 +1,106 @@
<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>
<el-tabs v-model="activeTab" class="hub-tabs">
<el-tab-pane name="overview" label="运维总览">
<div class="overview-pane">
<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 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>
<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>
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计</p>
</div>
</el-tab-pane>
<el-tab-pane name="maintenance" label="维护策略" lazy>
<div class="config-pane">
<VehicleMaintenanceView />
</div>
</el-tab-pane>
<el-tab-pane name="fleet" label="车队生命周期" lazy>
<div class="config-pane">
<FleetLifecycleView />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
import type { CarState } from '@/types/car'
@@ -88,6 +109,24 @@ import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const canWrite = computed(() => auth.scope === 'Platform' || (auth.effectivePermissions?.allowedOps ?? []).includes('*'))
// Tab URL ?tab= /config/vehicle/config/fleet 线
const route = useRoute()
const router = useRouter()
const TAB_NAMES = ['overview', 'maintenance', 'fleet'] as const
type TabName = (typeof TAB_NAMES)[number]
function readTab(): TabName {
const q = route.query.tab
return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'overview'
}
const activeTab = ref<TabName>(readTab())
watch(activeTab, (t) => {
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
})
watch(() => route.query.tab, () => {
const next = readTab()
if (next !== activeTab.value) activeTab.value = next
})
const {
cardModels,
loading,
@@ -157,6 +196,42 @@ async function onBatchCommand(cmd: string) {
<style scoped>
.vehicle-hub-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.hub-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
:deep(.el-tabs__item) {
color: var(--mg-text-muted, rgba(255, 255, 255, 0.65));
}
:deep(.el-tabs__item.is-active) {
color: var(--mg-text-light, #fff);
}
:deep(.el-tabs__active-bar) {
background-color: var(--mg-accent, var(--el-color-primary));
}
:deep(.el-tabs__nav-wrap::after) {
background-color: rgba(255, 255, 255, 0.08);
}
.overview-pane {
height: 100%;
display: flex;
flex-direction: column;
@@ -164,6 +239,11 @@ async function onBatchCommand(cmd: string) {
overflow: hidden;
}
.config-pane {
height: 100%;
overflow: auto;
}
.hub-toolbar {
display: flex;
flex-direction: column;