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
@@ -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;