fix(platform): 代码审查整改——反代按域拆分授权、根除探测副作用与死代码清理

- YARP: map-edit/ai-config 全方法、reflection 写方法挂 PlatformScope,
  reflection/selection 单独放行(运营端 3D 高亮),堵住运营账号直达地图编辑/反射调用
- goto-site 探测改用不存在的 car/-1(消除健康检查真实派车风险)并加 60s 缓存
- Config PUT 按 scope 收紧:RCSMonitor 仅可写 ops 节;wizard 写操作与
  simplelite/restart-for-update 限 PlatformScope;/api/health 去除虚假端口表
- 修复 wms 模块菜单裁剪失效(admin-config-location → admin-config-facility)
- vrHost 默认 location.hostname:8223(新增 utils/vrender.ts),远程访问 3D 视口可用
- /status 页改接真实 /api/health* 诊断;uploadAsset 移除矛盾 multipart 头;
  mapsApi.merge 对齐 save 的 409 冲突处理;JWT 验签参数改启动期 DI 一次性配置
- 清理死代码:ProjectionController、DataTablePro、useClipboard、CadToolbarView、
  AppShell 未用导入;lint 脚本替换为 typecheck;日志窗口 List 改 Queue
This commit is contained in:
zhaowei.huang
2026-06-12 23:00:47 +08:00
parent d857cda071
commit 20f98db6da
24 changed files with 266 additions and 320 deletions
@@ -8,7 +8,7 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .ts,.vue --fix"
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -204,11 +204,9 @@ export const mapEditApi = {
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
// 资产上传:传 base64
// 资产上传:JSON body 传 base64(后端按 JSON 解析;勿手动设 multipart 头 —— body 并非 multipart)。
uploadAsset: (filename: string, dataBase64: string) =>
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 }, {
headers: { 'Content-Type': 'multipart/form-data' }
})),
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 })),
// AI 生图
aiMapGenerate: (req: AiMapGenerateRequest) =>
@@ -335,7 +333,6 @@ export interface MapSaveResult {
export interface MapContentResult {
name: string
fileName: string
path: string
content: string
}
@@ -426,13 +423,25 @@ export const mapsApi = {
* 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。
*/
async merge(sources: string[], target: string, overwrite = false): Promise<MapMergeOutcome> {
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
sources,
target,
overwrite
})
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
return { ok: false, conflict, message: data?.message ?? '合并失败' }
try {
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
sources,
target,
overwrite
})
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
return { ok: false, conflict, message: data?.message ?? '合并失败' }
} catch (err) {
// 与 save() 对齐:后端以 HTTP 409 状态码返回时同样翻译为 conflict,
// 让调用方能弹「是否替换」确认而非直接报错。
const ax = err as AxiosError<MapEditEnvelope<MapMergeResult>>
const body = ax.response?.data
if (ax.response?.status === 409 || body?.code === 409) {
const message = body?.message ?? '目标地图已存在'
return { ok: false, conflict: message.includes('已存在'), message }
}
throw err
}
}
}
@@ -1,54 +0,0 @@
<template>
<el-card class="dtp-card" shadow="never">
<template #header>
<div class="dtp-header">
<span class="dtp-title">{{ title }}</span>
<div class="dtp-actions">
<el-input v-if="searchable" v-model="kw" :placeholder="searchPlaceholder" clearable size="small" style="width: 220px" />
<slot name="actions" />
</div>
</div>
</template>
<el-table :data="filtered" stripe size="small" :max-height="maxHeight" border>
<el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :width="c.width" :min-width="c.minWidth">
<template #default="scope">
<slot :name="`col-${c.prop}`" :row="scope.row">
{{ scope.row[c.prop] }}
</slot>
</template>
</el-table-column>
<slot name="extra-columns" />
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Column { prop: string; label: string; width?: number | string; minWidth?: number | string }
const props = defineProps<{
title: string
data: Array<Record<string, unknown>>
columns: Column[]
searchable?: boolean
searchPlaceholder?: string
maxHeight?: number | string
}>()
const kw = ref('')
const filtered = computed(() => {
if (!props.searchable || !kw.value) return props.data
const q = kw.value.trim().toLowerCase()
return props.data.filter((row) =>
Object.values(row).some((v) => String(v ?? '').toLowerCase().includes(q))
)
})
</script>
<style scoped>
.dtp-header { display: flex; justify-content: space-between; align-items: center; }
.dtp-title { font-weight: 600; }
.dtp-actions { display: flex; gap: 8px; align-items: center; }
</style>
@@ -36,6 +36,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue'
import type { Scope } from '@/types/auth'
import { defaultVrHost } from '@/utils/vrender'
interface PickEvent { x: number; y: number }
@@ -78,7 +79,7 @@ const lastPick = ref<PickEvent | null>(null)
const lastSelect = ref<string[]>([])
const iframeSrc = ref<string>('')
const resolvedHost = computed(() => props.host ?? (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223')
const resolvedHost = computed(() => props.host ?? defaultVrHost())
const vrUrl = computed(() => {
const qs = new URLSearchParams()
@@ -1,56 +0,0 @@
import { ref } from 'vue'
import type { SelectionItem } from './useSelection'
/**
* 编辑器剪贴板:保存最近一次「复制」操作的对象快照(含字段值),
* 用于「粘贴 (Ctrl+V)」与「复制字段 (Copy Fields)」。
*
* 粘贴策略:调用方在拿到目标坐标后,用 mapEditApi.batch 创建副本(带偏移)。
* 复制字段:调用方调 mapEditApi.copyFieldsTo 把指定字段名写到目标对象(们)。
*
* 注意:剪贴板里保存的是对象的"逻辑快照",不是 DOM 文本剪贴板。
*/
export interface ClipboardSnapshot {
items: Array<{
kind: string
sourceId: number
typeName: string
/** 对象的几何 / 样式字段(含 x, y 用于偏移粘贴)。 */
fields: Record<string, string>
}>
fieldNames: string[]
}
export function useClipboard() {
const data = ref<ClipboardSnapshot | null>(null)
function copy(items: SelectionItem[], allFields: Record<number, Record<string, string>>) {
if (items.length === 0) {
data.value = null
return
}
const fieldNamesSet = new Set<string>()
const snapshot: ClipboardSnapshot = {
items: items.map((it) => {
const f = allFields[it.id] ?? {}
Object.keys(f).forEach((k) => fieldNamesSet.add(k))
return {
kind: it.kind,
sourceId: it.id,
typeName: it.typeName,
fields: f
}
}),
fieldNames: []
}
snapshot.fieldNames = [...fieldNamesSet]
data.value = snapshot
}
function clear() {
data.value = null
}
return { data, copy, clear }
}
@@ -109,7 +109,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
MapLocation, Van, Promotion, Box, OfficeBuilding, Notebook
MapLocation, Van, Promotion, Notebook
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
@@ -209,8 +209,6 @@ const activePath = computed(() => {
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
void Van; void Box; void OfficeBuilding
function onUserCommand(cmd: string) {
if (cmd === 'logout') {
auth.logout()
@@ -0,0 +1,11 @@
/**
* webVRender (SimpleLite 3D 视口, 默认 :8223) 的 host 解析。
*
* 优先级:显式 VITE_VRENDER_HOST > 当前页面 hostname:8223。
* 不能写死 localhost —— 从远程浏览器访问平台时 iframe 会去连访问者本机而非服务器。
*/
export function defaultVrHost(): string {
const env = import.meta.env.VITE_VRENDER_HOST as string | undefined
if (env && env.trim()) return env.trim()
return `${window.location.hostname}:8223`
}
@@ -1,26 +1,50 @@
<template>
<div class="status-page mg-content">
<el-card shadow="never" class="status-card">
<el-card shadow="never" class="status-card" v-loading="loading">
<template #header>
<span>SimpleLite Service Status</span>
<el-tag type="success" effect="dark" style="margin-left: 8px">Web-Enabled (Mock)</el-tag>
<span>服务状态</span>
<el-tag v-if="health" type="success" effect="dark" style="margin-left: 8px">在线</el-tag>
<el-tag v-else-if="!loading" type="danger" effect="dark" style="margin-left: 8px">不可达</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="模式">Web-Enabled</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ startTime }}</el-descriptions-item>
<el-descriptions-item label="运行时长">{{ uptime }}</el-descriptions-item>
<el-descriptions-item label="节点角色"><el-tag type="success">Active (ROSE)</el-tag></el-descriptions-item>
<el-descriptions-item label="WebAPI">http://0.0.0.0:7001 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="WebSocket">ws://0.0.0.0:7002 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="webVRender">http://0.0.0.0:8223 <el-tag size="small" type="success">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="Platform.Server">:8080 (pid=12345)</el-descriptions-item>
<el-descriptions-item label="在线 Vue 客户端">admin=3, monitor=4</el-descriptions-item>
<el-descriptions-item label="调度循环 / 任务">50 Hz · 14 / 32</el-descriptions-item>
<el-descriptions :column="2" border title="MiGu.Server">
<el-descriptions-item label="状态">
<el-tag :type="health ? 'success' : 'danger'" size="small">{{ health ? 'ok' : 'unreachable' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ serverStartTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="运行时长" :span="2">{{ serverUptime || '—' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions v-if="sl" :column="2" border title="SimpleLite" class="status-block">
<el-descriptions-item label="托管启用">
<el-tag :type="sl.enabled ? 'success' : 'info'" size="small">{{ sl.enabled ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进程运行中">
<el-tag :type="sl.isRunning ? 'success' : 'info'" size="small">{{ sl.isRunning ? '是' : '否(或外部启动)' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="启动模式">{{ sl.lastLaunchMode || '—' }}</el-descriptions-item>
<el-descriptions-item :label="`投影端口 :${sl.projectionPort}`">
<el-tag :type="sl.projectionPortReachable ? 'success' : 'danger'" size="small">
{{ sl.projectionPortReachable ? '可达' : '不可达' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="前往站点 API">
<el-tag :type="gotoSiteTagType" size="small">{{ gotoSiteLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="可执行文件">
<el-tag :type="sl.executableExists ? 'success' : 'warning'" size="small">
{{ sl.executableExists ? '已找到' : '未找到' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="sl.deployHint" label="部署提示" :span="2">
{{ sl.deployHint }}
</el-descriptions-item>
</el-descriptions>
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="status-block" />
<div class="status-actions">
<el-button>查看日志</el-button>
<el-button type="warning" plain>重启 Web</el-button>
<el-button type="danger" plain>关闭服务</el-button>
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="back">返回</el-button>
</div>
</el-card>
@@ -28,28 +52,89 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/api/http'
import { useAuthStore } from '@/stores/auth'
interface HealthInfo {
status: string
startTime: string
uptimeSec: number
}
interface SimpleLiteDiagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
projectionPort: number
projectionPortReachable: boolean
gotoSiteApiAvailable?: boolean | null
executableExists: boolean
deployHint?: string | null
}
const router = useRouter()
const startTime = new Date().toLocaleString('zh-CN')
const uptime = ref('00:00:00')
const auth = useAuthStore()
const loading = ref(false)
const health = ref<HealthInfo | null>(null)
const sl = ref<SimpleLiteDiagnostics | null>(null)
const error = ref('')
let timer: number | undefined
const t0 = Date.now()
function tick() {
const ms = Date.now() - t0
const s = Math.floor(ms / 1000)
const serverStartTime = computed(() =>
health.value ? new Date(health.value.startTime).toLocaleString('zh-CN') : ''
)
const serverUptime = computed(() => {
if (!health.value) return ''
const s = Math.max(0, Math.floor(health.value.uptimeSec))
const h = String(Math.floor(s / 3600)).padStart(2, '0')
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
uptime.value = `${h}:${m}:${ss}`
return `${h}:${m}:${ss}`
})
const gotoSiteLabel = computed(() => {
const v = sl.value?.gotoSiteApiAvailable
if (v === true) return '可用'
if (v === false) return '缺失(旧版 DLL'
return '未知'
})
const gotoSiteTagType = computed(() => {
const v = sl.value?.gotoSiteApiAvailable
if (v === true) return 'success'
if (v === false) return 'warning'
return 'info'
})
async function refresh() {
loading.value = true
error.value = ''
try {
// /status 是 public 页:未登录只拉匿名 /health,不调需登录的 simplelite 诊断
// (401 会触发全局拦截器强制跳转登录页)。
const requests: [Promise<{ data: HealthInfo }>, Promise<{ data: SimpleLiteDiagnostics }> | null] = [
http.get<HealthInfo>('/health'),
auth.token ? http.get<SimpleLiteDiagnostics>('/health/simplelite') : null
]
const [h, d] = await Promise.allSettled([requests[0], requests[1] ?? Promise.reject(new Error('skipped'))])
health.value = h.status === 'fulfilled' ? h.value.data : null
sl.value = d.status === 'fulfilled' ? d.value.data : null
if (h.status === 'rejected') error.value = '无法连接 MiGu.Server/api/health'
else if (auth.token && d.status === 'rejected') error.value = 'SimpleLite 诊断获取失败(/api/health/simplelite'
} finally {
loading.value = false
}
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
void refresh()
// 10s 轮询:服务端对 goto-site 探测有 60s 缓存,此频率不会对 SimpleLite 产生压力。
timer = window.setInterval(() => void refresh(), 10_000)
})
onUnmounted(() => {
@@ -62,5 +147,6 @@ function back() { router.back() }
<style scoped>
.status-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.status-card { width: 720px; }
.status-block { margin-top: 16px; }
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
</style>
@@ -1,75 +0,0 @@
<template>
<PermissionGuard widget-id="CadToolbar">
<el-card shadow="never">
<template #header><span>CAD 工具栏cad.tool.run</span></template>
<el-tabs v-model="active">
<el-tab-pane v-for="g in groups" :key="g.key" :name="g.key" :label="g.label">
<el-row :gutter="12">
<el-col v-for="t in g.tools" :key="t.id" :span="6" style="margin-bottom: 12px">
<el-card shadow="hover" class="tool-card" @click="run(t)">
<el-icon size="24" class="tool-icon"><Tools /></el-icon>
<div class="tool-name">{{ t.label }}</div>
<div class="tool-desc">{{ t.desc }}</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Tools } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
interface CadTool { id: string; label: string; desc: string }
const active = ref('Scene')
const groups: Array<{ key: string; label: string; tools: CadTool[] }> = [
{
key: 'Scene', label: '场景 (Scene)',
tools: [
{ id: 'grid', label: '生成网格', desc: '按间距生成站点网格' },
{ id: 'mirror', label: '镜像', desc: '镜像复制选中对象' },
{ id: 'align', label: '对齐', desc: '横向/纵向对齐多选' },
{ id: 'distribute', label: '等距分布', desc: '在两端之间均匀分布站点' }
]
},
{
key: 'Car', label: '车辆 (Car)',
tools: [
{ id: 'spawn', label: '批量创建', desc: '从模板批量创建车辆' },
{ id: 'reset', label: '回原点', desc: '将选中车辆送回原点' }
]
},
{
key: 'Project', label: '工程 (Project)',
tools: [
{ id: 'validate', label: '工程校验', desc: '检查轨道连通性 / 重复站点' },
{ id: 'snapshot', label: '快照', desc: '输出 problems/simplelite-scene-*.json' }
]
}
]
function run(t: CadTool) {
ElMessage.info(`占位:执行 CAD 工具 [${t.id}] ${t.label}`)
}
</script>
<style scoped>
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
.tool-name { font-weight: 600; margin-top: 6px; }
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
.tool-icon {
color: var(--mg-accent);
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
}
.tool-card:hover .tool-icon {
color: var(--mg-text-light);
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
}
</style>
@@ -176,6 +176,7 @@ import {
promptSaveMode,
resolveCurrentMapName
} from '@/utils/projectSaveFlow'
import { defaultVrHost } from '@/utils/vrender'
import {
reflectionApi,
normalizeViewportPayload,
@@ -190,7 +191,7 @@ import {
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const vrHost = defaultVrHost()
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
@@ -153,7 +153,7 @@ async function loadMapContent(name: string) {
viewingPath.value = ''
try {
const r = await mapsApi.readContent(name)
viewingPath.value = r.path
viewingPath.value = r.fileName
viewingJsonRaw.value = r.content
} catch (err) {
ElMessage.error(`加载地图配置失败:${(err as Error).message}`)
@@ -100,6 +100,7 @@ import type { DeliveryTask } from '@/types/delivery'
import type { SelectedObjectRef } from '@/types/workbench'
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
import type { MapFocusKind } from '@/utils/mapObjectFocus'
import { defaultVrHost } from '@/utils/vrender'
defineProps<{
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
@@ -109,7 +110,7 @@ defineProps<{
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const vrHost = defaultVrHost()
const cars = ref<Car[]>([])
const missions = ref<Mission[]>([])