feat(platform): 合并车辆运维页并优化 3D 画布嵌入体验
将维护策略与车队生命周期并入车辆运维 Tab,补充旧路由重定向与 RBAC 页面 key 迁移;改进 Workspace3D 就绪探测与 wwwroot 静态资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 × 地图编辑器深紫沉浸 UI(MapEditorView)
|
||||
* 侧栏 / 属性面板 / 工具轨保持深紫底 + 浅字。全局浅紫规则会把 .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;
|
||||
|
||||
Reference in New Issue
Block a user