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>