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