feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退

从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-29 18:16:34 +08:00
co-authored by Cursor
parent 804aa68ade
commit 42978930ca
280 changed files with 30046 additions and 8 deletions
@@ -0,0 +1,163 @@
<template>
<PermissionGuard widget-id="ConfigCenter">
<el-card shadow="never">
<template #header>
<div class="cpb-header">
<span class="cpb-title">{{ title }}</span>
<el-tag size="small" effect="plain">section={{ section }}</el-tag>
<el-tag size="small" type="info" effect="plain">v{{ envelope?.version ?? '-' }}</el-tag>
<el-tag size="small" type="success" effect="plain" v-if="envelope?.updatedAt">{{ relativeTime }}</el-tag>
<div class="spacer" />
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save">保存</el-button>
</div>
</template>
<p v-if="description" class="cpb-desc">{{ description }}</p>
<div class="cpb-body">
<el-row :gutter="12">
<el-col :span="14">
<el-card shadow="never" body-style="padding: 12px">
<template #header>表单</template>
<slot :payload="payload" :update="update" />
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="never" body-style="padding: 0">
<template #header>JSON 预览保存即下发占位</template>
<pre class="cpb-json">{{ jsonText }}</pre>
</el-card>
</el-col>
</el-row>
</div>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts" generic="T extends object">
import { computed, onMounted, ref, watch } from 'vue'
import { Refresh, Check } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
import { useConfigStore } from '@/stores/config'
import type { ConfigEnvelope, ConfigSection } from '@/types/config'
const props = defineProps<{
section: ConfigSection
title: string
description?: string
defaults: T
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
normalizePayload?: (payload: T) => T
/** 加载完成后二次合并(如从 SimpleLite 拉 monitor */
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
/** 写入 Platform 之前先执行(如先落盘 SimpleLite monitor */
beforeSave?: (payload: T) => void | Promise<void>
/** 保存成功后副作用(如再次同步 SimpleLite */
afterSave?: (payload: T) => void | Promise<void>
}>()
const store = useConfigStore()
const envelope = ref<ConfigEnvelope<T> | null>(null)
const payload = ref<T>(JSON.parse(JSON.stringify(props.defaults)) as T)
const loading = ref(false)
const saving = ref(false)
const jsonText = computed(() => JSON.stringify(payload.value, null, 2))
const relativeTime = computed(() => envelope.value?.updatedAt
? new Date(envelope.value.updatedAt).toLocaleString('zh-CN')
: '—')
function update(next: T) { payload.value = next }
function applyPayload(raw: T | undefined | null) {
let next = raw
? (JSON.parse(JSON.stringify(raw)) as T)
: (JSON.parse(JSON.stringify(props.defaults)) as T)
if (props.normalizePayload) next = props.normalizePayload(next)
payload.value = next
}
async function reloadFromServer(force: boolean, toastOnSuccess: boolean) {
loading.value = true
try {
const env = await store.load<T>(props.section, force)
envelope.value = env
applyPayload(env.payload as T)
if (props.afterLoad) await props.afterLoad(payload.value, update)
if (toastOnSuccess) ElMessage.success('已重载')
} catch (e) {
ElMessage.error(`加载失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
}
async function reload(force = false) {
await reloadFromServer(force, force)
}
async function save() {
saving.value = true
let body = payload.value
if (props.normalizePayload) body = props.normalizePayload(body)
if (props.beforeSave) {
try {
await props.beforeSave(body)
} catch (e) {
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
saving.value = false
return
}
}
let monitorSyncWarn = ''
try {
const env = await store.save<T>(props.section, body)
envelope.value = env
payload.value = body
if (props.afterSave) {
try {
await props.afterSave(body)
} catch (e) {
monitorSyncWarn = `(平台配置已保存,但 SimpleLite 同步失败:${e instanceof Error ? e.message : String(e)}`
}
}
await reloadFromServer(true, false)
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 SimpleLite' : ''
ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
watch(() => props.section, () => reload())
onMounted(() => reload())
</script>
<style scoped>
.cpb-header { display: flex; align-items: center; gap: 8px; }
.cpb-title { font-weight: 600; font-size: 15px; color: #fff; }
.cpb-header .spacer { flex: 1; }
.cpb-desc {
color: rgba(232, 215, 245, 0.7);
font-size: 12.5px;
margin: 0 0 14px;
line-height: 1.6;
}
.cpb-body { margin-top: 4px; }
.cpb-json {
margin: 0; padding: 14px;
font-size: 12px; line-height: 1.55;
font-family: ui-monospace, Menlo, Consolas, monospace;
background: rgba(15, 4, 32, 0.55);
color: #d6c5ee;
max-height: 520px;
overflow: auto;
}
</style>
@@ -0,0 +1,54 @@
<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>
@@ -0,0 +1,27 @@
<template>
<template v-if="visibility === 'interactive'">
<slot />
</template>
<div v-else-if="visibility === 'readonly'" class="pg-readonly" :title="`${widgetId} 当前为只读`">
<slot />
<div class="pg-mask" />
</div>
<!-- hidden: 不渲染任何内容 -->
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{ widgetId: string }>()
const auth = useAuthStore()
const visibility = computed(() => auth.widgetOf(props.widgetId))
</script>
<style scoped>
.pg-readonly { position: relative; }
.pg-mask {
position: absolute; inset: 0; background: rgba(255, 255, 255, 0.3);
pointer-events: all; cursor: not-allowed;
}
</style>
@@ -0,0 +1,37 @@
<template>
<el-radio-group v-model="current" size="small" @change="onChange">
<el-radio-button label="Platform">管理员</el-radio-button>
<el-radio-button label="RCSMonitor">运营</el-radio-button>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { Scope } from '@/types/auth'
const auth = useAuthStore()
const router = useRouter()
// 会话 45 AR-6switchScope 已变成异步(调后端 /auth/switch-scope)。
// computed.set 不能 await,只能 fire-and-forget 并在 catch 里弹错。
const current = computed<Scope>({
get: () => auth.scope ?? 'Platform',
set: (v) => { void switchAndNavigate(v) }
})
async function onChange(v: string | number | boolean | undefined) {
await switchAndNavigate(v as Scope)
}
async function switchAndNavigate(scope: Scope) {
try {
await auth.switchScope(scope)
router.push(scope === 'Platform' ? '/admin/map-monitor' : '/monitor/map')
} catch (err) {
ElMessage.error((err as Error)?.message ?? `切换到 ${scope} 失败`)
}
}
</script>
@@ -0,0 +1,153 @@
<template>
<div class="theme-customizer">
<p class="tc-hint">
为各主题单独调色修改后立即预览当前生效主题会同步应用到全站保存系统配置时一并写入本地
</p>
<el-collapse v-model="expanded" accordion>
<el-collapse-item v-for="t in ui.availableThemes" :key="t.id" :name="t.id">
<template #title>
<div class="tc-title-row">
<span class="tc-swatch" :style="swatchStyle(t.id)" />
<span class="tc-name">{{ t.name }}</span>
<el-tag v-if="hasCustom(t.id)" size="small" type="warning" effect="plain">已自定义</el-tag>
<el-tag v-if="t.id === ui.themeId" size="small" type="success" effect="plain">当前</el-tag>
</div>
</template>
<div class="tc-panel">
<el-form label-width="100px" class="tc-form">
<el-form-item
v-for="f in THEME_COLOR_FIELDS"
:key="f.key"
:label="f.label">
<div class="tc-picker-row">
<el-color-picker
:model-value="ui.getThemeColor(t.id, f.key)"
color-format="hex"
:predefine="predefineColors"
@update:model-value="(v: string | null) => onPick(t.id, f.key, v)" />
<span class="tc-hex">{{ ui.getThemeColor(t.id, f.key) }}</span>
<span class="tc-field-hint">{{ f.hint }}</span>
</div>
</el-form-item>
</el-form>
<div class="tc-actions">
<el-button size="small" @click="ui.setThemeId(t.id)">切换到此主题</el-button>
<el-button size="small" :disabled="!hasCustom(t.id)" @click="resetOne(t.id)">恢复默认</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
<div class="tc-footer">
<el-button size="small" type="warning" plain :disabled="!anyCustom" @click="resetAll">
重置全部主题配色
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUiStore } from '@/stores/ui'
import { THEME_COLOR_FIELDS, type ThemeColorKey } from '@/styles/themeCustomize'
import { findTheme } from '@/styles/themes'
const ui = useUiStore()
const expanded = ref<string>(ui.themeId)
const predefineColors = [
'#7c3aed', '#8b5cf6', '#c4b5fd', '#5b21b6', '#4c1d95',
'#6366f1', '#a855f7', '#9333ea', '#1565c0', '#00796b',
'#b71c1c', '#455a64', '#e65100'
]
const anyCustom = computed(() =>
ui.availableThemes.some((t) => hasCustom(t.id))
)
function hasCustom(themeId: string): boolean {
const o = ui.themeOverrides[themeId]
return !!o && Object.keys(o).length > 0
}
function swatchStyle(themeId: string) {
const merged = ui.getMergedTheme(findTheme(themeId))
return {
background: `linear-gradient(135deg, ${merged.preview} 0%, ${merged.previewAccent} 100%)`
}
}
function onPick(themeId: string, key: ThemeColorKey, value: string | null) {
if (!value) return
ui.setThemeColor(themeId, key, value)
if (themeId === ui.themeId) {
ElMessage.success('已应用当前主题配色')
}
}
function resetOne(themeId: string) {
ui.clearThemeOverrides(themeId)
ElMessage.info('已恢复该主题默认配色')
}
function resetAll() {
ui.clearAllThemeOverrides()
ElMessage.info('已恢复全部主题默认配色')
}
</script>
<style scoped>
.theme-customizer {
margin-top: 8px;
}
.tc-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--mg-text-dim, rgba(255, 255, 255, 0.55));
line-height: 1.5;
}
.tc-title-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.tc-swatch {
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.tc-name {
font-weight: 600;
color: var(--mg-text-light, #fff);
}
.tc-panel {
padding: 4px 8px 12px;
}
.tc-picker-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tc-hex {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.75));
}
.tc-field-hint {
font-size: 11px;
color: var(--mg-text-dim, rgba(255, 255, 255, 0.45));
}
.tc-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-left: 100px;
}
.tc-footer {
margin-top: 16px;
}
</style>
@@ -0,0 +1,131 @@
<template>
<el-dropdown trigger="click" @command="onSelect">
<button class="theme-trigger" type="button" :title="`当前主题:${ui.activeTheme.name}`">
<span class="swatch" :style="swatchStyle" />
<span class="theme-label">{{ ui.activeTheme.name }}</span>
<el-icon class="caret"><CaretBottom /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="theme-menu">
<el-dropdown-item
v-for="t in ui.availableThemes"
:key="t.id"
:command="t.id"
:class="{ 'is-active': t.id === ui.themeId }">
<span class="option-swatch" :style="optionSwatch(t)" />
<span class="option-text">
<span class="option-name">{{ t.name }}</span>
<span class="option-desc">{{ t.description }}</span>
</span>
<el-icon v-if="t.id === ui.themeId" class="check"><Check /></el-icon>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CaretBottom, Check } from '@element-plus/icons-vue'
import { useUiStore } from '@/stores/ui'
import type { ThemePreset } from '@/styles/themes'
const ui = useUiStore()
const swatchStyle = computed(() => ({
background: `linear-gradient(135deg, ${ui.activeTheme.preview} 0%, ${ui.activeTheme.previewAccent} 100%)`
}))
function optionSwatch(t: ThemePreset) {
const merged = ui.getMergedTheme(t)
return {
background: `linear-gradient(135deg, ${merged.preview} 0%, ${merged.previewAccent} 100%)`
}
}
function onSelect(id: string) {
ui.setThemeId(id)
}
</script>
<style scoped>
/* 会话 N+2:把硬编码的白字 / 白底改成基于 --mg-* 主题变量,
* 让 fame-lavender 浅紫主题下(header 白底)也能看清楚。
* 默认深主题:--mg-text-light = #fff--mg-glass-bg = rgba(255,255,255,0.06)
* fame-lavender--mg-text-light = #1a0f3d 深紫,--mg-glass-bg = rgba(255,255,255,0.95) 近纯白。 */
.theme-trigger {
appearance: none;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 8px;
border-radius: 999px;
border: 1px solid var(--mg-glass-border, rgba(255, 255, 255, 0.18));
background: var(--mg-glass-bg, rgba(255, 255, 255, 0.05));
color: var(--mg-text-light, rgba(255, 255, 255, 0.9));
cursor: pointer;
transition: var(--mg-transition, all 0.25s ease);
font-size: 12.5px;
}
.theme-trigger:hover {
background: rgba(var(--mg-accent-rgb), 0.18);
border-color: rgba(var(--mg-accent-rgb), 0.45);
color: var(--mg-text-light, #fff);
}
.swatch {
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--mg-glass-border-hi, rgba(255, 255, 255, 0.25));
flex-shrink: 0;
}
.theme-label {
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.caret {
font-size: 12px;
opacity: 0.7;
}
</style>
<style>
/* 下拉面板项布局(teleport 到 body,需全局样式) */
.theme-menu .el-dropdown-menu__item {
display: flex !important;
align-items: center;
gap: 12px;
min-width: 240px;
padding: 10px 16px !important;
}
.theme-menu .el-dropdown-menu__item.is-active {
background: rgba(var(--mg-accent-rgb), 0.22) !important;
}
.theme-menu .option-swatch {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.theme-menu .option-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.3;
}
.theme-menu .option-name {
font-weight: 600;
font-size: 13px;
}
.theme-menu .option-desc {
font-size: 11px;
opacity: 0.65;
}
.theme-menu .check {
color: var(--mg-accent);
font-size: 16px;
}
</style>
@@ -0,0 +1,217 @@
<template>
<div class="workspace-3d-wrap" :class="{ 'workspace-3d--canvas-only': canvasOnly || hideToolbar }">
<div v-if="!(canvasOnly || hideToolbar)" class="workspace-3d-toolbar">
<el-tag :type="readOnly ? 'info' : 'success'" effect="dark" size="small">
{{ readOnly ? '只读' : '可交互' }}
</el-tag>
<span class="hint">webVRender · http://{{ resolvedHost }} · scope={{ scope }}</span>
<div class="spacer" />
<el-tag v-if="lastPick" type="warning" size="small">
Pick ({{ lastPick.x.toFixed(0) }}, {{ lastPick.y.toFixed(0) }})
</el-tag>
<el-tag v-if="lastSelect.length" type="primary" size="small">
Selected: {{ lastSelect.length }}
</el-tag>
<el-button size="small" :icon="Refresh" @click="reload">重载</el-button>
<el-button size="small" :icon="FullScreen" @click="enterFullscreen">全屏</el-button>
</div>
<div ref="frameWrap" class="workspace-3d-frame">
<iframe
v-if="iframeSrc"
ref="frame"
:src="iframeSrc"
class="workspace-3d-iframe"
allow="fullscreen"
@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 class="muted">如长时间未加载请确认 SimpleLite 已以 Web-Enabled 模式启动且 8223 端口可达</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue'
import type { Scope } from '@/types/auth'
interface PickEvent { x: number; y: number }
const props = withDefaults(defineProps<{
host?: string
scope?: Scope
token?: string
readOnly?: boolean
/** 嵌入平台页时传 trueSimpleLite 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
embedUi?: boolean
/**
* 地图编辑器纯画布模式:比 embedUi 更裸,连底栏都不创建。
* 设为 true 时 Vue 端会先调 /declareCanvasOnly,并在 iframe URL 上加 ?ui=canvas-only。
* 适用于 MapEditorView,所有 UI(顶栏 / 工具栏 / 属性面板)由 Vue 接管。
*/
canvasOnly?: boolean
/** 隐藏 Vue 端的顶部信息工具条(迷榖 / 重载 / 全屏)。地图编辑器内推荐设为 true,由 EditTopBar 接管。 */
hideToolbar?: boolean
}>(), {
host: undefined,
scope: 'Platform',
token: '',
readOnly: false,
embedUi: false,
canvasOnly: false,
hideToolbar: false
})
const emit = defineEmits<{
(e: 'pick', wp: PickEvent): void
(e: 'select', names: string[]): void
(e: 'ready'): void
}>()
const frame = ref<HTMLIFrameElement>()
const frameWrap = ref<HTMLDivElement>()
const loaded = ref(false)
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 vrUrl = computed(() => {
const qs = new URLSearchParams()
qs.set('scope', props.scope ?? 'Platform')
if (props.token) qs.set('token', props.token)
qs.set('ro', props.readOnly ? '1' : '0')
if (props.canvasOnly) qs.set('ui', 'canvas-only')
else if (props.embedUi) qs.set('ui', 'embed')
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
try {
await fetch(`http://${resolvedHost.value}${endpoint}`, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
credentials: 'omit',
keepalive: true
})
} catch (err) {
console.warn(`[Workspace3D] ${endpoint} 请求失败,SimpleLite 可能仍以完整面板模式启动:`, err)
}
}
async function prepareAndLoad() {
loaded.value = false
await declareEmbedIfNeeded()
iframeSrc.value = vrUrl.value
}
function onReady() {
loaded.value = true
emit('ready')
}
function onMessage(ev: MessageEvent) {
if (!frame.value || ev.source !== frame.value.contentWindow) return
const msg = ev.data
if (!msg || typeof msg !== 'object') return
if (msg.type === 'workspace.pick' && msg.payload && typeof msg.payload.x === 'number') {
lastPick.value = msg.payload
emit('pick', msg.payload)
} else if (msg.type === 'workspace.select' && Array.isArray(msg.payload)) {
lastSelect.value = msg.payload as string[]
emit('select', msg.payload as string[])
}
}
function reload() {
prepareAndLoad()
}
function enterFullscreen() {
const el = frameWrap.value
if (!el) return
if (document.fullscreenElement) document.exitFullscreen()
else el.requestFullscreen()
}
onMounted(() => {
window.addEventListener('message', onMessage)
prepareAndLoad()
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
})
defineExpose({ reload, enterFullscreen })
</script>
<style scoped>
.workspace-3d-wrap {
display: flex; flex-direction: column; height: 100%; min-height: 480px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--mg-radius, 16px);
background: rgba(15, 4, 32, 0.45);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
box-shadow: 0 8px 24px rgba(10, 2, 16, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
overflow: hidden;
transition: all .25s cubic-bezier(.25, .8, .25, 1);
}
.workspace-3d-wrap.workspace-3d--canvas-only {
border: 0;
border-radius: 0;
min-height: 0;
background: transparent;
box-shadow: none;
}
.workspace-3d-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 12px;
color: rgba(232, 215, 245, 0.85);
}
.workspace-3d-toolbar .hint {
color: rgba(201, 166, 227, 0.7);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 11.5px;
}
.workspace-3d-toolbar .spacer { flex: 1; }
.workspace-3d-frame {
flex: 1; position: relative;
background: radial-gradient(ellipse at 50% 40%, #2d1456 0%, #1a0930 50%, #0f0420 100%);
}
.workspace-3d-iframe {
width: 100%; height: 100%; border: 0; background: transparent; display: block;
}
.workspace-3d-mask {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
background:
radial-gradient(ellipse at center, rgba(var(--mg-primary-rgb), 0.35) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.92) 80%),
rgba(var(--mg-bg-app-deep-rgb), 0.85);
color: rgba(232, 220, 255, 0.92);
font-size: 13px;
backdrop-filter: blur(10px);
}
.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)); }
</style>
@@ -0,0 +1,158 @@
<template>
<section class="mmcg">
<header class="mmcg-head">
<span class="mmcg-title">{{ title }}</span>
<el-tag size="small" effect="plain" type="info">{{ summaryText }}</el-tag>
<div class="spacer" />
<el-button-group v-if="available.length">
<el-button size="small" :disabled="!canSelectAll" @click="selectAll">全选</el-button>
<el-button size="small" :disabled="!selected.length" @click="clearAll">清空</el-button>
</el-button-group>
</header>
<p v-if="!available.length" class="mmcg-empty muted">{{ emptyTip }}</p>
<el-checkbox-group
v-else
:model-value="selected"
class="mmcg-grid"
@update:model-value="onChange">
<el-checkbox v-for="key in available" :key="key" :value="key" :label="key" class="mmcg-cb" />
</el-checkbox-group>
<!-- 处理"残留键"已勾选但不再出现在 available 后端实现变了 / 插件卸载了-->
<div v-if="orphanKeys.length" class="mmcg-orphans">
<el-alert
type="warning"
:closable="false"
show-icon
:title="`检测到 ${orphanKeys.length} 个残留键(后端已经不再暴露,但仍在白名单中)`">
<template #default>
<div class="orphan-list">
<el-tag
v-for="key in orphanKeys"
:key="key"
type="warning"
size="small"
closable
@close="removeOrphan(key)">
{{ key }}
</el-tag>
<el-button link type="warning" size="small" @click="clearOrphans">一键清理全部残留</el-button>
</div>
</template>
</el-alert>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CheckboxValueType } from 'element-plus'
const props = defineProps<{
title: string
available: string[]
selected: string[]
emptyTip?: string
}>()
const emit = defineEmits<{
(e: 'update:selected', value: string[]): void
}>()
const summaryText = computed(() => {
if (!props.available.length) return '空'
if (!props.selected.length) return `白名单空 = 显示全部 (${props.available.length})`
return `${props.selected.length} / ${props.available.length}`
})
// available 是后端最新扫描结果。selected 里可能有 "残留"——之前勾选过但后端现在不再返回(插件
// 卸载 / 类型重命名)。这些键不会出现在 checkbox-group 里(避免渲染陌生项),但要单独列出来
// 让用户决定是否清理;不主动删,避免误清"暂时离线的插件键"。
const orphanKeys = computed(() =>
props.selected.filter((k) => !props.available.includes(k))
)
const canSelectAll = computed(() => props.available.length > props.selected.length)
function onChange(v: CheckboxValueType[]) {
// element-plus 在泛型推断下会拿到 CheckboxValueTypestring | number | boolean)。我们这里键
// 全是 string,强制断言;同时把可能残留的 orphan 键并回去,避免「在某 kind 全选」操作把
// orphan 默默丢掉(用户应该走"清理残留"显式确认)。
const next = (v as string[]).filter((x) => typeof x === 'string')
const merged = [...next, ...orphanKeys.value]
emit('update:selected', merged)
}
function selectAll() {
emit('update:selected', [...props.available, ...orphanKeys.value])
}
function clearAll() {
// 仅清空 available 内的;orphan 保留(防误删,要走"残留清理"流程)
emit('update:selected', [...orphanKeys.value])
}
function removeOrphan(key: string) {
emit('update:selected', props.selected.filter((k) => k !== key))
}
function clearOrphans() {
emit('update:selected', props.selected.filter((k) => props.available.includes(k)))
}
</script>
<style scoped>
.mmcg {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.mmcg-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mmcg-title {
font-weight: 600;
font-size: 13px;
color: #fff;
}
.mmcg-head .spacer { flex: 1; }
.mmcg-empty {
margin: 4px 0 0;
font-size: 12px;
font-style: italic;
}
.muted { color: rgba(255, 255, 255, 0.62); }
.mmcg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 4px 12px;
align-items: center;
}
.mmcg-grid :deep(.el-checkbox) {
margin-right: 0;
height: 28px;
}
.mmcg-grid :deep(.el-checkbox__label) {
color: rgba(255, 255, 255, 0.92);
font-size: 12.5px;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.mmcg-orphans { margin-top: 4px; }
.orphan-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
</style>
@@ -0,0 +1,188 @@
<template>
<el-dialog
:model-value="modelValue"
title="AI 生图"
width="640px"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@close="onClose"
>
<el-alert v-if="!configured" type="warning" :closable="false" class="cfg-alert">
尚未配置 AI 服务(apiKey / endpoint)。请先到
<el-link type="primary" @click="goConfig">系统级配置 → AI 服务</el-link>
完成配置。
</el-alert>
<el-form label-width="100px" size="default">
<el-form-item label="提示词" required>
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="一条 U 型生产线 5 个工站间距 2m方向单向通行..."
/>
</el-form-item>
<el-form-item label="参考图片">
<el-upload
drag
:auto-upload="false"
:show-file-list="false"
accept="image/*"
@change="onPickImage"
>
<div class="upload-hint">
<el-icon><UploadFilled /></el-icon>
<span>{{ form.referenceImageBase64 ? '已选择图片 (点击替换)' : '拖入或点击上传参考图(可选,多模态)' }}</span>
</div>
</el-upload>
</el-form-item>
<el-form-item label="生成模式">
<el-radio-group v-model="form.mode">
<el-radio-button value="sites">仅站点</el-radio-button>
<el-radio-button value="tracks">仅路径</el-radio-button>
<el-radio-button value="sites+tracks">站点+路径</el-radio-button>
<el-radio-button value="full">完整地图</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围 (mm)">
<el-input-number v-model="bounds[0]" placeholder="x1" :step="500" />
<el-input-number v-model="bounds[1]" placeholder="y1" :step="500" class="ml" />
<el-input-number v-model="bounds[2]" placeholder="x2" :step="500" class="ml" />
<el-input-number v-model="bounds[3]" placeholder="y2" :step="500" class="ml" />
</el-form-item>
<el-form-item label="图层">
<el-input v-model="form.layer" placeholder="ai-generated(默认)" />
</el-form-item>
<el-form-item label="附加约束">
<el-input v-model="form.constraints" placeholder="例如:单向通行、最小转弯半径 1500mm" />
</el-form-item>
</el-form>
<el-collapse v-if="result" v-model="resultPanel" class="result-panel">
<el-collapse-item title="AI 返回 / 落地结果" name="r">
<div v-if="result.assistantText" class="ai-text">{{ result.assistantText }}</div>
<div class="ai-stat">
落地对象 <b>{{ result.created?.length ?? 0 }}</b>,工具调用次数 <b>{{ result.usedTools }}</b>。
</div>
<el-table :data="result.created ?? []" size="small" max-height="220">
<el-table-column label="工具" prop="tool" width="160" />
<el-table-column label="kind" prop="kind" width="80" />
<el-table-column label="id" prop="id" width="80" />
<el-table-column label="typeName" prop="typeName" />
<el-table-column label="错误">
<template #default="{ row }">
<el-tag v-if="row.error" type="danger" size="small">{{ row.error }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
<template #footer>
<el-button @click="onClose">关闭</el-button>
<el-button type="primary" :loading="busy" :disabled="!form.prompt.trim() || !configured" @click="onGenerate">
生成并落地
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
import { useRouter } from 'vue-router'
const props = defineProps<{
modelValue: boolean
configured: boolean
/** 默认 boundsx1, y1, x2, y2mm。 */
defaultBounds?: [number, number, number, number]
/** 默认图层。 */
defaultLayer?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'generated', r: AiMapGenerateResult): void
}>()
const router = useRouter()
const busy = ref(false)
const result = ref<AiMapGenerateResult | null>(null)
const resultPanel = ref<string[]>(['r'])
const form = reactive<AiMapGenerateRequest>({
prompt: '',
mode: 'sites+tracks',
referenceImageBase64: undefined,
layer: props.defaultLayer ?? 'ai-generated',
constraints: ''
})
const bounds = ref<[number, number, number, number]>(
props.defaultBounds ?? [-10000, -10000, 10000, 10000]
)
watch(() => props.defaultBounds, (nv) => { if (nv) bounds.value = nv as [number, number, number, number] })
watch(() => props.defaultLayer, (nv) => { if (nv) form.layer = nv })
function onClose() {
emit('update:modelValue', false)
}
function goConfig() {
emit('update:modelValue', false)
router.push('/admin/config/system')
}
async function onPickImage(f: UploadFile) {
if (!f.raw) return
const reader = new FileReader()
reader.onload = () => {
const result = String(reader.result || '')
const base64 = result.includes(',') ? result.split(',', 2)[1]! : result
form.referenceImageBase64 = base64
}
reader.readAsDataURL(f.raw)
}
async function onGenerate() {
try {
busy.value = true
form.bounds = bounds.value
const r = await mapEditApi.aiMapGenerate(form)
result.value = r
ElMessage.success(`AI 已生成 ${r.created?.length ?? 0} 个对象`)
emit('generated', r)
} catch (err) {
ElMessage.error(`AI 生图失败:${(err as Error).message}`)
} finally {
busy.value = false
}
}
</script>
<style scoped>
.cfg-alert { margin-bottom: 12px; }
.ml { margin-left: 6px; }
/* dialog 内文字色统一用 inherit / CSS 变量,避免白底主题下出现 "白字白底" 看不清。 */
.upload-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--el-text-color-regular, rgba(232,215,245,0.7));
}
.result-panel { margin-top: 8px; }
.ai-text {
color: var(--el-text-color-primary, rgba(232,215,245,0.85));
padding: 6px 0;
white-space: pre-wrap;
}
.ai-stat {
padding: 6px 0;
color: var(--el-text-color-primary, rgba(232,215,245,0.85));
}
.ai-stat b { color: var(--mg-accent, #c4a4ff); }
</style>
@@ -0,0 +1,851 @@
<template>
<div class="edit-property-panel">
<el-tabs v-model="tab" class="prop-tabs">
<el-tab-pane label="对象" name="object">
<div v-if="selectionCount === 0" class="empty-hint">
<el-icon class="empty-icon"><InfoFilled /></el-icon>
<span class="empty-title">未选中对象</span>
<span class="empty-desc">在画布上点击或框选以编辑属性</span>
</div>
<div v-else-if="loading" class="loading-hint">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载属性</span>
</div>
<div v-else class="object-pane">
<!-- Header cardID / 类型 / 名称 / 图层 / 删除按钮 -->
<section class="prop-card prop-card--header">
<div class="prop-card-row">
<div class="prop-id-block">
<span class="prop-id-tag">{{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }}</span>
<span class="prop-type-tag">{{ primary?.typeName ?? '-' }}</span>
</div>
<div class="prop-header-actions">
<el-tag
v-if="primary?.status"
:type="statusTagType"
size="small"
effect="dark"
class="prop-status-tag"
>
{{ primary.status }}
</el-tag>
<el-tooltip
:content="selectionCount > 1 ? `删除选中的 ${selectionCount} 个对象(可 Ctrl+Z 撤销)` : '删除该对象(可 Ctrl+Z 撤销)'"
placement="top"
:show-after="220"
>
<button
type="button"
class="prop-delete-btn"
:disabled="deleting"
@click="onDelete"
>
<span class="prop-delete-icon">{{ deleting ? '⟳' : '🗑' }}</span>
<span>删除</span>
</button>
</el-tooltip>
</div>
</div>
<div class="prop-card-row prop-name-row">
<label class="prop-name-label">名称</label>
<el-input
v-if="primary"
:model-value="editingName"
size="small"
placeholder="对象名"
@update:model-value="(v: string) => editingName = v"
@blur="onPrimaryNameBlur"
@change="onPrimaryNameSubmit"
/>
</div>
<div class="prop-card-row prop-layer-row">
<label class="prop-name-label">图层</label>
<el-select
v-if="primary"
:model-value="primary.layer ?? ''"
size="small"
placeholder=""
class="prop-layer-select"
@update:model-value="(v: any) => onPrimaryLayerChange(v)"
>
<el-option label="" value="" />
<el-option v-for="l in layers" :key="l" :label="l" :value="l" />
</el-select>
</div>
</section>
<!-- 几何 / 位置:x, y, z, size, angle, rotation, scale, radius, siteA, siteB, ... -->
<section v-if="positionFields.length > 0" class="prop-card prop-card--geom">
<header class="prop-card-header">
<span class="prop-card-title">位置 / 几何</span>
<span class="prop-card-flag" :class="{ 'is-synced': syncFlash }" :title="syncFlash ? '已同步到画布' : '改动会立即同步到 SimpleLite 画布'">
<span class="prop-card-flag-dot" />
<span class="prop-card-flag-text">{{ syncFlash ? '已同步' : '同步到画布' }}</span>
</span>
<span class="prop-card-meta">{{ positionFields.length }} 项</span>
</header>
<div class="prop-grid">
<div v-for="f in positionFields" :key="f.key" class="prop-grid-item">
<label class="prop-field-label">
{{ fieldDisplayLabel(f.key) }}
<span v-if="fieldUnit(f.key)" class="prop-field-unit">{{ fieldUnit(f.key) }}</span>
</label>
<el-input-number
v-if="isNumberField(f.key)"
:model-value="numericValue(f.value)"
:controls="false"
:step="numericStep(f.key)"
size="small"
class="prop-field-num"
@change="(v: number | undefined) => onFieldChange(f.key, v == null ? '' : String(v))"
/>
<el-input
v-else
:model-value="f.value"
size="small"
spellcheck="false"
@change="(v: string) => onFieldChange(f.key, v)"
/>
</div>
</div>
</section>
<!-- 样式:颜色、显示开关 -->
<section v-if="styleFields.length > 0" class="prop-card prop-card--style">
<header class="prop-card-header">
<span class="prop-card-title">样式 / 外观</span>
<span class="prop-card-meta">{{ styleFields.length }} 项</span>
</header>
<div class="prop-grid">
<div v-for="f in styleFields" :key="f.key" class="prop-grid-item">
<label class="prop-field-label">{{ fieldDisplayLabel(f.key) }}</label>
<el-color-picker
v-if="isColorField(f.key, f.value)"
:model-value="f.value || '#ffffff'"
show-alpha
size="small"
@change="(v: string | null) => onFieldChange(f.key, v ?? '')"
/>
<el-switch
v-else-if="isBooleanField(f.key, f.value)"
:model-value="parseBool(f.value)"
size="small"
@change="(v: any) => onFieldChange(f.key, v ? 'true' : 'false')"
/>
<el-input
v-else
:model-value="f.value"
size="small"
spellcheck="false"
@change="(v: string) => onFieldChange(f.key, v)"
/>
</div>
</div>
</section>
<!-- 其它 / 自定义字段(兜底,可添加) -->
<section class="prop-card prop-card--custom">
<header class="prop-card-header">
<span class="prop-card-title">其它字段 / 自定义</span>
<span class="prop-card-meta">{{ otherFields.length }} 项</span>
</header>
<div v-if="otherFields.length > 0" class="prop-grid">
<div v-for="f in otherFields" :key="f.key" class="prop-grid-item prop-grid-item--wide">
<label class="prop-field-label" :title="f.key">{{ f.key }}</label>
<div class="prop-field-row">
<el-input
:model-value="f.value"
size="small"
spellcheck="false"
@change="(v: string) => onFieldChange(f.key, v)"
/>
<el-tooltip content="删除该字段" placement="top">
<button
class="prop-field-icon-btn"
@click="onFieldChange(f.key, '')"
>×</button>
</el-tooltip>
</div>
</div>
</div>
<div v-else class="prop-empty-row">无其它字段</div>
<button class="prop-add-field-btn" @click="onAddField"> 添加字段</button>
</section>
<!-- 运行状态(只读) -->
<section v-if="status.length > 0" class="prop-card prop-card--status">
<header class="prop-card-header">
<span class="prop-card-title">运行状态</span>
<span class="prop-card-meta">{{ status.length }} 项</span>
</header>
<div class="prop-status-list">
<div v-for="s in status" :key="s.key" class="prop-status-item">
<span class="prop-status-key">{{ s.key }}</span>
<span class="prop-status-value" :title="s.value">{{ s.value }}</span>
</div>
</div>
</section>
<!-- 操作 -->
<section v-if="methods.length > 0" class="prop-card prop-card--actions">
<header class="prop-card-header">
<span class="prop-card-title">操作</span>
<span class="prop-card-meta">{{ methods.length }} 项</span>
</header>
<div class="prop-actions">
<el-tooltip
v-for="m in methods"
:key="m.methodName"
:content="m.description || m.label || m.methodName"
placement="top"
:show-after="220"
>
<button
class="prop-action-btn"
:disabled="actionBusy === m.methodName"
@click="onAction(m.methodName)"
>
<span v-if="actionBusy === m.methodName" class="prop-action-spinner">⟳</span>
<span class="prop-action-icon">▶</span>
<span class="prop-action-label">{{ m.label || m.methodName }}</span>
</button>
</el-tooltip>
</div>
</section>
</div>
</el-tab-pane>
<el-tab-pane label="类型默认" name="defaults">
<el-form label-width="120px" size="small" class="defaults-form">
<el-alert type="info" :closable="false">
为「新建站点 / 新建路径」预设默认样式与字段。保存后下次创建即生效。
</el-alert>
<el-divider content-position="left">站点</el-divider>
<el-form-item label="默认图层">
<el-input v-model="defaults.site.layer" />
</el-form-item>
<el-form-item label="名字前缀">
<el-input v-model="defaults.site.namePrefix" />
</el-form-item>
<el-form-item label="默认颜色">
<el-color-picker v-model="defaults.site.color" show-alpha />
</el-form-item>
<el-divider content-position="left">路径</el-divider>
<el-form-item label="默认图层">
<el-input v-model="defaults.track.layer" />
</el-form-item>
<el-form-item label="默认线宽">
<el-input-number v-model="defaults.track.lineWidth" :min="1" :max="20" />
</el-form-item>
<el-form-item label="默认颜色">
<el-color-picker v-model="defaults.track.color" show-alpha />
</el-form-item>
<el-button size="small" type="primary" @click="onSaveDefaults">保存默认</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="图层" name="layers">
<el-table :data="layerRows" size="small" class="layer-table">
<el-table-column prop="name" label="图层" width="120" />
<el-table-column label="可见" width="60">
<template #default="{ row }">
<el-switch v-model="row.visible" @change="onLayerToggle(row)" />
</template>
</el-table-column>
<el-table-column label="可选" width="60">
<template #default="{ row }">
<el-switch v-model="row.selectable" @change="onLayerToggle(row)" />
</template>
</el-table-column>
<el-table-column label="颜色">
<template #default="{ row }">
<el-color-picker v-model="row.color" @change="onLayerToggle(row)" />
</template>
</el-table-column>
</el-table>
<el-input v-model="newLayerName" size="small" placeholder="新图层名" class="new-layer">
<template #append>
<el-button @click="onAddLayer">添加</el-button>
</template>
</el-input>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { InfoFilled, Loading } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import type { ReflectionMethod, ReflectionObject } from '@/api/reflection'
interface PropFieldKv { key: string; value: string }
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
const props = defineProps<{
selectionCount: number
primary: ReflectionObject | null
fields: PropFieldKv[]
status: PropFieldKv[]
methods: ReflectionMethod[]
loading: boolean
layers: string[]
defaults: {
site: { layer: string; namePrefix: string; color: string }
track: { layer: string; lineWidth: number; color: string }
}
layerRows: LayerRow[]
}>()
const emit = defineEmits<{
(e: 'rename', name: string): void
(e: 'change-layer', layer: string): void
(e: 'set-field', key: string, value: string): void
(e: 'add-field'): void
(e: 'exec-action', method: string): void
(e: 'save-defaults'): void
(e: 'layer-toggle', row: LayerRow): void
(e: 'add-layer', name: string): void
(e: 'delete'): void
}>()
const tab = ref<'object' | 'defaults' | 'layers'>('object')
const actionBusy = ref<string | null>(null)
const newLayerName = ref('')
const deleting = ref(false)
const syncFlash = ref(false)
let syncFlashTimer: ReturnType<typeof setTimeout> | null = null
function flashSync() {
syncFlash.value = true
if (syncFlashTimer) clearTimeout(syncFlashTimer)
syncFlashTimer = setTimeout(() => { syncFlash.value = false }, 1200)
}
// ──────────────────────────────────────────────────────────────────────────
// 字段分组
//
// 后端 ReflectionApiController/MapEditApiController 对每个被 [FieldMember] 标的
// public 字段都吐出来(含 `id / name / layerName / x / y / z / color /
// displaySetting / radius / direction / typeInfo / siteA / siteB / mustFree / ...`)。
// 这里的策略:
// - 顶部 Header 卡片已经覆盖 id / name / layerName / status,所以从 fields 中剔除这些;
// - 「位置 / 几何」吃掉所有跟坐标 / 角度 / 大小 / 半径 / 端点站点强相关的 key;
// - 「样式 / 外观」吃掉 color / displaySetting / direction / typeInfo / mustFree / 文字渲染相关;
// - 剩下的统统进「其它字段」(含用户 [FieldMember] 自定义、tag 反序、随便加上去的 free-form key)。
// 这样不管后端塞什么字段进来都能露出来 + 可编辑。
// ──────────────────────────────────────────────────────────────────────────
const HEADER_KEYS = new Set(['id', 'name', 'layerName'])
const POSITION_KEYS = new Set([
'x', 'y', 'z',
'cx', 'cy', 'cz',
'dx', 'dy', 'dz',
'sx', 'sy', 'ex', 'ey',
'startX', 'startY', 'endX', 'endY',
'size', 'width', 'height', 'depth',
'angle', 'rotation', 'theta',
'scale',
'radius', 'r',
'siteA', 'siteB'
])
const STYLE_KEYS = new Set([
'color',
'displaySetting',
'direction',
'typeInfo',
'mustFree',
'imagePath',
'modelPath',
'fontFamily',
'fontSize',
'textColor',
'lineWidth',
'opacity'
])
const FIELD_LABELS: Record<string, string> = {
x: 'X',
y: 'Y',
z: 'Z',
size: '大小',
width: '宽',
height: '高',
depth: '厚',
angle: '角度',
rotation: '旋转',
theta: 'θ',
scale: '缩放',
radius: '半径',
siteA: '起点站',
siteB: '终点站',
startX: '起点 X',
startY: '起点 Y',
endX: '终点 X',
endY: '终点 Y',
cx: '中心 X',
cy: '中心 Y',
color: '颜色',
displaySetting: '显示设置',
direction: '方向',
typeInfo: '类型信息',
mustFree: '必须互斥',
imagePath: '图片路径',
modelPath: '模型路径',
fontSize: '字号',
textColor: '文字色',
lineWidth: '线宽',
opacity: '透明度'
}
const FIELD_UNITS: Record<string, string> = {
x: 'mm', y: 'mm', z: 'mm',
cx: 'mm', cy: 'mm', cz: 'mm',
dx: 'mm', dy: 'mm', dz: 'mm',
startX: 'mm', startY: 'mm',
endX: 'mm', endY: 'mm',
width: 'mm', height: 'mm', depth: 'mm',
size: 'mm', radius: 'mm',
angle: '°', rotation: '°', theta: '°',
fontSize: 'px', lineWidth: 'px'
}
const editableFields = computed(() => props.fields.filter((f) => !HEADER_KEYS.has(f.key)))
const positionFields = computed(() => editableFields.value.filter((f) => POSITION_KEYS.has(f.key)))
const styleFields = computed(() => editableFields.value.filter((f) => STYLE_KEYS.has(f.key)))
const otherFields = computed(() =>
editableFields.value.filter((f) => !POSITION_KEYS.has(f.key) && !STYLE_KEYS.has(f.key))
)
function fieldDisplayLabel(key: string): string {
return FIELD_LABELS[key] ?? key
}
function fieldUnit(key: string): string {
return FIELD_UNITS[key] ?? ''
}
function numericValue(v: string): number | undefined {
if (v == null || v === '') return undefined
const n = Number(v)
return Number.isFinite(n) ? n : undefined
}
function numericStep(key: string): number {
if (key.toLowerCase().includes('angle') || key.toLowerCase().includes('rotation') || key === 'theta') return 1
if (key === 'scale' || key === 'opacity') return 0.1
return 10
}
function isNumberField(key: string): boolean {
return POSITION_KEYS.has(key)
}
function isColorField(key: string, value: string): boolean {
if (key === 'color' || key === 'textColor') return true
return /^#[0-9a-fA-F]{3,8}$/.test(value)
}
function isBooleanField(_key: string, value: string): boolean {
if (value === 'true' || value === 'false' || value === 'True' || value === 'False') return true
return false
}
function parseBool(v: string): boolean {
return v === 'true' || v === 'True' || v === '1'
}
const statusTagType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const s = (props.primary?.status ?? '').toLowerCase()
if (!s) return 'info'
if (s.includes('error') || s.includes('alarm') || s.includes('failed')) return 'danger'
if (s.includes('warn') || s.includes('idle')) return 'warning'
if (s.includes('online') || s.includes('running') || s.includes('ready') || s.includes('ok')) return 'success'
return 'info'
})
// 本地名称缓冲:避免 v-model 直接 mutate 父级传下的 primary props(违反 vue/no-mutating-props)。
// 父级 primary 变化时同步进 editingName;用户改完按回车/失焦时 emit('rename') 向上抛新值。
const editingName = ref<string>('')
watch(() => props.primary?.name ?? '', (n) => { editingName.value = n }, { immediate: true })
function onPrimaryNameBlur() { onPrimaryNameSubmit() }
function onPrimaryNameSubmit() {
if (!props.primary) return
const next = editingName.value
if (next === (props.primary.name ?? '')) return
emit('rename', next)
}
function onPrimaryLayerChange(v: string) {
emit('change-layer', v)
}
function onFieldChange(key: string, value: string) {
emit('set-field', key, value)
if (POSITION_KEYS.has(key)) flashSync()
}
function onAddField() { emit('add-field') }
async function onAction(method: string) {
try {
actionBusy.value = method
emit('exec-action', method)
} finally {
actionBusy.value = null
}
}
function onSaveDefaults() { emit('save-defaults') }
function onLayerToggle(row: LayerRow) { emit('layer-toggle', row) }
function onAddLayer() {
const n = newLayerName.value.trim()
if (!n) return
emit('add-layer', n)
newLayerName.value = ''
}
function onDelete() {
if (props.selectionCount === 0 || deleting.value) return
// 仅置 busy;真正的删除由父组件 deleteSelection 走 history.run
// SSE object-deleted 回来后 selection 自动清空、本面板会被父组件解除。
// 这里 100ms 后自动放开按钮,避免父组件 await 期间用户疯狂双击导致重复入栈。
deleting.value = true
emit('delete')
setTimeout(() => { deleting.value = false }, 600)
}
</script>
<style scoped>
.edit-property-panel {
height: 100%;
width: 360px;
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
border-left: 1px solid rgba(255,255,255,0.10);
padding: 0;
backdrop-filter: blur(10px);
overflow: hidden;
display: flex;
flex-direction: column;
color: rgba(232,215,245,0.92);
}
.prop-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.prop-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.prop-tabs :deep(.el-tabs__nav-wrap)::after {
background: transparent;
}
.prop-tabs :deep(.el-tabs__item) {
color: rgba(220, 200, 240, 0.7);
font-weight: 500;
font-size: 13px;
height: 36px;
line-height: 36px;
}
.prop-tabs :deep(.el-tabs__item.is-active) {
color: #fff;
}
.prop-tabs :deep(.el-tabs__active-bar) {
background: linear-gradient(90deg, rgba(170, 110, 250, 1) 0%, rgba(255, 110, 220, 1) 100%);
height: 2px;
}
.prop-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: auto;
padding: 10px 12px 14px;
}
.empty-hint {
display: flex; align-items: center; gap: 6px;
padding: 56px 16px; text-align: center;
color: rgba(220, 200, 240, 0.55);
flex-direction: column;
}
.empty-icon { font-size: 28px; color: rgba(180, 130, 220, 0.45); }
.empty-title { font-size: 13px; color: rgba(232, 215, 245, 0.75); font-weight: 500; }
.empty-desc { font-size: 11.5px; color: rgba(220, 200, 240, 0.5); }
.loading-hint {
display: flex; align-items: center; gap: 8px;
padding: 32px 16px; text-align: center;
color: rgba(232,215,245,0.65); font-size: 12.5px;
justify-content: center;
}
.object-pane {
display: flex;
flex-direction: column;
gap: 10px;
}
.prop-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.20);
}
.prop-card--header {
background: linear-gradient(140deg, rgba(170, 110, 250, 0.18) 0%, rgba(70, 30, 110, 0.10) 100%);
border-color: rgba(170, 110, 250, 0.28);
}
.prop-card--geom {
background: linear-gradient(140deg, rgba(80, 140, 220, 0.10) 0%, rgba(40, 70, 130, 0.06) 100%);
border-color: rgba(120, 170, 230, 0.22);
}
.prop-card--style {
background: linear-gradient(140deg, rgba(220, 110, 200, 0.10) 0%, rgba(150, 70, 130, 0.06) 100%);
border-color: rgba(230, 130, 200, 0.22);
}
.prop-card--custom {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(170, 110, 250, 0.18);
}
.prop-card--status { background: rgba(80, 140, 220, 0.06); border-color: rgba(120, 170, 230, 0.18); }
.prop-card--actions { background: rgba(140, 80, 200, 0.06); border-color: rgba(170, 110, 250, 0.18); }
.prop-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.prop-card-title {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 1px;
color: rgba(220, 180, 250, 0.85);
text-transform: uppercase;
}
.prop-card-meta {
font-size: 10.5px;
color: rgba(200, 180, 220, 0.5);
}
.prop-card-flag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10.5px;
color: rgba(180, 200, 240, 0.6);
padding: 2px 7px;
border-radius: 10px;
background: rgba(120, 160, 220, 0.10);
transition: background .25s ease, color .25s ease, box-shadow .25s ease;
margin-left: auto;
}
.prop-card-flag.is-synced {
background: linear-gradient(120deg, rgba(80, 200, 130, 0.32), rgba(120, 220, 200, 0.32));
color: rgba(220, 255, 230, 0.95);
box-shadow: 0 0 8px rgba(80, 200, 130, 0.45);
}
.prop-card-flag-dot {
width: 6px; height: 6px; border-radius: 50%;
background: rgba(180, 200, 240, 0.6);
transition: background .25s ease, box-shadow .25s ease;
}
.prop-card-flag.is-synced .prop-card-flag-dot {
background: #6ce7a8;
box-shadow: 0 0 6px rgba(108, 231, 168, 0.85);
}
.prop-card-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.prop-card-row:last-child { margin-bottom: 0; }
.prop-id-block { display: flex; align-items: center; gap: 6px; }
.prop-id-tag {
background: rgba(170, 110, 250, 0.30);
color: #fff;
padding: 2px 8px;
font-size: 11.5px;
font-weight: 600;
border-radius: 4px;
letter-spacing: 0.5px;
}
.prop-type-tag {
color: rgba(232, 215, 245, 0.75);
font-size: 11.5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.prop-status-tag { font-weight: 500; }
.prop-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.prop-delete-btn {
appearance: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 5px;
font-size: 11.5px;
font-weight: 500;
letter-spacing: 0.4px;
color: rgba(255, 200, 210, 0.95);
background: linear-gradient(135deg, rgba(255, 90, 120, 0.18) 0%, rgba(220, 60, 110, 0.22) 100%);
border: 1px solid rgba(255, 110, 140, 0.45);
transition: background .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
}
.prop-delete-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(255, 90, 120, 0.55) 0%, rgba(220, 60, 110, 0.65) 100%);
border-color: rgba(255, 140, 170, 0.85);
color: #fff;
box-shadow: 0 3px 10px rgba(220, 70, 110, 0.45);
}
.prop-delete-btn:active:not(:disabled) { transform: scale(0.96); }
.prop-delete-btn:disabled { opacity: 0.55; cursor: progress; }
.prop-delete-icon { font-size: 12px; line-height: 1; }
.prop-name-row, .prop-layer-row { gap: 8px; }
.prop-name-label {
width: 36px;
font-size: 11.5px;
color: rgba(200, 180, 220, 0.7);
flex: none;
}
.prop-layer-select { flex: 1; }
.prop-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 8px;
row-gap: 8px;
}
.prop-grid-item { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.prop-grid-item--wide { grid-column: 1 / -1; }
.prop-field-label {
font-size: 11px;
color: rgba(200, 180, 230, 0.78);
letter-spacing: 0.3px;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prop-field-unit {
font-size: 10px;
color: rgba(170, 150, 200, 0.55);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
flex: none;
}
.prop-field-num :deep(.el-input-number) { width: 100%; }
.prop-field-num :deep(.el-input__wrapper) {
background: rgba(255,255,255,0.06);
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
}
.prop-field-row { display: flex; align-items: center; gap: 4px; }
.prop-field-icon-btn {
appearance: none; border: 0; cursor: pointer;
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(255, 100, 130, 0.12);
color: rgba(255, 180, 200, 0.85);
font-size: 14px; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
flex: none;
transition: background .14s, color .14s;
}
.prop-field-icon-btn:hover {
background: rgba(255, 100, 130, 0.32);
color: #fff;
}
.prop-empty-row {
font-size: 11.5px;
color: rgba(200, 180, 220, 0.4);
text-align: center;
padding: 8px 0;
}
.prop-add-field-btn {
appearance: none; border: 0; cursor: pointer;
width: 100%;
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(170, 110, 250, 0.14);
border: 1px dashed rgba(170, 110, 250, 0.40);
color: rgba(220, 180, 250, 0.92);
font-size: 12px;
font-weight: 500;
transition: background .14s ease, border-color .14s ease, color .14s ease;
}
.prop-add-field-btn:hover {
background: rgba(170, 110, 250, 0.26);
border-color: rgba(170, 110, 250, 0.65);
color: #fff;
}
.prop-status-list { display: flex; flex-direction: column; gap: 4px; }
.prop-status-item {
display: flex; justify-content: space-between; align-items: center;
padding: 5px 8px;
border-radius: 4px;
background: rgba(255,255,255,0.03);
font-size: 11.5px;
}
.prop-status-key { color: rgba(170, 200, 240, 0.85); font-weight: 500; }
.prop-status-value {
color: rgba(232, 245, 255, 0.95);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prop-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.prop-action-btn {
appearance: none; border: 0; cursor: pointer;
padding: 7px 10px;
border-radius: 7px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.20) 0%, rgba(120, 70, 220, 0.20) 100%);
border: 1px solid rgba(170, 110, 250, 0.35);
color: rgba(232, 215, 245, 0.95);
font-size: 12px;
font-weight: 500;
display: flex; align-items: center; justify-content: flex-start; gap: 6px;
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
text-align: left;
}
.prop-action-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.62) 0%, rgba(255, 110, 220, 0.55) 100%);
border-color: rgba(255, 130, 220, 0.7);
color: #fff;
box-shadow: 0 4px 12px rgba(180, 90, 220, 0.40);
}
.prop-action-btn:active:not(:disabled) { transform: scale(0.985); }
.prop-action-btn:disabled { opacity: 0.6; cursor: progress; }
.prop-action-icon {
font-size: 9px;
color: rgba(255, 200, 250, 0.85);
flex: none;
}
.prop-action-spinner {
display: inline-block;
animation: prop-spin .8s linear infinite;
font-size: 13px;
}
.prop-action-label {
flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
@keyframes prop-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.defaults-form .el-button { margin-top: 8px; }
.layer-table { margin: 6px 0; }
.new-layer { margin-top: 8px; }
</style>
@@ -0,0 +1,59 @@
<template>
<div class="edit-status-bar">
<span class="seg">
选中 <b>{{ selectedCount }}</b>
</span>
<span class="seg">
鼠标 ({{ formatXY(mouseX) }}, {{ formatXY(mouseY) }}) mm
</span>
<span class="seg">
当前工具<b>{{ toolLabel }}</b>
</span>
<span class="seg">
吸附<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b>
</span>
<span class="spacer" />
<span class="seg">历史 {{ undoSize }} / {{ redoSize }}</span>
<span v-if="connected" class="seg ok"> 实时通道在线</span>
<span v-else class="seg warn"> 实时通道断开</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
selectedCount: number
mouseX: number
mouseY: number
toolLabel: string
snapOn: boolean
undoSize: number
redoSize: number
connected: boolean
}>()
function formatXY(v: number) {
if (!Number.isFinite(v)) return '--'
return v.toFixed(0)
}
</script>
<style scoped>
.edit-status-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 14px;
background: rgba(20, 8, 40, 0.85);
border-top: 1px solid rgba(255,255,255,0.08);
color: rgba(232,215,245,0.85);
font-size: 12px;
font-family: ui-monospace, Menlo, Consolas, monospace;
height: 28px;
}
.spacer { flex: 1; }
.seg b { color: var(--mg-accent, #c4a4ff); }
.seg .on { color: #67c23a; }
.seg .off { color: #a0a0a0; }
.seg.ok { color: #67c23a; }
.seg.warn { color: #f56c6c; }
</style>
@@ -0,0 +1,256 @@
<template>
<div class="edit-tool-rail">
<el-scrollbar class="rail-scroll">
<div v-for="(group, gi) in groups" :key="group.id" class="rail-group">
<div class="rail-group-title">
<span class="rail-group-bar" />
<span class="rail-group-text">{{ group.title }}</span>
<span class="rail-group-bar" />
</div>
<div class="rail-group-list">
<el-tooltip
v-for="t in group.tools"
:key="t.id"
:content="t.tip ?? t.label"
placement="right"
:show-after="280"
>
<button
:class="['rail-btn', { 'rail-btn--active': isActive(t.id) }]"
@click="onClick(t.id)"
>
<span class="rail-btn-glyph">{{ t.glyph }}</span>
<span class="rail-btn-label">{{ t.label }}</span>
</button>
</el-tooltip>
</div>
<div v-if="gi < groups.length - 1" class="rail-divider" />
</div>
<div class="rail-group rail-group-ai">
<div class="rail-group-title">
<span class="rail-group-bar" />
<span class="rail-group-text">AI</span>
<span class="rail-group-bar" />
</div>
<button class="rail-btn rail-btn--ai" @click="emit('open-ai')">
<span class="rail-btn-glyph"></span>
<span class="rail-btn-label">AI 生图</span>
</button>
</div>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import type { EditToolId } from '@/composables/useEditTool'
interface ToolDef { id: EditToolId; label: string; glyph: string; tip?: string }
interface ToolGroup { id: string; title: string; tools: ToolDef[] }
const props = defineProps<{
activeTool: EditToolId
}>()
const emit = defineEmits<{
(e: 'select-tool', id: EditToolId): void
(e: 'open-ai'): void
}>()
const groups: ToolGroup[] = [
{
id: 'select',
title: '选择',
tools: [
{ id: 'select.single', label: '单选', glyph: '⌖', tip: '点击拾取单个对象' },
{ id: 'select.rect', label: '矩形框选', glyph: '▱' },
{ id: 'select.lasso', label: '套索圈选', glyph: '◯' },
{ id: 'select.byType.site', label: '仅站点', glyph: '·' },
{ id: 'select.byType.polyline', label: '仅折线', glyph: '', tip: '仅 UITrack(直线/折线)' },
{ id: 'select.byType.bezier', label: '仅贝塞尔', glyph: '∽', tip: '仅 UIBezierTrack' },
{ id: 'select.byType.arc', label: '仅弧形', glyph: '◜', tip: '仅 UICircularArcTrack' },
{ id: 'select.byType.decor', label: '仅装饰', glyph: '★' },
{ id: 'select.byType.currentLayer', label: '当前图层', glyph: '▤', tip: '保留当前图层对象' },
{ id: 'select.invert', label: '反选', glyph: '⇄' },
{ id: 'select.clear', label: '清空选中', glyph: '⊘' }
]
},
{
id: 'draw',
title: '绘制',
tools: [
{ id: 'draw.site', label: '添加站点', glyph: '', tip: '点击工具后在画布单击放置' },
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或 Esc 退出' },
{ id: 'draw.track.polyline', label: '折线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 弹框输入中间控制点个数 → 连续点选每个控制点' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点 → 输入半径(≥ |AB|/2,等于即半圆)' },
{ id: 'draw.decor.line', label: '装饰线段', glyph: '' },
{ id: 'draw.decor.rect', label: '装饰矩形', glyph: '▭' },
{ id: 'draw.decor.circle', label: '装饰圆', glyph: '◯' },
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼' },
{ id: 'draw.model', label: '插入模型', glyph: '◎', tip: 'CycleGUI 3D 模型 (obj/glb/gltf)' }
]
},
{
id: 'transform',
title: '变换',
tools: [
{ id: 'transform.move', label: '移动', glyph: '✥' },
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
{ id: 'transform.scale', label: '缩放', glyph: '⤡' },
{ id: 'transform.duplicate', label: '复制副本', glyph: '⎘', tip: 'Ctrl+D' },
{ id: 'transform.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' },
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' },
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' },
{ id: 'transform.copyFields', label: '复制字段', glyph: '⇉', tip: '多选时第 1 个为源,其余为目标' }
]
},
{
id: 'align',
title: '对齐',
tools: [
{ id: 'align.left', label: '左对齐', glyph: '⫷' },
{ id: 'align.right', label: '右对齐', glyph: '⫸' },
{ id: 'align.top', label: '上对齐', glyph: '⫵' },
{ id: 'align.bottom', label: '下对齐', glyph: '⫶' },
{ id: 'align.centerH', label: '水平居中', glyph: '⇔' },
{ id: 'align.centerV', label: '垂直居中', glyph: '⇕' },
{ id: 'align.center', label: '整体居中', glyph: '⊕' },
{ id: 'align.distributeH', label: '水平等距', glyph: '⇿' },
{ id: 'align.distributeV', label: '垂直等距', glyph: '⇳' },
{ id: 'align.toFirst', label: '对齐到首', glyph: '⏮' },
{ id: 'align.toLast', label: '对齐到尾', glyph: '⏭' }
]
},
{
id: 'batch',
title: '批量生成',
tools: [
{ id: 'batch.linearH', label: '横向间距', glyph: '⇢' },
{ id: 'batch.linearV', label: '纵向间距', glyph: '⇣' },
{ id: 'batch.matrix', label: '矩阵生成', glyph: '#' },
{ id: 'batch.alongPath', label: '沿路径采样', glyph: '⤳', tip: '先选 1+ 条路径,再点本工具' },
{ id: 'batch.circular', label: '环形阵列', glyph: '◌' }
]
},
{
id: 'show',
title: '显示',
tools: [
{ id: 'show.controlPoints', label: '控制点', glyph: '⊙' },
{ id: 'show.controlHandles', label: '控制柄', glyph: '⊝' }
]
}
]
function isActive(id: EditToolId) { return id === props.activeTool }
function onClick(id: EditToolId) { emit('select-tool', id) }
</script>
<style scoped>
.edit-tool-rail {
height: 100%;
width: 170px;
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
border-right: 1px solid rgba(255,255,255,0.10);
padding: 4px 0 12px;
backdrop-filter: blur(10px);
display: flex; flex-direction: column;
box-shadow: inset -1px 0 0 rgba(255,255,255,0.04);
}
.rail-scroll { height: 100%; }
.rail-group {
display: flex; flex-direction: column;
padding: 4px 8px 2px;
}
.rail-group-title {
display: flex; align-items: center; gap: 6px;
font-size: 10px;
letter-spacing: 2px;
color: rgba(190, 160, 230, 0.7);
text-transform: uppercase;
margin: 4px 0 6px;
font-weight: 600;
}
.rail-group-bar { flex: 1; height: 1px; background: rgba(190, 160, 230, 0.2); }
.rail-group-list {
display: flex; flex-direction: column;
gap: 2px;
}
.rail-divider {
width: 100%;
height: 1px;
background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.0) 100%);
margin: 6px 0 2px;
}
.rail-btn {
appearance: none;
border: 0;
cursor: pointer;
width: 100%;
min-height: 32px;
padding: 0 8px;
border-radius: 6px;
background: rgba(255,255,255,0.04);
color: rgba(232,215,245,0.92);
display: flex; align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 500;
text-align: left;
transition: background .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
}
.rail-btn:hover {
background: rgba(150, 90, 230, 0.20);
color: #fff;
}
.rail-btn:active {
transform: scale(0.985);
}
.rail-btn.rail-btn--active {
background: linear-gradient(120deg, rgba(170, 110, 250, 0.92) 0%, rgba(120, 70, 220, 0.92) 100%);
color: #fff;
box-shadow: 0 3px 10px rgba(140, 80, 230, 0.45), inset 0 0 0 1px rgba(255,255,255,0.18);
}
.rail-btn-glyph {
width: 20px;
display: inline-flex; justify-content: center; align-items: center;
font-size: 15px;
line-height: 1;
flex: none;
}
.rail-btn-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-group-ai {
margin-top: 14px;
padding-bottom: 10px;
position: relative;
}
.rail-group-ai::before {
content: '';
position: absolute;
left: 8px; right: 8px; top: 0;
height: 1px;
background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(220, 140, 230, 0.35) 50%, rgba(255,255,255,0.0) 100%);
}
.rail-btn--ai {
background: linear-gradient(135deg, rgba(120, 70, 220, 0.55) 0%, rgba(255, 90, 200, 0.55) 100%);
color: #fff;
font-weight: 600;
box-shadow: 0 2px 10px rgba(220, 90, 220, 0.30), inset 0 0 0 1px rgba(255,255,255,0.20);
}
.rail-btn--ai:hover {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.85) 0%, rgba(255, 110, 220, 0.85) 100%);
color: #fff;
box-shadow: 0 4px 14px rgba(230, 110, 230, 0.55), inset 0 0 0 1px rgba(255,255,255,0.30);
}
.rail-btn--ai .rail-btn-glyph {
font-size: 16px;
text-shadow: 0 0 8px rgba(255, 200, 250, 0.6);
}
</style>
@@ -0,0 +1,230 @@
<template>
<div class="edit-top-bar">
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">项目加载和保存</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="project.open">加载文件</el-dropdown-item>
<el-dropdown-item command="project.save">保存文件</el-dropdown-item>
<el-dropdown-item command="project.saveAs">另存为</el-dropdown-item>
<el-dropdown-item command="project.props" divided>项目属性</el-dropdown-item>
<el-dropdown-item command="project.close">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">导入</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="file.import.dwg">导入 .dwg/.dxf</el-dropdown-item>
<el-dropdown-item command="file.import.geojson">导入 .geojson</el-dropdown-item>
<el-dropdown-item command="file.upload.image" divided>导入图片</el-dropdown-item>
<el-dropdown-item command="file.upload.model">导入 CycleGUI 模型</el-dropdown-item>
<el-dropdown-item command="file.export.png" divided>导出 PNG</el-dropdown-item>
<el-dropdown-item command="file.export.svg">导出 SVG</el-dropdown-item>
<el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">编辑</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit.cut">剪切 (Ctrl+X)</el-dropdown-item>
<el-dropdown-item command="edit.copy">复制 (Ctrl+C)</el-dropdown-item>
<el-dropdown-item command="edit.paste">粘贴 (Ctrl+V)</el-dropdown-item>
<el-dropdown-item command="edit.delete">删除 (Del)</el-dropdown-item>
<el-dropdown-item command="edit.selectAll" divided>全选</el-dropdown-item>
<el-dropdown-item command="edit.invert">反选</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!--
添加菜单直接通过坐标 / 站点 ID 落地不依赖画布 GetPoint
场景iframe 模式下 SimpleUI.GetPoint 仅监听 LocalTerminal 或其他原因画布 pick 不响应
用户可走这条菜单先把站点 / 路径 / 模拟车造出来
-->
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">添加</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="add.site.byCoord">站点 (输入 x, y)</el-dropdown-item>
<el-dropdown-item command="add.track.polyline.byIds" divided>直线路径 (输入 siteA, siteB)</el-dropdown-item>
<el-dropdown-item command="add.track.bezier.byIds">贝塞尔路径 (siteA/B + 控制点坐标)</el-dropdown-item>
<el-dropdown-item command="add.track.nurbs.byIds">NURBS 路径 (siteA/B + 控制点坐标)</el-dropdown-item>
<el-dropdown-item command="add.track.arc.byIds">弧形路径 (siteA/B + 半径)</el-dropdown-item>
<el-dropdown-item command="add.car.dummy.byCoord" divided>模拟车 (输入 x, y, 朝向)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">撤销</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit.undo" :disabled="!canUndo">撤销 (Ctrl+Z)</el-dropdown-item>
<el-dropdown-item command="edit.redo" :disabled="!canRedo">重做 (Ctrl+Y)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">视图</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view.fitAll">缩放至全屏</el-dropdown-item>
<el-dropdown-item command="view.fitSelection">缩放至选中</el-dropdown-item>
<el-dropdown-item command="view.toggleGrid" divided>显示网格</el-dropdown-item>
<el-dropdown-item command="view.toggleScale">显示比例尺</el-dropdown-item>
<el-dropdown-item command="view.toggle2D">切换 2D / 3D</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">图层</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="layer.new">新建图层</el-dropdown-item>
<el-dropdown-item command="layer.toggleVisibility">切换显示图层</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!--
过滤菜单对齐 SimpleLite 桌面底部工具栏的选择 / 对齐 / 显示内容三组开关
MapEditorView 维护 viewFilter 状态
- selectFilter.{sites,tracks,cars,decor}未勾选的类型不会进入 selection也就不会被高亮 + 不会出现在右侧属性面板等价于 SimpleLite SelectFilter*
- alignSnap.{sites,cars,tracks}影响绘制工具吸附目标
- showViewport.{sceneLabels,scenePrimitives,cars}影响右侧属性面板的类型筛选画布渲染本身仍由 SimpleLite LayerToShow / 后端开关控制等后续后端 API 打通后可双向同步
历史上图层菜单里的站点层可见 / 可选等三项是 stub已删除并搬到这里
-->
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">过滤</el-button>
<template #dropdown>
<el-dropdown-menu class="topbar-filter-menu">
<el-dropdown-item disabled class="topbar-filter-header">选择可拾取</el-dropdown-item>
<el-dropdown-item command="view.select.sites">{{ mark(viewFilter.selectFilter.sites) }} 站点</el-dropdown-item>
<el-dropdown-item command="view.select.tracks">{{ mark(viewFilter.selectFilter.tracks) }} 路径</el-dropdown-item>
<el-dropdown-item command="view.select.cars">{{ mark(viewFilter.selectFilter.cars) }} AGV</el-dropdown-item>
<el-dropdown-item command="view.select.decor">{{ mark(viewFilter.selectFilter.decor) }} 区域 / 装饰</el-dropdown-item>
<el-dropdown-item divided disabled class="topbar-filter-header">对齐鼠标吸附</el-dropdown-item>
<el-dropdown-item command="view.snap.sites">{{ mark(viewFilter.alignSnap.sites) }} 吸附站点</el-dropdown-item>
<el-dropdown-item command="view.snap.cars">{{ mark(viewFilter.alignSnap.cars) }} 吸附 AGV</el-dropdown-item>
<el-dropdown-item command="view.snap.tracks">{{ mark(viewFilter.alignSnap.tracks) }} 吸附路径</el-dropdown-item>
<el-dropdown-item divided disabled class="topbar-filter-header">显示内容</el-dropdown-item>
<el-dropdown-item command="view.show.sceneLabels">{{ mark(viewFilter.showViewport.sceneLabels) }} 场景图元标记</el-dropdown-item>
<el-dropdown-item command="view.show.scenePrimitives">{{ mark(viewFilter.showViewport.scenePrimitives) }} 场景图元</el-dropdown-item>
<el-dropdown-item command="view.show.cars">{{ mark(viewFilter.showViewport.cars) }} 车辆</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="spacer" />
<el-tooltip content="撤销 (Ctrl+Z)" placement="bottom">
<el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')"></el-button>
</el-tooltip>
<el-tooltip content="重做 (Ctrl+Y)" placement="bottom">
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')"></el-button>
</el-tooltip>
<el-tag v-if="saving" type="warning" effect="dark" class="topbar-saving-tag" size="small">
保存中
</el-tag>
</div>
</template>
<script setup lang="ts">
export interface ViewFilterState {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
}
defineProps<{
canUndo: boolean
canRedo: boolean
saving?: boolean
// 由 MapEditorView 注入:「过滤」菜单的三组开关状态。父组件保证非空。
viewFilter: ViewFilterState
}>()
const emit = defineEmits<{
(e: 'cmd', cmd: string): void
}>()
function onCmd(cmd: string) {
emit('cmd', cmd)
}
function mark(on: boolean): string {
return on ? '☑' : '☐'
}
</script>
<style scoped>
.edit-top-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
background: linear-gradient(180deg, rgba(50, 22, 88, 0.95) 0%, rgba(34, 14, 64, 0.95) 100%);
border-bottom: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.spacer { flex: 1; }
.el-divider--vertical { margin: 0 6px; background: rgba(255,255,255,0.15); }
.topbar-btn {
color: #fdf6ff !important;
font-weight: 600;
font-size: 13.5px;
padding: 6px 12px;
height: auto;
border-radius: 6px;
transition: background .15s ease, color .15s ease;
}
.topbar-btn:hover {
color: #fff !important;
background: rgba(170, 110, 250, 0.28) !important;
}
.topbar-btn:focus {
background: rgba(170, 110, 250, 0.30) !important;
}
.topbar-icon-btn {
color: rgba(253, 246, 255, 0.92) !important;
background: rgba(255,255,255,0.05) !important;
border: 1px solid rgba(255,255,255,0.15) !important;
font-size: 15px;
}
.topbar-icon-btn:hover:not(.is-disabled) {
background: rgba(170, 110, 250, 0.30) !important;
border-color: rgba(170, 110, 250, 0.55) !important;
}
.topbar-icon-btn.is-disabled {
opacity: 0.4;
}
.topbar-saving-tag {
margin-left: 8px;
}
.topbar-filter-menu :deep(.topbar-filter-header) {
font-size: 11px;
color: rgba(232, 215, 245, 0.55);
cursor: default;
pointer-events: none;
padding-top: 6px;
padding-bottom: 2px;
text-transform: none;
letter-spacing: 0.4px;
}
</style>
@@ -0,0 +1,462 @@
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="780px"
align-center
destroy-on-close
class="proj-browse-dialog"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@open="onOpen"
>
<div class="proj-browse">
<!-- 顶栏:路径 + 快捷跳转 -->
<div class="proj-browse-toolbar">
<button class="proj-icon-btn" :disabled="!parent" title="返回上级" @click="goUp">
<span class="proj-icon">←</span>
</button>
<button class="proj-icon-btn" title="刷新" @click="refresh">
<span class="proj-icon">⟳</span>
</button>
<button class="proj-icon-btn" title="SimpleLite 主目录" @click="goCwd">
<span class="proj-icon">⌂</span>
</button>
<el-input
v-model="dir"
size="small"
spellcheck="false"
class="proj-path-input"
placeholder="输入或粘贴目录路径回车跳转"
@keyup.enter="refresh"
>
<template #prefix>
<span class="proj-path-prefix">📁</span>
</template>
</el-input>
</div>
<el-alert
v-if="error"
type="error"
:title="error"
:closable="false"
show-icon
class="proj-error"
/>
<!-- 双栏:左目录 / 右文件 -->
<div class="proj-browse-body">
<!-- 左栏:子目录 + 主目录 -->
<div class="proj-browse-side">
<div class="proj-side-title">服务端目录</div>
<el-scrollbar class="proj-side-scroll">
<button class="proj-side-btn proj-side-cwd" @click="goCwd">
<span class="proj-side-icon">🏠</span>
<span class="proj-side-text">SimpleLite 主目录</span>
</button>
<div v-if="parent" class="proj-side-section">
<button class="proj-side-btn" @click="goUp">
<span class="proj-side-icon">↑</span>
<span class="proj-side-text">返回上级</span>
</button>
</div>
<div class="proj-side-section">
<div class="proj-side-section-title">子文件夹</div>
<button
v-for="d in subdirs"
:key="d.fullPath"
class="proj-side-btn"
:class="{ 'proj-side-btn--active': false }"
@dblclick="enterDir(d.fullPath)"
@click="enterDir(d.fullPath)"
>
<span class="proj-side-icon">📁</span>
<span class="proj-side-text" :title="d.name">{{ d.name }}</span>
</button>
<div v-if="subdirs.length === 0" class="proj-side-empty">无子文件夹</div>
</div>
</el-scrollbar>
</div>
<!-- 右栏:JSON 文件列表 -->
<div class="proj-browse-main">
<el-table
v-loading="loading"
:data="jsons"
size="small"
height="380"
highlight-current-row
class="proj-browse-table"
empty-text="该目录下没有 JSON 项目文件"
@current-change="onRowSelect"
@row-dblclick="onRowDbl"
>
<el-table-column width="36">
<template #default>
<span class="proj-file-icon">📄</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="220">
<template #default="{ row }">
<span class="proj-file-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">
<span class="proj-mute">{{ formatSize(row.sizeBytes) }}</span>
</template>
</el-table-column>
<el-table-column prop="modified" label="修改时间" width="160">
<template #default="{ row }">
<span class="proj-mute">{{ row.modified }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 底栏:文件名输入框 + 类型筛选 -->
<div class="proj-browse-footer">
<span class="proj-footer-label">{{ mode === 'load' ? '选中文件' : '保存为' }}</span>
<el-input
v-model="manualPath"
size="small"
:placeholder="mode === 'load'
? '在右侧选择 JSON 文件,或直接输入绝对路径'
: '输入文件名(不写绝对路径则保存到当前目录)'"
spellcheck="false"
class="proj-manual-input"
/>
<span class="proj-footer-type">JSON (*.json)</span>
</div>
</div>
<template #footer>
<div class="proj-footer-bar">
<span class="proj-footer-tip">{{ footerTip }}</span>
<span class="proj-footer-actions">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button
type="primary"
:loading="loading"
:disabled="!canConfirm"
@click="onConfirm"
>
{{ mode === 'load' ? '加载此文件' : '保存到此文件' }}
</el-button>
</span>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { mapEditApi } from '@/api/mapEdit'
interface BrowseRow {
kind: 'dir' | 'file'
name: string
fullPath: string
sizeBytes?: number
modified?: string
}
const props = defineProps<{
modelValue: boolean
/** 'load' 用于「加载文件」对话框;'save' 用于「另存为」对话框(可选中 .json 或新建文件名)。 */
mode: 'load' | 'save'
/** 上次记住的路径,用于打开时初始定位。空字符串则用 SimpleLite CWD。 */
initialPath?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'confirm', path: string): void
}>()
const dir = ref('')
const parent = ref<string | null>(null)
const subdirs = ref<BrowseRow[]>([])
const jsons = ref<BrowseRow[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const selected = ref<BrowseRow | null>(null)
const manualPath = ref('')
const cwdRoot = ref('')
const dialogTitle = computed(() =>
props.mode === 'load' ? '加载项目(服务端目录)' : '保存项目(服务端目录)')
const canConfirm = computed(() => {
if (manualPath.value.trim()) return true
if (selected.value?.kind === 'file') return true
if (props.mode === 'save' && dir.value) return true
return false
})
const footerTip = computed(() => {
if (props.mode === 'save')
return manualPath.value
? `将保存到:${resolveSavePath()}`
: `当前目录:${dir.value}`
return selected.value
? `已选:${selected.value.fullPath}`
: '请在右侧选中一个 JSON 文件,或输入绝对路径'
})
async function refresh() {
loading.value = true
error.value = null
try {
const r = await mapEditApi.projectBrowse(dir.value || undefined)
dir.value = r.dir
parent.value = r.parent
subdirs.value = r.subdirs.map((d) => ({ kind: 'dir' as const, name: d.name, fullPath: d.fullPath }))
jsons.value = r.jsons.map((f) => ({
kind: 'file' as const,
name: f.name,
fullPath: f.fullPath,
sizeBytes: f.sizeBytes,
modified: f.modified
}))
if (!cwdRoot.value) cwdRoot.value = r.dir
selected.value = null
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}
function goUp() {
if (!parent.value) return
dir.value = parent.value
refresh()
}
function goCwd() {
dir.value = ''
refresh()
}
function enterDir(full: string) {
dir.value = full
refresh()
}
function onRowSelect(row: BrowseRow | null) {
selected.value = row
if (row?.kind === 'file') manualPath.value = row.fullPath
}
function onRowDbl(row: BrowseRow) {
if (row.kind === 'file') onConfirm()
}
function resolveSavePath() {
const m = manualPath.value.trim()
if (!m) return `${dir.value}/project.current.json`
// 如果用户写的是完整路径,直接用;否则当作 dir + filename。
if (/^([A-Za-z]:[\\/]|\\\\|\/)/.test(m)) return m
return `${dir.value.replace(/[\\/]+$/, '')}/${m.endsWith('.json') ? m : m + '.json'}`
}
function onConfirm() {
if (props.mode === 'save') {
const path = resolveSavePath()
if (!path) {
ElMessage.warning('请输入文件名或选中目标')
return
}
emit('confirm', path)
emit('update:modelValue', false)
return
}
const path = manualPath.value.trim() || (selected.value?.kind === 'file' ? selected.value.fullPath : '')
if (!path) {
ElMessage.warning('请先选中一个 .json 文件,或直接输入路径')
return
}
emit('confirm', path)
emit('update:modelValue', false)
}
function onOpen() {
selected.value = null
manualPath.value = ''
dir.value = props.initialPath ?? ''
refresh()
}
function formatSize(bytes: number | undefined) {
if (bytes == null) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
watch(() => props.modelValue, (v) => {
if (v) onOpen()
})
</script>
<style scoped>
.proj-browse-dialog :deep(.el-dialog__header) {
padding: 14px 20px;
margin-right: 0;
border-bottom: 1px solid rgba(170, 110, 250, 0.15);
}
.proj-browse-dialog :deep(.el-dialog__title) {
font-weight: 600;
font-size: 14px;
color: rgba(60, 30, 110, 0.95);
}
.proj-browse-dialog :deep(.el-dialog__body) {
padding: 12px 16px 8px;
}
.proj-browse {
display: flex;
flex-direction: column;
gap: 10px;
}
.proj-browse-toolbar {
display: flex;
gap: 6px;
align-items: center;
}
.proj-icon-btn {
appearance: none;
border: 1px solid rgba(170, 110, 250, 0.25);
background: rgba(245, 235, 255, 0.65);
color: rgba(80, 40, 130, 0.85);
width: 30px; height: 30px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: inline-flex; align-items: center; justify-content: center;
transition: background .14s, border-color .14s, color .14s;
}
.proj-icon-btn:hover:not(:disabled) {
background: rgba(170, 110, 250, 0.20);
border-color: rgba(170, 110, 250, 0.55);
color: rgba(40, 10, 90, 0.95);
}
.proj-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.proj-icon { font-size: 14px; line-height: 1; }
.proj-path-input { flex: 1; }
.proj-path-prefix { font-size: 12px; color: rgba(120, 80, 180, 0.7); }
.proj-error { margin: 0; }
.proj-browse-body {
display: grid;
grid-template-columns: 220px 1fr;
gap: 10px;
height: 400px;
border: 1px solid rgba(170, 110, 250, 0.18);
border-radius: 8px;
overflow: hidden;
background: rgba(250, 245, 255, 0.50);
}
.proj-browse-side {
display: flex; flex-direction: column;
border-right: 1px solid rgba(170, 110, 250, 0.15);
background: rgba(245, 238, 255, 0.65);
}
.proj-side-title {
font-size: 11px;
letter-spacing: 1px;
color: rgba(120, 80, 180, 0.85);
font-weight: 600;
text-transform: uppercase;
padding: 8px 10px;
border-bottom: 1px solid rgba(170, 110, 250, 0.10);
background: rgba(255, 255, 255, 0.45);
}
.proj-side-scroll { flex: 1; }
.proj-side-section { margin-top: 10px; padding: 0 6px; }
.proj-side-section:first-child { margin-top: 0; }
.proj-side-section-title {
font-size: 10.5px;
color: rgba(120, 80, 180, 0.7);
padding: 6px 6px 2px;
letter-spacing: 0.5px;
}
.proj-side-btn {
appearance: none;
border: 0;
background: transparent;
width: 100%;
display: flex; align-items: center; gap: 6px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12.5px;
color: rgba(60, 30, 110, 0.92);
cursor: pointer;
text-align: left;
transition: background .12s;
}
.proj-side-btn:hover { background: rgba(170, 110, 250, 0.18); }
.proj-side-btn--active { background: rgba(170, 110, 250, 0.32); color: #fff; }
.proj-side-cwd {
margin: 6px;
background: rgba(170, 110, 250, 0.14);
border: 1px solid rgba(170, 110, 250, 0.30);
}
.proj-side-cwd:hover { background: rgba(170, 110, 250, 0.28); }
.proj-side-icon { width: 16px; flex: none; text-align: center; }
.proj-side-text {
flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.proj-side-empty {
font-size: 11.5px;
color: rgba(120, 90, 160, 0.55);
padding: 8px 12px;
text-align: center;
}
.proj-browse-main { padding: 0; overflow: hidden; }
.proj-browse-table { width: 100%; }
.proj-file-icon { font-size: 14px; }
.proj-file-name {
color: rgba(40, 10, 90, 0.95);
font-weight: 500;
font-size: 12.5px;
}
.proj-mute { color: rgba(120, 90, 160, 0.7); font-size: 11.5px; }
.proj-browse-footer {
display: flex; align-items: center; gap: 8px;
padding: 4px 0;
}
.proj-footer-label {
font-size: 12px;
color: rgba(80, 40, 130, 0.85);
font-weight: 500;
flex: none;
min-width: 64px;
}
.proj-manual-input { flex: 1; }
.proj-footer-type {
font-size: 11.5px;
color: rgba(120, 90, 160, 0.7);
flex: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.proj-footer-bar {
display: flex; align-items: center; justify-content: space-between;
}
.proj-footer-tip {
font-size: 11.5px;
color: rgba(120, 90, 160, 0.7);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 460px;
}
</style>
@@ -0,0 +1,148 @@
<template>
<div
ref="cardRef"
class="floating-alarm-card"
:class="[`lvl-${level}`, { dragging }]"
:style="cardStyle"
@mousedown.stop="onMouseDown"
>
<div class="fac-header">
<span class="fac-dot" />
<span class="fac-title">{{ alarm.carName }} #{{ alarm.carId }}</span>
<span class="fac-time">{{ timeShort }}</span>
<el-icon class="fac-close" @click.stop="onClose"><Close /></el-icon>
</div>
<div class="fac-body">
<div class="fac-info">{{ alarm.info }}</div>
<div class="fac-coord">
<span v-if="hasCoord">坐标 ({{ alarm.x?.toFixed(0) }}, {{ alarm.y?.toFixed(0) }}) mm</span>
<span v-else class="muted">无坐标</span>
</div>
</div>
<div class="fac-actions">
<el-button size="small" plain :disabled="!hasCoord" @click.stop="onLocate">定位</el-button>
<el-button size="small" plain @click.stop="onAck">确认</el-button>
<el-button size="small" plain @click.stop="onSnooze">稍后</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Close } from '@element-plus/icons-vue'
import type { AlarmEvent } from '@/composables/useMapEditStream'
const props = defineProps<{
alarm: AlarmEvent
/** 卡片初始位置(相对父容器的右下角 offset,单位 px)。 */
initialOffset?: { right: number; bottom: number }
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'ack'): void
(e: 'snooze'): void
(e: 'locate', a: AlarmEvent): void
}>()
const cardRef = ref<HTMLDivElement | null>(null)
const dragging = ref(false)
const pos = ref<{ left?: number; top?: number; right?: number; bottom?: number }>({
right: props.initialOffset?.right ?? 24,
bottom: props.initialOffset?.bottom ?? 24
})
const hasCoord = computed(() => typeof props.alarm.x === 'number' && Number.isFinite(props.alarm.x))
const level = computed<'info' | 'warn' | 'error' | 'critical'>(() => {
const s = (props.alarm.info || '').toLowerCase()
if (s.includes('critical') || s.includes('严重') || s.includes('紧急')) return 'critical'
if (s.includes('error') || s.includes('故障')) return 'error'
if (s.includes('warn') || s.includes('告警')) return 'warn'
return 'info'
})
const timeShort = computed(() => {
try {
const d = new Date(props.alarm.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch { return '' }
})
const cardStyle = computed(() => ({
left: pos.value.left !== undefined ? `${pos.value.left}px` : undefined,
top: pos.value.top !== undefined ? `${pos.value.top}px` : undefined,
right: pos.value.right !== undefined ? `${pos.value.right}px` : undefined,
bottom: pos.value.bottom !== undefined ? `${pos.value.bottom}px` : undefined
}))
let startX = 0, startY = 0, originLeft = 0, originTop = 0
function onMouseDown(ev: MouseEvent) {
if (!cardRef.value) return
const rect = cardRef.value.getBoundingClientRect()
originLeft = rect.left
originTop = rect.top
startX = ev.clientX
startY = ev.clientY
dragging.value = true
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(ev: MouseEvent) {
pos.value = {
left: originLeft + (ev.clientX - startX),
top: originTop + (ev.clientY - startY)
}
}
function onMouseUp() {
dragging.value = false
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
function onClose() { emit('close') }
function onAck() { emit('ack') }
function onSnooze() { emit('snooze') }
function onLocate() { emit('locate', props.alarm) }
</script>
<style scoped>
.floating-alarm-card {
position: absolute;
width: 320px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(20, 8, 40, 0.92);
backdrop-filter: blur(10px);
box-shadow: 0 10px 32px rgba(10,2,16,0.55), 0 0 0 1px rgba(255,255,255,0.08) inset;
color: rgba(232,215,245,0.95);
font-size: 13px;
cursor: grab;
user-select: none;
z-index: 1000;
transition: box-shadow .2s, transform .2s;
}
.floating-alarm-card.dragging { cursor: grabbing; box-shadow: 0 14px 40px rgba(10,2,16,0.7); }
.fac-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.fac-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--lvl-color, #67c23a); box-shadow: 0 0 8px var(--lvl-color, #67c23a); }
.fac-title { flex: 1; font-weight: 600; }
.fac-time { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: rgba(232,215,245,0.65); }
.fac-close { cursor: pointer; opacity: .7; }
.fac-close:hover { opacity: 1; }
.fac-body { padding: 4px 0 6px; }
.fac-info { line-height: 1.4; word-break: break-all; }
.fac-coord { font-size: 11.5px; color: rgba(232,215,245,0.7); margin-top: 4px; font-family: ui-monospace, Menlo, Consolas, monospace; }
.fac-actions { display: flex; gap: 6px; justify-content: flex-end; }
.muted { color: rgba(232,215,245,0.5); }
.floating-alarm-card.lvl-info { --lvl-color: #67c23a; }
.floating-alarm-card.lvl-warn { --lvl-color: #e6a23c; }
.floating-alarm-card.lvl-error { --lvl-color: #f56c6c; }
.floating-alarm-card.lvl-critical { --lvl-color: #d63a3a; }
</style>
@@ -0,0 +1,78 @@
<template>
<div class="floating-alarm-stack">
<FloatingAlarmCard
v-for="(a, idx) in visible"
:key="`${a.carId}-${a.timestamp}`"
:alarm="a"
:initial-offset="{ right: 24, bottom: 24 + idx * 132 }"
@close="onClose(a)"
@ack="onAck(a)"
@snooze="onSnooze(a)"
@locate="onLocate"
/>
<div v-if="snoozed.length" class="snooze-tray" @click="restoreSnoozed">
<el-badge :value="snoozed.length" type="warning">
<el-button size="small" circle></el-button>
</el-badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import FloatingAlarmCard from './FloatingAlarmCard.vue'
import type { AlarmEvent } from '@/composables/useMapEditStream'
const props = defineProps<{ alarms: AlarmEvent[] }>()
const emit = defineEmits<{
(e: 'locate', a: AlarmEvent): void
(e: 'ack', a: AlarmEvent): void
}>()
const dismissed = ref<Set<string>>(new Set())
const snoozedIds = ref<Set<string>>(new Set())
function keyOf(a: AlarmEvent) { return `${a.carId}` }
const visible = computed(() =>
props.alarms
.filter((a) => a.action !== 'clear')
.filter((a) => !dismissed.value.has(keyOf(a)))
.filter((a) => !snoozedIds.value.has(keyOf(a)))
)
const snoozed = computed(() => props.alarms.filter((a) => snoozedIds.value.has(keyOf(a))))
function onClose(a: AlarmEvent) {
dismissed.value = new Set([...dismissed.value, keyOf(a)])
}
function onAck(a: AlarmEvent) {
dismissed.value = new Set([...dismissed.value, keyOf(a)])
emit('ack', a)
}
function onSnooze(a: AlarmEvent) {
snoozedIds.value = new Set([...snoozedIds.value, keyOf(a)])
}
function onLocate(a: AlarmEvent) {
emit('locate', a)
}
function restoreSnoozed() {
snoozedIds.value = new Set()
}
</script>
<style scoped>
.floating-alarm-stack {
position: absolute;
inset: 0;
pointer-events: none;
}
.floating-alarm-stack > * {
pointer-events: auto;
}
.snooze-tray {
position: absolute;
right: 24px;
top: 24px;
}
</style>
@@ -0,0 +1,332 @@
<template>
<div class="fme-root">
<div class="fme-header">
<p class="fme-desc">{{ description }}</p>
<el-descriptions v-if="pathInfos.length > 0" :column="1" size="small" border class="fme-paths">
<el-descriptions-item v-for="info in pathInfos" :key="info.label" :label="info.label">
<code v-if="info.value" class="fme-path">{{ info.value }}</code>
<span v-else class="fme-path-empty">({{ info.emptyHint ?? '空' }})</span>
</el-descriptions-item>
</el-descriptions>
<div class="fme-toolbar">
<el-input
v-model="filterText"
placeholder="按中文/英文键过滤"
size="small"
clearable
class="fme-search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
<span class="fme-btn-text">刷新</span>
</el-button>
<el-button
size="small"
type="primary"
:disabled="!hasDirty && !forceAllowSave"
:loading="saving"
@click="onSave"
>
<el-icon><Document /></el-icon>
<span class="fme-btn-text">{{ saveButtonLabel }}{{ hasDirty ? `${dirtyCount} 项未应用)` : '' }}</span>
</el-button>
<el-button v-if="hasDirty" size="small" link type="warning" @click="resetDirty">
放弃未应用的改动
</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never" class="fme-table-card">
<el-table
:data="filteredRows"
stripe
border
size="small"
height="100%"
class="fme-table"
row-key="key"
>
<el-table-column label="中文说明" prop="label" min-width="240" show-overflow-tooltip />
<el-table-column label="英文键" prop="key" width="240" show-overflow-tooltip />
<el-table-column label="类型" prop="typeName" width="100" />
<el-table-column label="当前值(可编辑)" min-width="260">
<template #default="{ row }">
<span v-if="row.locked" class="fme-locked">{{ row.value }}🔒 锁定</span>
<el-switch
v-else-if="isBool(row.typeName)"
:model-value="boolOf(getEditing(row))"
@update:model-value="(v: boolean | string | number) => setEditing(row, String(Boolean(v)))"
/>
<el-input-number
v-else-if="isInt(row.typeName)"
:model-value="intOf(getEditing(row))"
:controls="false"
:step="1"
:precision="0"
size="small"
class="fme-input"
@update:model-value="(v: number | undefined) => setEditing(row, String(v ?? 0))"
/>
<el-input-number
v-else-if="isFloat(row.typeName)"
:model-value="floatOf(getEditing(row))"
:controls="false"
:step="0.1"
size="small"
class="fme-input"
@update:model-value="(v: number | undefined) => setEditing(row, String(v ?? 0))"
/>
<el-input
v-else
:model-value="getEditing(row)"
size="small"
class="fme-input"
@update:model-value="(v: string) => setEditing(row, v)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.locked" size="small" type="info" effect="plain">只读</el-tag>
<el-tag v-else-if="isDirty(row)" size="small" type="warning" effect="dark">已修改</el-tag>
<el-tag v-else size="small" type="success" effect="plain">同步</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && rows.length === 0" :description="`未发现可编辑的 [FieldMember] 字段(target: ${targetLabel}`" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, Refresh, Search } from '@element-plus/icons-vue'
import type { ProjectPropertyRow } from '@/api/reflection'
export interface FieldMemberPathInfo {
label: string
value: string | null
emptyHint?: string
}
export interface FieldMemberEditorLoadResult {
fields: ProjectPropertyRow[]
pathInfos?: FieldMemberPathInfo[]
saveButtonLabel?: string
}
const props = defineProps<{
targetLabel: string
description: string
loader: () => Promise<FieldMemberEditorLoadResult>
onSet: (field: string, value: string) => Promise<unknown>
onSave: () => Promise<{ path: string }>
}>()
const loading = ref(false)
const saving = ref(false)
const filterText = ref('')
const rows = ref<ProjectPropertyRow[]>([])
const pathInfos = ref<FieldMemberPathInfo[]>([])
const saveButtonLabel = ref('保存')
const editing = reactive<Record<string, string>>({})
// 即使无 dirty 也允许"保存"例如桌面端修改了内存但平台仅想触发持久化
// 现版本默认 false:保存按钮只在有 dirty 时可点;用户可后续按需放开。
const forceAllowSave = ref(false)
const filteredRows = computed<ProjectPropertyRow[]>(() => {
const q = filterText.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter(
(r) => r.key.toLowerCase().includes(q) || r.label.toLowerCase().includes(q)
)
})
const dirtyCount = computed(() =>
rows.value.reduce((n, f) => (f.locked ? n : editing[f.key] !== f.value ? n + 1 : n), 0)
)
const hasDirty = computed(() => dirtyCount.value > 0)
function isBool(t: string) {
return t === 'Boolean'
}
function isInt(t: string) {
return t === 'Int32' || t === 'Int64' || t === 'Int16' || t === 'Byte'
}
function isFloat(t: string) {
return t === 'Single' || t === 'Double' || t === 'Decimal'
}
function boolOf(s: string): boolean {
const v = (s ?? '').trim().toLowerCase()
return v === 'true' || v === '1' || v === 'yes'
}
function intOf(s: string): number {
const n = parseInt(s, 10)
return Number.isFinite(n) ? n : 0
}
function floatOf(s: string): number {
const n = parseFloat(s)
return Number.isFinite(n) ? n : 0
}
function getEditing(row: ProjectPropertyRow): string {
return editing[row.key] ?? row.value
}
function setEditing(row: ProjectPropertyRow, v: string) {
editing[row.key] = v
}
function isDirty(row: ProjectPropertyRow): boolean {
if (row.locked) return false
const cur = editing[row.key]
return cur !== undefined && cur !== row.value
}
async function refresh() {
loading.value = true
try {
const r = await props.loader()
rows.value = r.fields
pathInfos.value = r.pathInfos ?? []
if (r.saveButtonLabel) saveButtonLabel.value = r.saveButtonLabel
for (const f of r.fields) editing[f.key] = f.value
for (const k of Object.keys(editing)) {
if (!r.fields.find((f) => f.key === k)) delete editing[k]
}
} catch (err) {
ElMessage.error(`加载${props.targetLabel}失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
function resetDirty() {
for (const f of rows.value) editing[f.key] = f.value
}
async function onSave() {
const dirtyFields = rows.value.filter((f) => !f.locked && editing[f.key] !== f.value)
saving.value = true
let okCount = 0
const failed: Array<{ key: string; reason: string }> = []
for (const f of dirtyFields) {
try {
await props.onSet(f.key, editing[f.key] ?? '')
okCount += 1
} catch (err) {
failed.push({ key: f.key, reason: (err as Error).message })
}
}
if (failed.length > 0) {
saving.value = false
try {
await ElMessageBox.confirm(
`${dirtyFields.length} 项改动:成功 ${okCount},失败 ${failed.length}(首项: ${failed[0].key}${failed[0].reason})。\n是否仍要把已成功应用的改动持久化到磁盘?`,
'部分字段写入失败',
{ confirmButtonText: '仍然保存', cancelButtonText: '取消', type: 'warning' }
)
} catch {
ElMessage.warning(`已取消保存(${okCount} 项已写入内存,${failed.length} 项未生效)`)
await refresh()
return
}
saving.value = true
}
try {
const r = await props.onSave()
ElMessage.success(`已保存到:${r.path}(应用 ${okCount} 项改动${failed.length > 0 ? `${failed.length} 项失败已忽略` : ''}`)
await refresh()
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
}
watch(() => props.loader, () => { refresh() })
onMounted(refresh)
defineExpose({ refresh })
</script>
<style scoped>
.fme-root {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
box-sizing: border-box;
}
.fme-header {
display: flex;
flex-direction: column;
gap: 10px;
}
.fme-desc {
margin: 0;
color: var(--el-text-color-primary);
font-size: 13px;
line-height: 1.65;
background: var(--el-fill-color-light);
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
border-radius: 4px;
}
.fme-paths code.fme-path {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
word-break: break-all;
color: var(--el-text-color-primary);
}
.fme-path-empty {
color: var(--el-text-color-secondary);
font-size: 12px;
font-style: italic;
}
.fme-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fme-search {
width: 280px;
}
.fme-btn-text {
margin-left: 4px;
}
.fme-table-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fme-table-card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.fme-table {
flex: 1;
}
.fme-input {
width: 100%;
}
.fme-locked {
color: var(--el-text-color-secondary);
font-style: italic;
}
</style>
@@ -0,0 +1,169 @@
<template>
<div class="ref-kind-table">
<div class="rkt-header">
<el-input
v-model="search"
:placeholder="`筛选 ${kind} ...`"
size="small"
clearable
class="rkt-search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" :loading="loading" @click="refresh">刷新</el-button>
</div>
<el-table
v-loading="loading"
:data="filteredObjects"
stripe
border
size="small"
class="rkt-table"
highlight-current-row
@current-change="onSelect"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="typeName" label="类型" width="180" />
<el-table-column prop="layer" label="图层" width="120" />
<el-table-column prop="summary" label="摘要" min-width="220" show-overflow-tooltip />
</el-table>
<el-card v-if="currentObject" shadow="never" class="rkt-actions">
<template #header>
<span>{{ currentObject.typeName }} #{{ currentObject.id }} · {{ currentObject.name }}</span>
</template>
<div v-if="loadingMethods" class="rkt-loading-methods">加载方法中</div>
<div v-else-if="methods.length === 0" class="rkt-empty">该类型未暴露 MethodMember</div>
<div v-else class="rkt-method-grid">
<el-tooltip
v-for="m in methods"
:key="m.methodName"
:content="m.description ?? m.hint ?? ''"
:disabled="!m.description && !m.hint"
placement="top"
>
<el-button
size="small"
type="primary"
plain
:loading="executing === m.methodName"
@click="runMethod(m)"
>
{{ m.label ?? m.methodName }}
</el-button>
</el-tooltip>
</div>
</el-card>
<el-empty v-else-if="!loading && objects.length === 0" :description="emptyText ?? `当前 ${kind} 无条目`" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { reflectionApi, type ReflectionKind, type ReflectionMethod, type ReflectionObject } from '@/api/reflection'
const props = defineProps<{
kind: ReflectionKind
/** 额外的客户端筛选关键字(用于 taskflow / trigger 这种从 mission 派生的虚拟分类)。 */
extraFilter?: string
emptyText?: string
}>()
const loading = ref(false)
const loadingMethods = ref(false)
const executing = ref<string | null>(null)
const search = ref('')
const objects = ref<ReflectionObject[]>([])
const currentObject = ref<ReflectionObject | null>(null)
const methods = ref<ReflectionMethod[]>([])
const filteredObjects = computed(() => {
let arr = objects.value
if (props.extraFilter) {
const k = props.extraFilter.toLowerCase()
arr = arr.filter((o) => (o.typeName ?? '').toLowerCase().includes(k) || (o.name ?? '').toLowerCase().includes(k))
}
const q = search.value.trim().toLowerCase()
if (q) {
arr = arr.filter((o) =>
(o.name ?? '').toLowerCase().includes(q) ||
(o.typeName ?? '').toLowerCase().includes(q) ||
String(o.id).includes(q)
)
}
return arr
})
async function refresh() {
loading.value = true
try {
objects.value = await reflectionApi.listObjects(props.kind)
} catch (err) {
ElMessage.error(`加载 ${props.kind} 失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
async function onSelect(row: ReflectionObject | null) {
currentObject.value = row
methods.value = []
if (!row) return
loadingMethods.value = true
try {
methods.value = await reflectionApi.listMethods(props.kind, row.id)
} catch (err) {
ElMessage.error(`加载方法失败:${(err as Error).message}`)
} finally {
loadingMethods.value = false
}
}
async function runMethod(m: ReflectionMethod) {
if (!currentObject.value) return
if (m.hasParams) {
ElMessage.info(`方法 ${m.methodName} 需要参数,请在「设计与编排」详细面板里执行(待开发)`)
return
}
executing.value = m.methodName
try {
const r = await reflectionApi.execute(props.kind, currentObject.value.id, m.methodName)
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `${r.returnValue}` : ''}`)
} catch (err) {
ElMessage.error(`执行失败:${(err as Error).message}`)
} finally {
executing.value = null
}
}
watch(() => props.kind, refresh)
onMounted(refresh)
</script>
<style scoped>
.ref-kind-table {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.rkt-header {
display: flex;
align-items: center;
gap: 8px;
}
.rkt-search { flex: 0 0 320px; }
.rkt-table { flex: 1; min-height: 200px; }
.rkt-actions { margin-top: 8px; }
.rkt-method-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.rkt-loading-methods, .rkt-empty {
color: rgba(232,215,245,0.7);
font-size: 12px;
padding: 8px 4px;
}
</style>
@@ -0,0 +1,172 @@
<template>
<el-card v-if="visible" shadow="never" class="plugin-list-card">
<template #header>
<div class="plg-header">
<span class="plg-title">已加载插件 ({{ plugins.length }})</span>
<el-button text size="small" :icon="ArrowDown" class="plg-collapse" @click="collapsed = !collapsed">
{{ collapsed ? '展开' : '折叠' }}
</el-button>
<el-button size="small" :loading="loading" :icon="Refresh" @click="refresh">刷新</el-button>
</div>
</template>
<div v-if="!collapsed">
<el-empty
v-if="!loading && plugins.length === 0"
description="尚未通过 PluginManager 加载任何插件 (重启 SimpleLite 后会自动迁移现有 plugins/ 内的 dll)"
/>
<el-table
v-else
v-loading="loading"
:data="plugins"
size="small"
stripe
border
class="plg-table"
>
<el-table-column prop="name" label="插件名" width="200" show-overflow-tooltip />
<el-table-column prop="assemblyVersion" label="版本" width="120" />
<el-table-column label="类型贡献" width="200">
<template #default="{ row }">
<el-tag size="small" type="success" effect="plain" class="plg-tag">
进程 {{ row.missionTypes }}
</el-tag>
<el-tag size="small" type="primary" effect="plain" class="plg-tag">
/脚本 {{ row.carTypes }}
</el-tag>
<el-tag size="small" type="info" effect="plain" class="plg-tag">
{{ row.loadedTypes }} type
</el-tag>
</template>
</el-table-column>
<el-table-column prop="dllPath" label="路径" min-width="240" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.collectible" size="small" type="success">可卸载</el-tag>
<el-tooltip
v-else
content="此插件被加到 default ALC(旧路径加载),重启 SimpleLite 后会自动迁移到 collectible"
placement="top"
>
<el-tag size="small" type="warning">不可卸载</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
text
size="small"
type="danger"
:disabled="!row.collectible"
:loading="unloading === row.name"
@click="onUnload(row as PluginEntry)"
>
卸载
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
<script setup lang="ts">
/**
* 插件管理面板(仅展示 + 卸载入口;增量加载走父组件的 「刷新插件」 按钮)。
*
* - 列出 PluginManager 跟踪的所有插件,标注 collectible / 不可卸载;
* - 「卸载」按钮:调用 POST /plugins/{name}/unload;后端会先检查没有 Mission/Car 实例
* 仍在用插件类型,否则 409 失败;卸载完毕后会重建 UiDiscoveryCache。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, Refresh } from '@element-plus/icons-vue'
import { reflectionApi, type PluginEntry } from '@/api/reflection'
const props = defineProps<{ visible?: boolean }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
const visible = computed(() => props.visible !== false)
const plugins = ref<PluginEntry[]>([])
const loading = ref(false)
const collapsed = ref(true)
const unloading = ref<string | null>(null)
async function refresh() {
loading.value = true
try {
plugins.value = await reflectionApi.listPlugins()
} catch (err) {
ElMessage.warning(`插件列表加载失败:${(err as Error).message}(旧版 SimpleLite 可能未带 /plugins 接口,请重启)`)
} finally {
loading.value = false
}
}
async function onUnload(p: PluginEntry) {
try {
await ElMessageBox.confirm(
`确认卸载插件 ${p.name}\n\n` +
`此插件贡献了 ${p.missionTypes} 个进程类型 / ${p.carTypes} 个车/脚本类型;` +
`如果当前还有这些类型的实例存在,卸载会失败(请先在管理面板里删除)。`,
'卸载插件',
{ type: 'warning', confirmButtonText: '卸载', cancelButtonText: '取消' }
)
} catch {
return
}
unloading.value = p.name
try {
const r = await reflectionApi.unloadPlugin(p.name)
ElMessage.success(r.message ?? `已卸载 ${p.name}`)
emit('changed')
await refresh()
} catch (err) {
ElMessage.error(`卸载失败:${(err as Error).message}`)
} finally {
unloading.value = null
}
}
watch(() => props.visible, (v) => { if (v) refresh() })
onMounted(() => { if (visible.value) refresh() })
defineExpose({ refresh })
</script>
<style scoped>
.plugin-list-card {
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
margin-bottom: 12px;
}
.plg-header {
display: flex;
align-items: center;
gap: 8px;
}
.plg-title {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
}
.plg-collapse { margin-left: auto; }
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
.plg-table {
background: transparent;
}
:deep(.el-table) { background: transparent; }
:deep(.el-table tr),
:deep(.el-table th.el-table__cell),
:deep(.el-table td.el-table__cell) {
background: transparent !important;
color: rgba(232, 215, 245, 0.92);
border-color: rgba(255, 255, 255, 0.08);
}
:deep(.el-card__header) {
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
</style>
@@ -0,0 +1,753 @@
<template>
<div class="monitor-selection">
<template v-if="!selection">
<div class="empty-hint">
<span class="empty-title">未选中对象</span>
<span class="empty-desc"> 3D 画布或车辆列表中点击以查看信息</span>
</div>
</template>
<div v-else class="pane">
<div v-if="initialLoading" class="pane-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中</span>
</div>
<!-- 站点 -->
<template v-else-if="viewKind === 'site'">
<section class="card card--header">
<div class="title-row">
<span class="id-tag">#{{ objectId }}</span>
<span class="type-tag">站点</span>
</div>
<div class="name-line">{{ siteInfo.name }}</div>
</section>
<section class="card">
<header class="card-hd"><span>位置</span></header>
<div class="kv-grid">
<div class="kv"><span class="k">X</span><span class="v mono">{{ siteInfo.x }}</span></div>
<div class="kv"><span class="k">Y</span><span class="v mono">{{ siteInfo.y }}</span></div>
<div class="kv"><span class="k">Z</span><span class="v mono">{{ siteInfo.z }}</span></div>
</div>
</section>
<section v-if="statusDisplayRows.length" class="card">
<header class="card-hd"><span>状态</span><span class="meta">{{ statusDisplayRows.length }}</span></header>
<div class="kv-list">
<div v-for="row in statusDisplayRows" :key="row.key" class="kv-row">
<span class="k">{{ row.key }}</span>
<span class="v" :title="row.value">{{ row.value }}</span>
</div>
</div>
</section>
<section v-if="siteActions.length" class="card card--actions">
<header class="card-hd"><span>操作</span></header>
<div class="action-grid">
<button
v-for="m in siteActions"
:key="m.methodName"
type="button"
class="action-btn"
:disabled="executing === m.methodName"
@click="onExecute(m)">
{{ m.label || m.methodName }}
</button>
</div>
</section>
</template>
<!-- 车辆 -->
<template v-else-if="viewKind === 'vehicle'">
<section class="card card--header">
<div class="title-row">
<span class="id-tag">{{ carInfo.displayId }}</span>
<el-tag size="small" :type="carStateTagType" effect="dark">{{ carInfo.stateLabel }}</el-tag>
</div>
<div class="name-line">{{ carInfo.name }}</div>
<div v-if="carInfo.typeShort" class="sub-line">{{ carInfo.typeShort }}</div>
</section>
<section v-if="statusDisplayRows.length" class="card">
<header class="card-hd">
<span>状态</span>
<span class="meta">{{ statusDisplayRows.length }}</span>
</header>
<div class="kv-list">
<div
v-for="row in statusDisplayRows"
:key="row.key"
class="kv-row"
:class="{ 'is-warn': row.warn }">
<span class="k">{{ row.key }}</span>
<span class="v" :title="row.value">{{ row.value }}</span>
</div>
</div>
</section>
<section v-if="fieldDisplayRows.length" class="card">
<header class="card-hd">
<span>属性</span>
<span class="meta">{{ fieldDisplayRows.length }}</span>
</header>
<div class="kv-list">
<div v-for="row in fieldDisplayRows" :key="row.key" class="kv-row">
<span class="k">{{ row.key }}</span>
<span class="v" :title="row.value">{{ row.value }}</span>
</div>
</div>
</section>
<section class="card card--actions">
<header class="card-hd">
<span>动作</span>
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
</header>
<div v-if="actionsLoading" class="muted-line">加载动作</div>
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div>
<div v-else class="action-grid action-grid--vehicle">
<button
v-for="m in carActions"
:key="m.methodName"
type="button"
class="action-btn"
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
:disabled="executing === m.methodName"
@click="onExecute(m)">
{{ m.label || m.methodName }}
</button>
</div>
</section>
</template>
<!-- 路径 -->
<template v-else-if="viewKind === 'track'">
<section class="card card--header">
<div class="title-row">
<span class="id-tag">#{{ objectId }}</span>
<span class="type-tag">路径</span>
</div>
<div class="name-line">{{ trackInfo.name }}</div>
</section>
<section v-if="statusDisplayRows.length || trackRows.length" class="card">
<header class="card-hd"><span>信息</span></header>
<div class="kv-list">
<div v-for="row in trackRows" :key="row.key" class="kv-row">
<span class="k">{{ row.label }}</span>
<span class="v">{{ row.value }}</span>
</div>
<div v-for="row in statusDisplayRows" :key="'s-' + row.key" class="kv-row">
<span class="k">{{ row.key }}</span>
<span class="v">{{ row.value }}</span>
</div>
</div>
</section>
<section v-if="trackActions.length" class="card card--actions">
<header class="card-hd"><span>动作</span></header>
<div class="action-grid">
<button
v-for="m in trackActions"
:key="m.methodName"
type="button"
class="action-btn"
:disabled="executing === m.methodName"
@click="onExecute(m)">
{{ m.label || m.methodName }}
</button>
</div>
</section>
</template>
<template v-else>
<div class="muted-line">该类型暂不支持监控详情展示</div>
</template>
</div>
<SitePickDialog
v-model="sitePickOpen"
:title="sitePickTitle"
:sites="sitePickSites"
@confirm="onSitePickConfirm"
@pick-on-map="onSitePickOnMap"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import SitePickDialog from '@/components/workbench/SitePickDialog.vue'
import {
reflectionApi,
type ReflectionKind,
type ReflectionKv,
type ReflectionMethod
} from '@/api/reflection'
import {
executeCarGotoSite,
executeReflectionMethod,
loadSitePickOptions,
methodNeedsSitePick,
pickTargetSiteOnMap,
type SitePickOption
} from '@/utils/carActionExecute'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
const SITE_ACTION_METHODS = new Set(['AllowLock', 'PreventLock'])
const props = defineProps<{
selection: SelectedObjectRef | null
refreshKey?: number
cars?: Car[]
missions?: Mission[]
}>()
const initialLoading = ref(false)
const hydrated = ref(false)
const actionsLoading = ref(false)
const executing = ref<string | null>(null)
const pendingGotoMethod = ref<ReflectionMethod | null>(null)
const bundleName = ref('')
const bundleTypeName = ref('')
const bundleFullTypeName = ref('')
const fieldMap = ref<Record<string, string>>({})
const statusRows = ref<ReflectionKv[]>([])
const allMethods = ref<ReflectionMethod[]>([])
const carActionByType = ref<Record<string, string[]>>({})
const siteActionKeys = ref<string[]>([])
const trackActionKeys = ref<string[]>([])
const monitorConfigLoaded = ref(false)
const sitePickOpen = ref(false)
const sitePickSites = ref<SitePickOption[]>([])
const sitePickTitle = ref('选择目标站点')
const SELECTION_TO_REFLECTION: Record<string, ReflectionKind> = {
car: 'car',
vehicle: 'car',
site: 'site',
track: 'track'
}
const reflectionKind = computed<ReflectionKind | null>(() => {
const k = props.selection?.kind?.toLowerCase()
return k ? SELECTION_TO_REFLECTION[k] ?? null : null
})
const viewKind = computed(() => {
const k = props.selection?.kind?.toLowerCase()
if (k === 'site') return 'site'
if (k === 'vehicle' || k === 'car') return 'vehicle'
if (k === 'track') return 'track'
return 'other'
})
const objectId = computed(() => {
const n = Number(props.selection?.id)
return Number.isFinite(n) ? n : props.selection?.id ?? '-'
})
function fmtNum(raw: string | undefined, digits = 1): string {
if (raw == null || raw === '') return '—'
const n = Number(raw)
return Number.isFinite(n) ? n.toFixed(digits) : raw
}
function pickField(...keys: string[]): string {
for (const k of keys) {
const v = fieldMap.value[k]
if (v != null && v !== '') return v
}
return ''
}
function pickStatus(...candidates: string[]): string {
for (const c of candidates) {
const hit = statusRows.value.find((r) => r.key === c)
if (hit?.value) return hit.value
}
for (const c of candidates) {
const hit = statusRows.value.find((r) => r.key.includes(c))
if (hit?.value) return hit.value
}
return ''
}
function isSiteDisabled(): boolean {
const tags = pickStatus('标记', 'tags')
if (!tags) return false
return /unavailable/i.test(tags)
}
const siteInfo = computed(() => ({
name: pickField('name') || bundleName.value || props.selection?.name || `站点 ${objectId.value}`,
x: fmtNum(pickField('x'), 1),
y: fmtNum(pickField('y'), 1),
z: fmtNum(pickField('z'), 1),
disabled: isSiteDisabled()
}))
const siteActions = computed(() => {
const allow = siteActionKeys.value
if (allow.length) {
const set = new Set(allow)
return allMethods.value.filter((m) => set.has(m.methodName))
}
return allMethods.value.filter((m) => SITE_ACTION_METHODS.has(m.methodName))
})
const trackActions = computed(() => {
const allow = trackActionKeys.value
if (!allow.length) return allMethods.value
const set = new Set(allow)
return allMethods.value.filter((m) => set.has(m.methodName))
})
function findCarInList(): Car | undefined {
if (!props.selection || viewKind.value !== 'vehicle') return undefined
const sid = String(props.selection.id)
return props.cars?.find(
(c) => c.id === sid || String(c.rawId ?? '') === sid || detailIdForCar(c) === sid
)
}
function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId)
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
}
function missionLabelForCar(car: Car | undefined): string {
if (!car?.missionId) return '无'
const m = props.missions?.find((x) => x.id === car.missionId)
if (!m) return car.missionId
const statusMap: Record<string, string> = {
running: '运行中',
queued: '排队',
completed: '已完成',
failed: '失败',
cancelled: '已取消'
}
return `${m.name || m.id}${statusMap[m.status] ?? m.status}`
}
const carInfo = computed(() => {
const car = findCarInList()
const stateLabels: Record<string, string> = {
idle: '空闲',
running: '运行',
charging: '充电',
paused: '暂停',
fault: '故障',
offline: '离线'
}
const state = car?.state ?? 'offline'
return {
displayId: car?.id ?? props.selection?.id ?? '-',
name: bundleName.value || car?.name || props.selection?.name || '车辆',
typeShort: shortType(bundleTypeName.value || car?.typeName),
stateLabel: stateLabels[state] ?? state,
state
}
})
const carStateTagType = computed((): 'success' | 'warning' | 'danger' | 'info' => {
switch (carInfo.value.state) {
case 'running': return 'success'
case 'charging': return 'warning'
case 'fault': return 'danger'
default: return 'info'
}
})
/** 状态区:展示 bundle 返回的全部 status 键值,不再折叠为少数几项。 */
const statusDisplayRows = computed(() => {
const rows = statusRows.value.map((r) => ({
key: r.key,
value: r.value || '—',
warn: /alarm|error|fault|block|stop/i.test(`${r.key} ${r.value}`) && r.value !== '0' && r.value !== '无'
}))
if (viewKind.value === 'vehicle' && !rows.length) {
const car = findCarInList()
const mission = missionLabelForCar(car)
const batteryRaw = pickField('batterySoc', 'battery', '电量')
const batteryPct = batteryRaw
? `${Math.round(Number(batteryRaw) <= 1 ? Number(batteryRaw) * 100 : Number(batteryRaw))}%`
: car != null
? `${Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)}%`
: '—'
return [
{ key: '位姿', value: pickStatus('位姿', '站点') || '—', warn: false },
{ key: '任务', value: mission, warn: false },
{ key: '电量', value: batteryPct, warn: Number.parseFloat(batteryPct) < 20 }
]
}
return rows
})
const FIELD_SKIP = new Set(['name', 'x', 'y', 'z', 'th', 'theta', 'layerName', 'layer'])
const fieldDisplayRows = computed(() => {
if (viewKind.value !== 'vehicle') return []
const entries = Object.entries(fieldMap.value)
.filter(([k, v]) => v != null && v !== '' && !FIELD_SKIP.has(k))
.map(([key, value]) => ({ key, value }))
return entries.sort((a, b) => a.key.localeCompare(b.key))
})
function resolveCarActionWhitelist(): string[] {
const map = carActionByType.value
const full = bundleFullTypeName.value
const short = bundleTypeName.value
if (full && map[full]?.length) return map[full]!
if (short && map[short]?.length) return map[short]!
for (const [k, v] of Object.entries(map)) {
if (!v.length) continue
if (full && (k === full || k.endsWith('.' + short) || full.endsWith('.' + k))) return v
if (short && (k === short || k.endsWith('.' + short))) return v
}
return []
}
const carActionWhitelist = computed(() => resolveCarActionWhitelist())
const carActions = computed(() => {
const allow = carActionWhitelist.value
if (!allow.length) return []
const set = new Set(allow)
return allMethods.value.filter((m) => set.has(m.methodName))
})
const carActionHint = computed(() => {
if (!monitorConfigLoaded.value) return '加载配置中…'
if (!Object.keys(carActionByType.value).length) {
return '未配置动作。请在「配置中心 → 运营维护」按车型勾选动作并保存。'
}
if (!carActionWhitelist.value.length) {
return '当前车型未配置动作,或方法名与运营维护中勾选的不一致。'
}
return '当前车型无可执行动作。'
})
const trackInfo = computed(() => ({
name: pickField('name') || bundleName.value || props.selection?.name || `路径 ${objectId.value}`
}))
const trackRows = computed(() => [
{ key: 'layer', label: '图层', value: pickField('layerName', 'layer') || '—' },
{ key: 'type', label: '类型', value: bundleTypeName.value || '—' }
])
function shortType(typeName?: string): string {
if (!typeName) return ''
const parts = typeName.split('.')
return parts[parts.length - 1] ?? typeName
}
function selectionKey(sel: SelectedObjectRef | null): string {
if (!sel) return ''
return `${sel.kind}:${sel.id}`
}
async function loadMonitorRuntimeConfig(force = false) {
if (monitorConfigLoaded.value && !force) return
try {
const mc = await reflectionApi.getMonitorConfig()
carActionByType.value = { ...(mc.config.carActionByType ?? {}) }
siteActionKeys.value = [...(mc.config.site?.methods ?? [])]
trackActionKeys.value = [...(mc.config.track?.methods ?? [])]
} catch {
carActionByType.value = {}
siteActionKeys.value = []
trackActionKeys.value = []
} finally {
monitorConfigLoaded.value = true
}
}
async function loadAll() {
if (!props.selection) {
bundleName.value = ''
fieldMap.value = {}
statusRows.value = []
allMethods.value = []
hydrated.value = false
return
}
const rk = reflectionKind.value
const idNum = Number(props.selection.id)
if (!rk || !Number.isFinite(idNum)) return
const soft = hydrated.value
if (!soft) initialLoading.value = true
else actionsLoading.value = true
try {
await loadMonitorRuntimeConfig(!soft)
const [bundle, methods] = await Promise.all([
reflectionApi.getBundle(rk, idNum),
reflectionApi.listMethods(rk, idNum)
])
bundleName.value = bundle.summary?.name ?? props.selection.name ?? ''
bundleTypeName.value = bundle.typeName ?? ''
bundleFullTypeName.value =
('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName ?? ''
fieldMap.value = { ...bundle.fields }
for (const f of bundle.fieldList ?? []) {
fieldMap.value[f.key] = f.value
}
statusRows.value = bundle.status ?? []
allMethods.value = methods
hydrated.value = true
} catch (e) {
ElMessage.error(`加载选中信息失败:${(e as Error).message}`)
} finally {
initialLoading.value = false
actionsLoading.value = false
}
}
async function onExecute(m: ReflectionMethod) {
const rk = reflectionKind.value
if (!rk || !props.selection || !m.methodName) return
const idNum = Number(props.selection.id)
if (!Number.isFinite(idNum)) return
if (rk === 'car' && methodNeedsSitePick(m)) {
pendingGotoMethod.value = m
sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}`
try {
sitePickSites.value = await loadSitePickOptions()
} catch (e) {
ElMessage.error(`加载站点列表失败:${(e as Error).message}`)
return
}
sitePickOpen.value = true
return
}
executing.value = m.methodName
try {
const ok = await executeReflectionMethod(rk, idNum, m)
if (ok) await loadAll()
} finally {
executing.value = null
}
}
async function onSitePickConfirm(siteId: number) {
const idNum = Number(props.selection?.id)
if (!Number.isFinite(idNum)) return
executing.value = pendingGotoMethod.value?.methodName ?? 'goto'
try {
const ok = await executeCarGotoSite(idNum, siteId)
if (ok) await loadAll()
} finally {
executing.value = null
pendingGotoMethod.value = null
}
}
async function onSitePickOnMap() {
const siteId = await pickTargetSiteOnMap()
sitePickOpen.value = false
if (siteId != null) await onSitePickConfirm(siteId)
}
let loadToken = 0
watch(
() => [selectionKey(props.selection), props.refreshKey] as const,
() => {
const token = ++loadToken
void (async () => {
await loadAll()
if (token !== loadToken) return
})()
},
{ immediate: true }
)
</script>
<style scoped>
.monitor-selection {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.pane {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 2px;
}
.pane-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
}
.empty-hint {
display: flex;
flex-direction: column;
gap: 4px;
padding: 24px 8px;
text-align: center;
}
.empty-title { font-size: 13px; color: rgba(255, 255, 255, 0.9); font-weight: 500; }
.empty-desc { font-size: 11.5px; color: rgba(255, 255, 255, 0.65); }
.card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 10px;
flex: none;
}
.card--header {
background: linear-gradient(140deg, rgba(170, 110, 250, 0.2) 0%, rgba(70, 30, 110, 0.08) 100%);
border-color: rgba(170, 110, 250, 0.28);
}
.card--actions {
background: rgba(140, 80, 200, 0.08);
border-color: rgba(170, 110, 250, 0.22);
}
.card-hd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.8px;
color: rgba(220, 180, 250, 0.88);
text-transform: uppercase;
}
.card-hd .meta {
font-size: 10px;
font-weight: 400;
color: rgba(200, 180, 220, 0.55);
text-transform: none;
letter-spacing: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.id-tag {
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 4px;
background: rgba(170, 110, 250, 0.35);
color: #fff;
}
.type-tag {
font-size: 11px;
color: rgba(232, 215, 245, 0.75);
}
.name-line {
font-size: 14px;
font-weight: 600;
color: #fff;
line-height: 1.3;
word-break: break-all;
}
.sub-line {
margin-top: 2px;
font-size: 11px;
color: rgba(200, 180, 230, 0.7);
font-family: ui-monospace, Consolas, monospace;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.kv-list { display: flex; flex-direction: column; gap: 2px; }
.kv-row {
display: grid;
grid-template-columns: minmax(72px, 38%) 1fr;
gap: 8px;
align-items: start;
font-size: 12px;
line-height: 1.35;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.kv-row:last-child { border-bottom: none; }
.kv-row.is-warn .v { color: #ffb4b4; }
.kv {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.k, .kv-row .k {
color: rgba(200, 180, 230, 0.75);
font-size: 11px;
word-break: break-word;
}
.v, .kv-row .v {
color: #fff;
word-break: break-word;
font-size: 12px;
}
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; }
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.action-grid--vehicle {
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
}
.action-btn {
appearance: none;
cursor: pointer;
border: 1px solid rgba(167, 139, 250, 0.55);
background: rgba(76, 29, 149, 0.45);
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 7px 8px;
border-radius: 6px;
transition: background 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease;
text-align: center;
line-height: 1.25;
}
.action-btn--goto {
border-color: rgba(250, 200, 120, 0.65);
background: rgba(120, 70, 20, 0.45);
}
.action-btn:hover:not(:disabled) {
background: rgba(109, 40, 217, 0.75);
border-color: rgba(196, 181, 253, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.action-btn--goto:hover:not(:disabled) {
background: rgba(160, 100, 30, 0.75);
border-color: rgba(255, 220, 150, 0.9);
}
.action-btn:disabled { opacity: 0.55; cursor: progress; }
.muted-line {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.65);
line-height: 1.4;
padding: 2px 0;
}
</style>
@@ -0,0 +1,463 @@
<template>
<div class="selection-detail">
<template v-if="!selection">
<div class="empty muted">未选择对象在左侧列表或 3D 视图中选中后此处显示属性 / 状态 / 动作</div>
</template>
<template v-else>
<div class="head">
<span class="name">{{ detail?.name ?? selection.name ?? selection.id }}</span>
<el-tag size="small" type="info">{{ detail?.typeName ?? selection.kind }}</el-tag>
<el-tooltip v-if="!reflectionKind" content="该对象类型未接入反射可见性过滤,所有内容按默认展示" placement="top">
<el-tag size="small" type="warning" effect="plain">未过滤</el-tag>
</el-tooltip>
</div>
<el-tabs v-model="activeTab" class="detail-tabs" @tab-change="loadDetail">
<el-tab-pane label="属性" name="properties" />
<el-tab-pane label="状态" name="status" />
<el-tab-pane label="动作" name="action" />
</el-tabs>
<div v-loading="loading" class="detail-body">
<template v-if="activeTab === 'properties'">
<el-descriptions v-if="filteredSummary.length" :column="1" size="small" border title="概要">
<el-descriptions-item v-for="f in filteredSummary" :key="f.key" :label="f.key">
{{ f.value }}
</el-descriptions-item>
</el-descriptions>
<div v-if="filteredMemberFields.length" class="section">
<div class="section-title">成员字段</div>
<el-table :data="filteredMemberFields" size="small" stripe>
<el-table-column prop="key" label="字段" width="100" />
<el-table-column prop="value" label="值" show-overflow-tooltip />
<el-table-column label="" width="48">
<template #default="{ row }">
<el-tag v-if="row.locked" size="small" type="info">锁定</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="filteredCustomFields.length" class="section">
<div class="section-title">自定义字段</div>
<el-table :data="filteredCustomFields" size="small" stripe>
<el-table-column prop="key" label="字段" width="100" />
<el-table-column prop="value" label="值" show-overflow-tooltip />
</el-table>
</div>
<div
v-if="!filteredSummary.length && !filteredMemberFields.length && !filteredCustomFields.length && fieldWhitelistActive"
class="muted empty">
当前白名单未匹配到任何字段可在配置中心 运营维护优先地图监控配置回退放宽白名单
</div>
</template>
<template v-else-if="activeTab === 'status'">
<el-descriptions v-if="filteredStatusFields.length" :column="1" size="small" border>
<el-descriptions-item
v-for="f in filteredStatusFields"
:key="f.key"
:label="f.key">
{{ f.value }}
</el-descriptions-item>
</el-descriptions>
<div v-else-if="statusWhitelistActive" class="muted empty">
当前白名单未匹配到任何状态项可在配置中心 运营维护优先地图监控配置回退放宽白名单
</div>
<div v-else class="muted empty">无状态信息</div>
</template>
<template v-else>
<div v-if="actionsLoading" class="muted empty">加载动作列表中</div>
<div v-else-if="!filteredActions.length" class="muted empty">
{{ methodWhitelistActive
? '当前白名单未匹配到任何动作可在配置中心 运营维护优先地图监控配置回退放宽白名单'
: '当前对象无可调用动作MethodMember' }}
</div>
<div v-else class="action-list">
<el-button
v-for="m in filteredActions"
:key="m.methodName"
size="small"
type="primary"
plain
class="action-btn"
:loading="executing === m.methodName"
@click="onExecute(m)">
{{ m.label || m.methodName }}
<el-tooltip v-if="m.description" :content="m.description" placement="top">
<el-icon class="hint-icon"><InfoFilled /></el-icon>
</el-tooltip>
</el-button>
</div>
</template>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue'
import { getSelectionDetail } from '@/api/workbench'
import { getConfig } from '@/api/config'
import {
reflectionApi,
type ReflectionKv,
type ReflectionKind,
type ReflectionMethod
} from '@/api/reflection'
import type { MonitorPanelPolicy, OpsConfig } from '@/types/config'
import type { DetailTab, SelectedObjectRef, SelectionDetail, DetailField } from '@/types/workbench'
const props = defineProps<{
selection: SelectedObjectRef | null
refreshKey?: number
}>()
const activeTab = ref<DetailTab>('properties')
const detail = ref<SelectionDetail | null>(null)
const loading = ref(false)
// 反射方法(动作 Tab 用) —— 与 workbench detail 互补:detail.actions 只有 label 没有
// methodName / params,无法真的调 execute;统一切到 reflection.listMethods 路径,可拿到完整签名。
const actions = ref<ReflectionMethod[]>([])
const actionsLoading = ref(false)
const executing = ref<string | null>(null)
const currentCarTypeKey = ref('')
const opsMonitor = ref<Record<'car' | 'site' | 'track', MonitorPanelPolicy> | null>(null)
const opsCarActionByType = ref<Record<string, string[]>>({})
const opsMonitorAt = ref<number>(0)
const OPS_MONITOR_TTL_MS = 30_000
// kind 映射:selection.kind 在前端用 'vehicle' / 'site' / 'track' / 'car' 等,统一映成
// reflection 后端 kind'car' / 'site' / 'track')。返回 null 表示该 kind 不在反射 / 监控
// 配置覆盖范围(例如 'image' / 'text' / 'model'),过滤逻辑会自动跳过白名单。
const SELECTION_KIND_TO_REFLECTION: Record<string, ReflectionKind> = {
car: 'car',
vehicle: 'car',
site: 'site',
track: 'track'
}
const reflectionKind = computed<ReflectionKind | null>(() => {
const k = props.selection?.kind?.toLowerCase()
return k ? SELECTION_KIND_TO_REFLECTION[k] ?? null : null
})
const opsPolicy = computed<MonitorPanelPolicy | null>(() => {
const rk = reflectionKind.value
if (!rk || !opsMonitor.value) return null
if (rk === 'car' || rk === 'site' || rk === 'track') {
return opsMonitor.value[rk]
}
return null
})
const activeFieldWhitelist = computed<string[]>(() => {
return []
})
const activeStatusWhitelist = computed<string[]>(() => {
return []
})
const activeMethodWhitelist = computed(() => {
if (reflectionKind.value === 'car') {
const exact = currentCarTypeKey.value ? opsCarActionByType.value[currentCarTypeKey.value] ?? [] : []
if (exact.length > 0) return exact
const short = detail.value?.typeName ?? ''
const fallback = short ? opsCarActionByType.value[short] ?? [] : []
if (fallback.length > 0) return fallback
return []
}
return opsPolicy.value?.actionKeys ?? []
})
const fieldWhitelistActive = computed(() => activeFieldWhitelist.value.length > 0)
const statusWhitelistActive = computed(() => activeStatusWhitelist.value.length > 0)
const methodWhitelistActive = computed(() => activeMethodWhitelist.value.length > 0)
function applyFieldFilter<T extends DetailField>(rows: T[] | undefined): T[] {
if (!rows?.length) return []
if (!fieldWhitelistActive.value) return rows
const allow = new Set(activeFieldWhitelist.value)
return rows.filter((r) => allow.has(r.key))
}
const filteredSummary = computed(() => applyFieldFilter(detail.value?.summary))
const filteredMemberFields = computed(() => applyFieldFilter(detail.value?.memberFields))
const filteredCustomFields = computed(() => applyFieldFilter(detail.value?.customFields))
const filteredStatusFields = computed(() => {
if (!detail.value) return [] as DetailField[]
// status Tab 原逻辑:summary + statusLines 合并展示。保留此语义,但分别按白名单过滤。
const combined: DetailField[] = [
...(detail.value.summary ?? []),
...(detail.value.statusLines ?? [])
]
if (!statusWhitelistActive.value) return combined
const allow = new Set(activeStatusWhitelist.value)
return combined.filter((r) => allow.has(r.key))
})
const filteredActions = computed(() => {
if (!actions.value.length) return []
if (!methodWhitelistActive.value) return actions.value
const allow = new Set(activeMethodWhitelist.value)
return actions.value.filter((m) => allow.has(m.methodName))
})
async function ensureOpsMonitorConfig() {
const now = Date.now()
if (opsMonitor.value && now - opsMonitorAt.value < OPS_MONITOR_TTL_MS) return
try {
const env = await getConfig<OpsConfig>('ops')
const monitor = env.payload?.monitor
opsMonitor.value = {
car: monitor?.car ?? { propertyKeys: [], statusKeys: [], actionKeys: [] },
site: monitor?.site ?? { propertyKeys: [], statusKeys: [], actionKeys: [] },
track: monitor?.track ?? { propertyKeys: [], statusKeys: [], actionKeys: [] }
}
opsCarActionByType.value = monitor?.carActionByType ?? {}
opsMonitorAt.value = now
} catch {
if (!opsMonitor.value) {
opsMonitor.value = {
car: { propertyKeys: [], statusKeys: [], actionKeys: [] },
site: { propertyKeys: [], statusKeys: [], actionKeys: [] },
track: { propertyKeys: [], statusKeys: [], actionKeys: [] }
}
opsCarActionByType.value = {}
}
}
}
async function loadDetail() {
if (!props.selection) {
detail.value = null
return
}
const rk = reflectionKind.value
const idNum = Number(props.selection.id)
loading.value = true
try {
if (rk && Number.isFinite(idNum)) {
if (activeTab.value === 'properties') {
const bundle = await reflectionApi.getBundle(rk, idNum)
if (rk === 'car') {
currentCarTypeKey.value = ('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName
}
const list = bundle.fieldList ?? []
const typed = list.filter((x) => x.source !== 'dynamic')
const dynamic = list.filter((x) => x.source === 'dynamic')
detail.value = {
objectKind: rk,
objectId: String(idNum),
name: bundle.summary?.name ?? props.selection.name ?? String(idNum),
typeName: bundle.typeName ?? bundle.summary?.typeName ?? rk,
layer: bundle.summary?.layer ?? undefined,
tab: activeTab.value,
summary: toDetailFields([
{ key: 'ID', value: String(bundle.id) },
{ key: '类型', value: bundle.typeName ?? rk },
...(bundle.summary?.layer ? [{ key: '图层', value: bundle.summary.layer }] : [])
]),
memberFields: toDetailFields(typed),
customFields: toDetailFields(dynamic),
statusLines: [],
actions: []
}
return
}
if (activeTab.value === 'status') {
const rows = await reflectionApi.getStatus(rk, idNum)
detail.value = {
objectKind: rk,
objectId: String(idNum),
name: detail.value?.name ?? props.selection.name ?? String(idNum),
typeName: detail.value?.typeName ?? rk,
layer: detail.value?.layer,
tab: activeTab.value,
summary: detail.value?.summary ?? [],
memberFields: [],
customFields: [],
statusLines: toDetailFields(rows),
actions: []
}
return
}
}
detail.value = await getSelectionDetail(
props.selection.kind,
props.selection.id,
activeTab.value
)
} catch (e) {
detail.value = null
ElMessage.error(`加载选中信息失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
async function loadActions() {
// 只在 reflection 能覆盖的 kind 才拉;其他 kindimage / text / model)保留旧行为:
// 走 workbench.detail.actions 兜底(label only)—— 通过把 ReflectionMethod 的 methodName
// 设为空、按钮点击给 ElMessage.info 提示。
if (!props.selection) {
actions.value = []
return
}
const rk = reflectionKind.value
if (!rk) {
actions.value = (detail.value?.actions ?? []).map((a) => ({
methodName: '',
label: a.label,
description: null,
hint: null,
returnType: 'void',
hasParams: false,
params: []
}))
return
}
const idNum = Number(props.selection.id)
if (!Number.isFinite(idNum)) {
actions.value = []
return
}
actionsLoading.value = true
try {
// 先拉一次 bundle,确保 detail.typeName 对车种动作过滤可用(按车型白名单)。
if (rk === 'car' && (!detail.value?.typeName || detail.value.typeName === 'car')) {
try {
const bundle = await reflectionApi.getBundle(rk, idNum)
currentCarTypeKey.value = ('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName
detail.value = {
objectKind: rk,
objectId: String(idNum),
name: detail.value?.name ?? bundle.summary?.name ?? props.selection.name ?? String(idNum),
typeName: bundle.typeName ?? detail.value?.typeName ?? 'car',
layer: detail.value?.layer ?? bundle.summary?.layer ?? undefined,
tab: detail.value?.tab ?? activeTab.value,
summary: detail.value?.summary ?? [],
memberFields: detail.value?.memberFields ?? [],
customFields: detail.value?.customFields ?? [],
statusLines: detail.value?.statusLines ?? [],
actions: detail.value?.actions ?? []
}
} catch {
// 不阻断动作拉取主流程
}
}
actions.value = await reflectionApi.listMethods(rk, idNum)
} catch (e) {
actions.value = []
ElMessage.warning(`加载动作列表失败:${(e as Error).message}`)
} finally {
actionsLoading.value = false
}
}
async function onExecute(m: ReflectionMethod) {
// 无 methodName = workbench 兜底 action,保留旧的"提示"语义,避免误导用户以为已执行。
if (!m.methodName) {
ElMessage.info(`动作「${m.label || m.methodName}」需在 SimpleLite 工作台执行(当前对象类型未接入反射 execute)。`)
return
}
const rk = reflectionKind.value
if (!rk || !props.selection) return
const idNum = Number(props.selection.id)
if (!Number.isFinite(idNum)) {
ElMessage.warning('选中对象 id 非数字,无法 execute')
return
}
// 收集参数:单参数 / 多参数都串行 prompt。多参数 UI 比较糙但不需要新组件;后续若用得
// 重再升级到 el-dialog 表单。中途取消任意一个 → 整体取消,不执行。
const params: Record<string, string> = {}
for (const p of m.params) {
try {
const promptDefault = p.hasDefault && p.defaultValue != null ? p.defaultValue : ''
const { value } = await ElMessageBox.prompt(
`参数:${p.name}${p.typeName}${p.hasDefault ? `,缺省 ${p.defaultValue ?? '""'}` : ''}`,
`执行 ${m.label || m.methodName}`,
{
inputValue: promptDefault,
confirmButtonText: '下一步',
cancelButtonText: '取消',
inputPlaceholder: m.hint ?? `请输入 ${p.name}`
}
)
params[p.name] = value ?? ''
} catch {
// ElMessageBox 取消会抛 'cancel';静默退出整个 execute 流程。
return
}
}
executing.value = m.methodName
try {
const r = await reflectionApi.execute(rk, idNum, m.methodName, params)
ElMessage.success(
r.returnValue ? `执行成功:${r.returnValue}` : `已执行 ${m.label || m.methodName}`
)
} catch (e) {
ElMessage.error(`执行失败:${(e as Error).message}`)
} finally {
executing.value = null
}
}
async function refreshForSelection() {
activeTab.value = 'properties'
await ensureOpsMonitorConfig()
// detail + actions 并行拉,提速 ~RTT/2。actions 不影响 properties Tab 渲染;先回的先到位。
await Promise.all([loadDetail(), loadActions()])
}
watch(() => props.selection, () => { void refreshForSelection() }, { deep: true })
watch(() => props.refreshKey, () => { void refreshForSelection() })
watch(activeTab, () => { void loadDetail() })
onMounted(() => { void refreshForSelection() })
function toDetailFields(rows: Array<ReflectionKv | { key: string; value: string }>): DetailField[] {
return rows.map((r) => ({ key: r.key, value: r.value, locked: 'locked' in r ? r.locked : false }))
}
</script>
<style scoped>
.selection-detail { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.name { font-weight: 600; font-size: 13px; color: #fff; }
.detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.detail-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
.detail-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
.detail-body { flex: 1; min-height: 120px; overflow: auto; color: #fff; }
.detail-body :deep(.el-descriptions__label) { color: rgba(255, 255, 255, 0.88) !important; }
.detail-body :deep(.el-descriptions__content),
.detail-body :deep(.el-table .cell) {
color: #fff !important;
}
.section { margin-top: 10px; }
.section-title { font-size: 12px; color: #fff; margin-bottom: 6px; }
.action-list { display: flex; flex-direction: column; gap: 6px; }
.action-btn {
justify-content: flex-start;
border-color: rgba(167, 139, 250, 0.65);
background: rgba(76, 29, 149, 0.45);
color: #fff !important;
}
.action-btn:hover {
border-color: rgba(196, 181, 253, 0.95);
background: rgba(109, 40, 217, 0.75);
}
.action-btn :deep(span) {
color: #fff !important;
font-weight: 600;
}
.action-btn .hint-icon { margin-left: 6px; font-size: 13px; opacity: 0.8; }
.empty { font-size: 12px; padding: 8px 0; color: #fff; }
.muted { color: rgba(255, 255, 255, 0.78); }
</style>
@@ -0,0 +1,127 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="420px"
destroy-on-close
append-to-body
class="site-pick-dialog"
@closed="onClosed">
<p class="hint">请选择车辆要前往的目标站点</p>
<el-input
v-model="filter"
clearable
placeholder="搜索站点 ID / 名称"
class="filter-input"
/>
<el-scrollbar max-height="280px" class="site-scroll">
<button
v-for="s in filteredSites"
:key="s.id"
type="button"
class="site-row"
:class="{ active: selectedId === s.id }"
@click="selectedId = s.id">
<span class="sid">#{{ s.id }}</span>
<span class="sname">{{ s.name || '未命名' }}</span>
</button>
<div v-if="!filteredSites.length" class="empty">无匹配站点</div>
</el-scrollbar>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button :loading="mapPicking" @click="emitPickOnMap">在地图上拾取</el-button>
<el-button type="primary" :disabled="selectedId == null" @click="confirm">确认前往</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { SitePickOption } from '@/utils/carActionExecute'
const props = defineProps<{
modelValue: boolean
title?: string
sites: SitePickOption[]
}>()
const emit = defineEmits<{
'update:modelValue': [v: boolean]
confirm: [siteId: number]
pickOnMap: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const filter = ref('')
const selectedId = ref<number | null>(null)
const mapPicking = ref(false)
const filteredSites = computed(() => {
const q = filter.value.trim().toLowerCase()
if (!q) return props.sites
return props.sites.filter(
(s) =>
String(s.id).includes(q) ||
s.name.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q)
)
})
watch(
() => props.modelValue,
(open) => {
if (open) {
filter.value = ''
selectedId.value = props.sites[0]?.id ?? null
}
}
)
function confirm() {
if (selectedId.value == null) return
emit('confirm', selectedId.value)
visible.value = false
}
function emitPickOnMap() {
mapPicking.value = true
emit('pickOnMap')
}
function onClosed() {
mapPicking.value = false
}
defineExpose({ close: () => { visible.value = false }, endMapPick: () => { mapPicking.value = false } })
</script>
<style scoped>
.hint { margin: 0 0 10px; font-size: 13px; color: rgba(255, 255, 255, 0.78); }
.filter-input { margin-bottom: 8px; }
.site-scroll { border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; }
.site-row {
display: flex;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: transparent;
color: #fff;
text-align: left;
cursor: pointer;
transition: background 0.12s ease;
}
.site-row:hover { background: rgba(167, 139, 250, 0.15); }
.site-row.active {
background: rgba(109, 40, 217, 0.45);
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95);
}
.sid { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: rgba(220, 180, 250, 0.9); flex: none; }
.sname { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.55); font-size: 12px; }
</style>
@@ -0,0 +1,490 @@
<template>
<div class="vehicle-monitor">
<div class="overview-row">
<div class="overview-item">
<div class="overview-num">{{ cars.length }}</div>
<div class="overview-label">总数</div>
</div>
<div class="overview-item">
<div class="overview-num online">{{ onlineCount }}</div>
<div class="overview-label">在线</div>
</div>
<div class="overview-item">
<div class="overview-num running">{{ runningCount }}</div>
<div class="overview-label">运行</div>
</div>
<div class="overview-item">
<div class="overview-num charging">{{ chargingCount }}</div>
<div class="overview-label">充电</div>
</div>
<div class="overview-item">
<div class="overview-num fault">{{ faultCount }}</div>
<div class="overview-label">故障</div>
</div>
</div>
<div class="toolbar">
<el-input
v-model="search"
size="small"
clearable
placeholder="搜索 ID / 名称 / 任务"
class="search"
/>
<el-select
v-model="filterState"
size="small"
placeholder="状态"
clearable
class="filter"
>
<el-option
v-for="opt in stateOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="vehicle-list">
<div
v-for="car in filteredCars"
:key="car.id"
class="vehicle-row"
:class="{ 'is-selected': car.id === selectedId }"
@click="onSelect(car)"
>
<div class="vehicle-head">
<div class="vehicle-id-name">
<span class="vid">{{ car.id }}</span>
<span class="vname">{{ car.name }}</span>
</div>
<el-tag
size="small"
:type="stateTagType(car.state)"
class="state-tag"
effect="dark"
>
<span class="dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</el-tag>
</div>
<div class="vehicle-meta muted">
<span v-if="car.typeName" class="meta-item">{{ shortType(car.typeName) }}</span>
<span v-if="car.group" class="meta-item">{{ car.group }}</span>
<span v-if="car.ip" class="meta-item">{{ car.ip }}</span>
</div>
<div class="battery-row">
<span class="battery-label">电量</span>
<el-progress
:percentage="batteryPct(car)"
:stroke-width="8"
:show-text="false"
:color="batteryColor(batteryPct(car))"
class="battery-bar"
/>
<span class="battery-text" :class="batteryLevel(batteryPct(car))">
{{ batteryPct(car) }}%
</span>
</div>
<div class="mission-row">
<span class="mission-label">任务</span>
<template v-if="missionForCar(car)">
<span class="mission-name" :title="missionForCar(car)!.name">
{{ missionForCar(car)!.name }}
</span>
<el-tag
size="small"
:type="missionStatusTagType(missionForCar(car)!.status)"
effect="plain"
class="mission-tag"
>
{{ missionStatusLabel(missionForCar(car)!.status) }}
</el-tag>
</template>
<template v-else>
<span class="mission-name muted">无任务</span>
<el-tag size="small" type="info" effect="plain" class="mission-tag">空闲</el-tag>
</template>
</div>
</div>
<div v-if="!filteredCars.length" class="empty muted">
无匹配车辆
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Car, CarState } from '@/types/car'
import type { Mission, MissionStatus } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
const props = defineProps<{
cars: Car[]
missions: Mission[]
selectedId?: string | null
}>()
const emit = defineEmits<{
select: [ref: SelectedObjectRef]
}>()
const search = ref('')
const filterState = ref<CarState | ''>('')
const stateOptions: { value: CarState; label: string }[] = [
{ value: 'idle', label: '空闲' },
{ value: 'running', label: '运行中' },
{ value: 'charging', label: '充电中' },
{ value: 'paused', label: '已暂停' },
{ value: 'fault', label: '故障' },
{ value: 'offline', label: '离线' }
]
const stateLabels = stateOptions.reduce<Record<string, string>>((acc, o) => {
acc[o.value] = o.label
return acc
}, {})
function stateLabel(s: CarState): string {
return stateLabels[s] ?? s
}
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
function stateTagType(s: CarState): TagType {
switch (s) {
case 'running':
return 'success'
case 'charging':
return 'primary'
case 'paused':
return 'warning'
case 'fault':
return 'danger'
case 'offline':
return 'info'
case 'idle':
default:
return 'info'
}
}
const missionStatusLabels: Record<MissionStatus, string> = {
queued: '排队',
assigned: '已分配',
running: '执行中',
paused: '暂停',
completed: '已完成',
cancelled: '已取消',
failed: '失败'
}
function missionStatusLabel(s: MissionStatus): string {
return missionStatusLabels[s] ?? s
}
function missionStatusTagType(s: MissionStatus): TagType {
switch (s) {
case 'running':
return 'success'
case 'assigned':
return 'primary'
case 'queued':
return 'info'
case 'paused':
return 'warning'
case 'completed':
return 'success'
case 'cancelled':
return 'info'
case 'failed':
return 'danger'
default:
return 'info'
}
}
const onlineCount = computed(() => props.cars.filter((c) => c.state !== 'offline').length)
const runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length)
const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length)
const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').length)
const missionByCarId = computed(() => {
const order = (s: MissionStatus) =>
s === 'running' ? 0 : s === 'paused' ? 1 : s === 'assigned' ? 2 : 3
const map = new Map<string, Mission>()
for (const m of props.missions) {
if (!m.carId) continue
if (m.status !== 'running' && m.status !== 'paused' && m.status !== 'assigned') continue
const prev = map.get(m.carId)
if (!prev || order(m.status) < order(prev.status)) {
map.set(m.carId, m)
}
}
return map
})
function missionForCar(car: Car): Mission | undefined {
if (car.missionId) {
const found = props.missions.find((m) => m.id === car.missionId)
if (found) return found
}
return missionByCarId.value.get(car.id)
}
function batteryPct(car: Car): number {
const raw = car.batterySoc ?? 0
const pct = raw > 1 ? raw : raw * 100
return Math.max(0, Math.min(100, Math.round(pct)))
}
function batteryColor(p: number): string {
if (p < 20) return '#f56c6c'
if (p < 50) return '#e6a23c'
return '#67c23a'
}
function batteryLevel(p: number): 'low' | 'mid' | 'high' {
if (p < 20) return 'low'
if (p < 50) return 'mid'
return 'high'
}
function shortType(typeName: string): string {
const idx = typeName.lastIndexOf('.')
return idx >= 0 ? typeName.slice(idx + 1) : typeName
}
const filteredCars = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return props.cars.filter((c) => {
if (filterState.value && c.state !== filterState.value) return false
if (!tokens.length) return true
const mission = missionForCar(c)
const hay = [
c.id,
c.name,
c.typeName ?? '',
c.group ?? '',
c.ip ?? '',
stateLabel(c.state),
mission?.name ?? '',
mission ? missionStatusLabel(mission.status) : ''
]
.join(' ')
.toLowerCase()
return tokens.every((t) => hay.includes(t))
})
})
function onSelect(car: Car) {
emit('select', { kind: 'vehicle', id: detailIdForCar(car), name: car.name })
}
function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId)
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
}
</script>
<style scoped>
.vehicle-monitor {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
}
.overview-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
flex: none;
}
.overview-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 6px 4px;
text-align: center;
transition: var(--mg-transition, all 0.2s ease);
}
.overview-item:hover {
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.14);
border-color: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.4);
}
.overview-num {
font-size: 18px;
font-weight: 600;
color: #fff;
line-height: 1.2;
}
.overview-num.online { color: var(--mg-accent); text-shadow: 0 0 12px rgba(var(--mg-accent-rgb), 0.55); }
.overview-num.running { color: #b6e7a3; text-shadow: 0 0 12px rgba(103, 194, 58, 0.45); }
.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); }
.overview-num.fault { color: #ff9bb8; text-shadow: 0 0 12px rgba(245, 108, 108, 0.5); }
.overview-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
}
.toolbar {
display: flex;
gap: 6px;
flex: none;
}
.toolbar .search { flex: 1; min-width: 0; }
.toolbar .filter { width: 100px; flex: none; }
.vehicle-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 2px;
}
.vehicle-row {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.18s ease;
}
.vehicle-row:hover {
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.12);
border-color: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.35);
}
.vehicle-row.is-selected {
background: rgba(109, 40, 217, 0.32);
border-color: rgba(167, 139, 250, 0.75);
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95);
}
.vehicle-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vehicle-id-name {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.vid {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
font-family: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
flex: none;
}
.vname {
color: #fff;
font-weight: 600;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-tag {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; }
.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); }
.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; }
.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; }
.dot-offline { color: rgba(255, 255, 255, 0.35); }
.dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.vehicle-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
line-height: 1.3;
}
.meta-item { white-space: nowrap; }
.battery-row,
.mission-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.battery-label,
.mission-label {
width: 32px;
color: rgba(255, 255, 255, 0.65);
flex: none;
}
.battery-bar {
flex: 1;
min-width: 0;
}
.battery-bar :deep(.el-progress-bar__outer) {
background: rgba(255, 255, 255, 0.1);
}
.battery-text {
width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
flex: none;
}
.battery-text.low { color: #f56c6c; }
.battery-text.mid { color: #e6a23c; }
.battery-text.high { color: #67c23a; }
.mission-name {
flex: 1;
min-width: 0;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mission-name.muted { color: rgba(255, 255, 255, 0.5); }
.mission-tag { flex: none; }
.empty {
text-align: center;
padding: 24px 0;
font-size: 12px;
}
.muted { color: rgba(255, 255, 255, 0.7); }
</style>
@@ -0,0 +1,176 @@
<template>
<div class="workbench-panel">
<el-tabs v-model="activeNav" class="nav-tabs" @tab-change="onNavChange">
<el-tab-pane label="地图" name="map" />
<el-tab-pane label="进程" name="process" />
<el-tab-pane label="车辆" name="vehicle" />
<el-tab-pane label="场景" name="scene" />
<el-tab-pane label="脚本" name="script" />
</el-tabs>
<div class="toolbar-hint muted">
{{ navHint }}
</div>
<el-input
v-model="search"
size="small"
clearable
placeholder="搜索(多词空格分隔)"
class="search-input"
/>
<el-table
:data="filteredRows"
size="small"
stripe
highlight-current-row
:row-class-name="rowClassName"
max-height="280"
class="list-table"
@row-click="onRowClick"
>
<el-table-column
v-for="col in list?.columns ?? []"
:key="col"
:prop="columnProp(col)"
:label="col"
min-width="72"
show-overflow-tooltip
>
<template #default="{ row }">
{{ cellValue(row, col) }}
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { listWorkbench } from '@/api/workbench'
import type { WorkbenchList, WorkbenchListRow, WorkbenchNav, SelectedObjectRef } from '@/types/workbench'
const props = defineProps<{
refreshKey?: number
}>()
const emit = defineEmits<{
select: [ref: SelectedObjectRef]
}>()
const activeNav = ref<WorkbenchNav>('vehicle')
const list = ref<WorkbenchList | null>(null)
const search = ref('')
const selectedId = ref<string | null>(null)
const navHints: Record<WorkbenchNav, string> = {
map: '地图/装饰对象列表(对齐 SimpleLite 工作台「地图」)',
process: '自定义进程 Mission 列表',
vehicle: '场景车辆列表(ID / 地址 / 名称 / 概况)',
scene: '站点、路径、装饰物',
script: '每辆车挂载的 CarProgram'
}
const navHint = computed(() => navHints[activeNav.value])
const filteredRows = computed(() => {
const rows = list.value?.rows ?? []
const q = search.value.trim().toLowerCase()
if (!q) return rows
const tokens = q.split(/\s+/).filter(Boolean)
return rows.filter((row) =>
tokens.every((t) => rowMatches(row, t))
)
})
function rowMatches(row: WorkbenchListRow, token: string): boolean {
const parts = [
row.id,
row.name,
row.type,
row.status,
row.overview ?? '',
...Object.values(row.cells ?? {})
]
return parts.some((p) => p.toLowerCase().includes(token))
}
function columnProp(col: string): string {
const map: Record<string, keyof WorkbenchListRow> = {
ID: 'id',
名称: 'name',
类型: 'type',
图层: 'status',
状态: 'status',
信息: 'status',
概况: 'overview',
地址: 'status',
车辆: 'name',
脚本名: 'type'
}
return (map[col] ?? 'name') as string
}
function cellValue(row: WorkbenchListRow, col: string): string {
if (row.cells?.[col]) return row.cells[col]
const prop = columnProp(col)
const v = row[prop as keyof WorkbenchListRow]
if (prop === 'overview' && row.overview) return row.overview
return v != null ? String(v) : '—'
}
function rowClassName({ row }: { row: WorkbenchListRow }) {
return row.id === selectedId.value ? 'is-selected-row' : ''
}
function onRowClick(row: WorkbenchListRow) {
selectedId.value = row.id
const kind = activeNav.value === 'process' ? 'process'
: activeNav.value === 'script' ? 'script'
: activeNav.value
emit('select', { kind, id: row.id, name: row.name })
}
async function loadList() {
list.value = await listWorkbench(activeNav.value)
}
function onNavChange() {
selectedId.value = null
loadList()
}
watch(() => props.refreshKey, loadList)
watch(activeNav, loadList)
onMounted(loadList)
</script>
<style scoped>
.workbench-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.nav-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.toolbar-hint { font-size: 11px; line-height: 1.4; }
.search-input { flex: none; }
.list-table { flex: 1; min-height: 0; }
.workbench-panel,
.workbench-panel :deep(.el-tabs__item),
.workbench-panel :deep(.el-table .cell),
.workbench-panel :deep(.el-input__inner) {
color: #fff;
}
.toolbar-hint { color: rgba(255, 255, 255, 0.88); }
.muted { color: rgba(255, 255, 255, 0.75); }
:deep(.el-table th.el-table__cell) {
color: rgba(255, 255, 255, 0.9) !important;
}
:deep(.is-selected-row) td {
background: rgba(var(--mg-primary-hover-rgb), 0.28) !important;
color: #fff !important;
}
</style>
@@ -0,0 +1,506 @@
<template>
<div class="canvas-toolbar">
<!-- 对齐 -->
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
<el-button size="small" :loading="busy.align">
<el-icon><Operation /></el-icon>
<span class="btn-label">对齐</span>
<el-icon class="caret"><ArrowUp /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="toolbar-menu">
<div v-for="opt in ALIGN_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleAlign(opt.key)">
<el-checkbox :model-value="state?.align[opt.key] ?? false" />
<span>{{ opt.label }}</span>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 选择 -->
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
<el-button size="small" :loading="busy.select">
<el-icon><Pointer /></el-icon>
<span class="btn-label">选择</span>
<el-icon class="caret"><ArrowUp /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="toolbar-menu">
<div v-for="opt in SELECT_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleSelect(opt.key)">
<el-checkbox :model-value="state?.select[opt.key] ?? false" />
<span>{{ opt.label }}</span>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 右击功能 -->
<el-dropdown trigger="click" placement="top-start">
<el-button size="small">
<el-icon><FirstAidKit /></el-icon>
<span class="btn-label">右击功能</span>
<el-icon class="caret"><ArrowUp /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="toolbar-menu">
<el-dropdown-item @click="onContextMenuPlaceholder">寻路</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="separator" />
<!-- 显示图层 -->
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
<el-button size="small" :loading="busy.layer">
<el-icon><Files /></el-icon>
<span class="btn-label">显示图层</span>
<el-icon class="caret"><ArrowUp /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="toolbar-menu layer-menu">
<div v-if="!state || state.layers.length === 0" class="menu-empty">无图层</div>
<div
v-else
v-for="ly in state.layers"
:key="ly.name"
class="menu-row"
@click.stop="toggleLayer(ly.name, !ly.visible)"
>
<el-checkbox :model-value="ly.visible" />
<span>图层 {{ ly.name }}</span>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 显示内容 -->
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
<el-button size="small" :loading="busy.display">
<el-icon><View /></el-icon>
<span class="btn-label">显示内容</span>
<el-icon class="caret"><ArrowUp /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="toolbar-menu">
<div v-for="opt in DISPLAY_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleDisplay(opt.key)">
<el-checkbox :model-value="state?.display[opt.key] ?? false" />
<span>{{ opt.label }}</span>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="separator" />
<!-- 录制 -->
<el-button
size="small"
:type="state?.recording.isRecording ? 'danger' : 'default'"
:loading="busy.recording"
@click="toggleRecording"
>
<el-icon v-if="state?.recording.isRecording"><VideoPause /></el-icon>
<el-icon v-else><VideoCamera /></el-icon>
<span class="btn-label">{{ recordLabel }}</span>
</el-button>
<!-- 回放 -->
<el-button
size="small"
:type="state?.recording.isPlaying ? 'warning' : 'default'"
:loading="busy.playback"
@click="togglePlayback"
>
<el-icon v-if="state?.recording.isPlaying"><CircleClose /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
<span class="btn-label">{{ state?.recording.isPlaying ? '退出回放' : '回放' }}</span>
</el-button>
<!-- 录像管理 -->
<el-button size="small" @click="openManageDialog">
<el-icon><Document /></el-icon>
<span class="btn-label">录像管理</span>
</el-button>
<!-- 选择录像回放对话框 -->
<el-dialog
v-model="playDialogVisible"
title="选择录像回放"
width="640px"
append-to-body
destroy-on-close
>
<el-empty v-if="!state || state.recording.entries.length === 0" description="(无可用录像。点 ● 录制 创建首份录像。)" />
<el-table v-else :data="state.recording.entries" size="small" stripe>
<el-table-column prop="fileName" label="文件名" min-width="180" />
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ formatSize(row.fileSizeBytes) }}</template>
</el-table-column>
<el-table-column prop="frameCount" label="帧" width="70" />
<el-table-column label="时长" width="100">
<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
</el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatDate(row.fileWriteTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.headerReadable" @click="playRecording(row.fileName)">回放</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 录像管理对话框 -->
<el-dialog
v-model="manageDialogVisible"
title="录像管理"
width="780px"
append-to-body
destroy-on-close
>
<p class="muted-hint">目录{{ state?.recording.recordingsDirectory ?? '—' }}</p>
<el-empty v-if="!state || state.recording.entries.length === 0" description="(无可用录像。)" />
<el-table v-else :data="state.recording.entries" size="small" stripe>
<el-table-column prop="fileName" label="文件名" min-width="200" />
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ formatSize(row.fileSizeBytes) }}</template>
</el-table-column>
<el-table-column prop="frameCount" label="帧" width="70" />
<el-table-column label="时长" width="100">
<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
</el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatDate(row.fileWriteTime) }}</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.headerReadable" type="success" size="small">完整</el-tag>
<el-tooltip v-else :content="row.error" placement="top">
<el-tag type="danger" size="small">头部不可读</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.headerReadable" @click="playRecording(row.fileName)">回放</el-button>
<el-button link type="danger" @click="deleteRecording(row.fileName)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Operation,
Pointer,
Files,
View,
VideoCamera,
VideoPause,
VideoPlay,
CircleClose,
Document,
ArrowUp,
FirstAidKit
} from '@element-plus/icons-vue'
import {
workspaceToolbarApi,
type AlignKey,
type DisplayKey,
type SelectKey,
type ToolbarState
} from '@/api/workspaceToolbar'
/**
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
*
* 设计原则:
* - 所有状态都通过 `/sl/projection/toolbar/*` 走 SimpleLite 全局状态,多 tab 同源。
* - `state` 是后端权威单一真值;本地不维护"乐观"切换:每次 toggle 都拿后端最新返回回写。
* - 录制 / 回放期间 1Hz 轮询刷新 elapsed / 帧计数,避免页面打开后停留在初值。
* - 错误用 ElMessage.error 提示,不阻断画布主操作。
*/
const ALIGN_OPTIONS: { key: AlignKey; label: string }[] = [
{ key: 'sites', label: '站点' },
{ key: 'cars', label: 'AGV' },
{ key: 'tracks', label: '路径' }
]
const SELECT_OPTIONS: { key: SelectKey; label: string }[] = [
{ key: 'tracks', label: '路径' },
{ key: 'cars', label: 'AGV' },
{ key: 'decor', label: '区域/装饰' },
{ key: 'sites', label: '站点' }
]
const DISPLAY_OPTIONS: { key: DisplayKey; label: string }[] = [
{ key: 'labels', label: '场景图元标记' },
{ key: 'primitives', label: '场景图元' },
{ key: 'cars', label: '车辆' }
]
const state = ref<ToolbarState | null>(null)
const busy = reactive({
align: false,
select: false,
display: false,
layer: false,
recording: false,
playback: false
})
const playDialogVisible = ref(false)
const manageDialogVisible = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const recordLabel = computed(() => {
const r = state.value?.recording
if (!r?.isRecording) return '录制'
const sec = Math.floor((r.elapsedMs ?? 0) / 1000)
const mm = String(Math.floor(sec / 60)).padStart(2, '0')
const ss = String(sec % 60).padStart(2, '0')
return `停止 ${mm}:${ss}`
})
async function refresh() {
try {
state.value = await workspaceToolbarApi.getState()
} catch (err) {
ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`)
}
}
async function toggleAlign(key: AlignKey) {
if (!state.value) return
busy.align = true
try {
state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key])
} catch (err) {
ElMessage.error(`切换对齐失败:${(err as Error).message}`)
} finally {
busy.align = false
}
}
async function toggleSelect(key: SelectKey) {
if (!state.value) return
busy.select = true
try {
state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key])
} catch (err) {
ElMessage.error(`切换选择过滤失败:${(err as Error).message}`)
} finally {
busy.select = false
}
}
async function toggleDisplay(key: DisplayKey) {
if (!state.value) return
busy.display = true
try {
state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key])
} catch (err) {
ElMessage.error(`切换显示内容失败:${(err as Error).message}`)
} finally {
busy.display = false
}
}
async function toggleLayer(name: string, visible: boolean) {
busy.layer = true
try {
state.value = await workspaceToolbarApi.setLayerVisibility(name, visible)
} catch (err) {
ElMessage.error(`切换图层失败:${(err as Error).message}`)
} finally {
busy.layer = false
}
}
function onContextMenuPlaceholder() {
ElMessage.info('寻路右键动作尚未接入 SimpleLite;请使用 SegmentPlan.FindRoute / 工作台脚本。')
}
async function toggleRecording() {
const cur = state.value?.recording
if (!cur) return
if (cur.isPlaying) {
ElMessage.warning('当前在回放中,请先停止回放。')
return
}
busy.recording = true
try {
state.value = cur.isRecording
? await workspaceToolbarApi.stopRecording()
: await workspaceToolbarApi.startRecording('')
} catch (err) {
ElMessage.error(`录制操作失败:${(err as Error).message}`)
} finally {
busy.recording = false
}
}
async function togglePlayback() {
const cur = state.value?.recording
if (!cur) return
if (cur.isRecording) {
ElMessage.warning('录制中无法进入回放,请先停止录制。')
return
}
if (cur.isPlaying) {
busy.playback = true
try {
state.value = await workspaceToolbarApi.stopPlayback()
} catch (err) {
ElMessage.error(`停止回放失败:${(err as Error).message}`)
} finally {
busy.playback = false
}
return
}
await refresh()
playDialogVisible.value = true
}
async function playRecording(fileName: string) {
busy.playback = true
try {
state.value = await workspaceToolbarApi.startPlayback(fileName)
playDialogVisible.value = false
manageDialogVisible.value = false
ElMessage.success(`已开始回放 ${fileName}`)
} catch (err) {
ElMessage.error(`启动回放失败:${(err as Error).message}`)
} finally {
busy.playback = false
}
}
async function deleteRecording(fileName: string) {
try {
await ElMessageBox.confirm(`确认删除录像 ${fileName}?此操作不可恢复。`, '删除录像', { type: 'warning' })
} catch { return }
try {
state.value = await workspaceToolbarApi.deleteRecording(fileName)
ElMessage.success(`已删除 ${fileName}`)
} catch (err) {
ElMessage.error(`删除失败:${(err as Error).message}`)
}
}
async function openManageDialog() {
await refresh()
manageDialogVisible.value = true
}
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(0)} KB`
return `${(kb / 1024).toFixed(1)} MB`
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '00:00:00'
const sec = Math.floor(ms / 1000)
const h = String(Math.floor(sec / 3600)).padStart(2, '0')
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
const s = String(sec % 60).padStart(2, '0')
return `${h}:${m}:${s}`
}
function formatDate(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
onMounted(() => {
void refresh()
pollTimer = setInterval(() => {
const r = state.value?.recording
if (r?.isRecording || r?.isPlaying) void refresh()
}, 1000)
})
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.canvas-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.45);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-bottom-left-radius: var(--mg-radius, 16px);
border-bottom-right-radius: var(--mg-radius, 16px);
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
flex-wrap: wrap;
}
.canvas-toolbar :deep(.el-button) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.btn-label {
margin-left: 2px;
}
.caret {
margin-left: 2px;
font-size: 10px;
opacity: 0.7;
}
.separator {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.18);
margin: 0 4px;
}
.toolbar-menu .menu-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
cursor: pointer;
user-select: none;
font-size: 13px;
color: var(--el-text-color-primary);
}
.toolbar-menu .menu-row:hover {
background: var(--el-fill-color-light);
}
.toolbar-menu .menu-row :deep(.el-checkbox) {
margin-right: 0;
pointer-events: none;
}
.layer-menu {
max-height: 320px;
overflow-y: auto;
}
.menu-empty {
padding: 10px 14px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.muted-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>