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,678 @@
<template>
<div class="login-page">
<div class="bg-image" />
<div class="bg-overlay" />
<div class="bg-orb bg-orb-a" />
<div class="bg-orb bg-orb-b" />
<div class="bg-orb bg-orb-c" />
<div class="bg-stars" aria-hidden="true">
<span v-for="i in 18" :key="i" :style="starStyles[i - 1]" />
</div>
<div class="login-card mg-glass-lg">
<!-- 左侧品牌 hero -->
<div class="hero">
<div class="hero-brand">
<div class="hero-mark"></div>
<div class="hero-name">迷毂</div>
</div>
<div class="hero-tagline"> </div>
<div class="hero-tagline-en">INTELLIGENT · DISPATCH · PLATFORM</div>
<div class="hero-divider" />
<ul class="hero-points">
<li><span class="dot" /> 复刻 ARCHITECTURE.md §3.2 启动登录窗</li>
<li><span class="dot" /> Web-Enabled · 管理员 / 运营 双视图</li>
<li><span class="dot" /> 嵌入 webVRender 真实 3D 场景</li>
</ul>
<div class="hero-footnote">
<span class="badge">v1.7</span>
<span>{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
</div>
</div>
<!-- 右侧表单 -->
<div class="panel">
<div class="panel-head">
<div class="panel-title"> </div>
<div class="panel-sub">访问 SimpleLite Vue 外壳</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
<el-form-item prop="username">
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" size="large" show-password placeholder="密码(Mock,任意非空)" autocomplete="current-password">
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="scope" class="scope-item">
<div class="scope-wrap">
<button type="button" class="scope-tab" :class="{ active: form.scope === 'Platform' }" @click="form.scope = 'Platform'">
<el-icon><Setting /></el-icon>
<div class="scope-meta">
<div class="scope-name">管理员</div>
<div class="scope-desc">platform-vue · 全功能</div>
</div>
</button>
<button type="button" class="scope-tab" :class="{ active: form.scope === 'RCSMonitor' }" @click="form.scope = 'RCSMonitor'">
<el-icon><Monitor /></el-icon>
<div class="scope-meta">
<div class="scope-name">运营</div>
<div class="scope-desc">rcsmonitor-vue · 受限</div>
</div>
</button>
</div>
</el-form-item>
<!-- 会话 N+1启动反转Platform.Server 作为主入口登录时由用户选择 SimpleLite 的启动模式
"本地+Web" SimpleLite 同时启 LocalTerminal + WebTerminal "仅Web" 只起 WebTerminal-->
<el-form-item prop="launchMode" class="scope-item launch-mode-item">
<div class="launch-mode-head">
<span class="launch-mode-title">SimpleLite 启动模式</span>
<el-tooltip placement="top">
<template #content>
<div class="launch-tip">
<div><b>本地+Web</b>登录后由 Platform 后端拉起 SimpleLite同时打开桌面端 ImGui 窗口与浏览器端 webVRender</div>
<div><b> Web</b>只起 WebTerminal不弹本地桌面窗口适合远程办公 / 服务器部署</div>
</div>
</template>
<el-icon class="launch-mode-help"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="scope-wrap launch-wrap">
<button
type="button"
class="scope-tab"
:class="{ active: form.launchMode === 'DesktopAndWeb' }"
@click="form.launchMode = 'DesktopAndWeb'">
<el-icon><Cpu /></el-icon>
<div class="scope-meta">
<div class="scope-name">本地 + Web</div>
<div class="scope-desc">桌面窗口 & 浏览器同时启动</div>
</div>
</button>
<button
type="button"
class="scope-tab"
:class="{ active: form.launchMode === 'WebOnly' }"
@click="form.launchMode = 'WebOnly'">
<el-icon><Connection /></el-icon>
<div class="scope-meta">
<div class="scope-name"> Web</div>
<div class="scope-desc">不启桌面窗口 · 浏览器使用</div>
</div>
</button>
</div>
</el-form-item>
<div class="row row-between">
<el-checkbox v-model="form.remember" size="default" class="remember">下次自动按当前 scope 进入</el-checkbox>
<el-link underline="never" class="adv-link" @click="adv = adv.length ? [] : ['adv']">高级 </el-link>
</div>
<el-collapse v-model="adv" class="adv-collapse">
<el-collapse-item name="adv">
<template #title><span class="adv-title">Web-Enabled · 端口与监听</span></template>
<div class="adv-grid">
<el-form-item label="监听地址">
<el-input v-model="adv2.host" size="default" />
</el-form-item>
<el-form-item label="WebAPI">
<el-input-number v-model="adv2.apiPort" :min="1" :max="65535" size="default" controls-position="right" />
</el-form-item>
<el-form-item label="Platform">
<el-input-number v-model="adv2.platformPort" :min="1" :max="65535" size="default" controls-position="right" />
</el-form-item>
<el-form-item label="webVRender">
<el-input-number v-model="adv2.vrPort" :min="1" :max="65535" size="default" controls-position="right" />
</el-form-item>
</div>
</el-collapse-item>
</el-collapse>
<el-button type="primary" :loading="loading" class="btn-login" size="large" @click="submit">
</el-button>
<div class="bottom-note">
任意非空用户名 + 任意密码即可登录scope 决定进入 admin / monitor 视图权限按 Mock 表生成
</div>
</el-form>
</div>
</div>
<div class="footer-stamp">
迷毂 · {{ year }} · ARCHITECTURE v1.6 · powered by webVRender
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { User, Lock, Setting, Monitor, Cpu, Connection, QuestionFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import type { LaunchMode, Scope } from '@/types/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const ui = useUiStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const adv = ref<string[]>([])
const form = reactive({
username: 'admin',
password: 'admin',
scope: 'Platform' as Scope,
// 会话 N+1:默认「本地+Web」与改造前 Configuration.displayMode="web+local" 一致,
// 保证「我啥也不选」时的行为与改造前完全相同。
launchMode: 'DesktopAndWeb' as LaunchMode,
remember: true
})
const adv2 = reactive({
host: '0.0.0.0',
apiPort: 7001,
platformPort: 8080,
vrPort: 8223
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
scope: [{ required: true, message: '请选择 scope', trigger: 'change' }],
launchMode: [{ required: true, message: '请选择启动模式', trigger: 'change' }]
}
const year = computed(() => new Date().getFullYear())
const starStyles = Array.from({ length: 18 }, (_, i) => {
const top = Math.random() * 100
const left = Math.random() * 100
const size = 2 + Math.random() * 3
const delay = -Math.random() * 6
const opacity = 0.35 + Math.random() * 0.45
void i
return {
top: `${top}%`,
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
opacity: `${opacity}`,
animationDelay: `${delay}s`
} as Record<string, string>
})
async function submit() {
if (!formRef.value) return
await formRef.value.validate(async (ok) => {
if (!ok) return
loading.value = true
try {
const resp = await auth.login({
username: form.username,
password: form.password,
scope: form.scope,
launchMode: form.launchMode
})
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
if (resp.launchWarning) {
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
} else if (resp.runMode === 'Detached') {
ElMessage({
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
type: 'warning',
duration: 6000,
showClose: true
})
}
const target = (route.query.redirect as string | undefined) ?? (form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map')
router.push(target)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(`登录失败:${msg}`)
} finally {
loading.value = false
}
})
}
</script>
<style scoped>
/* ===== 背景层 ===== */
.login-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
.bg-image {
position: absolute; inset: 0;
background: url('/login-bg.jpg') center/cover no-repeat;
filter: saturate(0.35) brightness(0.34) hue-rotate(-12deg);
transform: scale(1.08);
z-index: 0;
}
.bg-overlay {
position: absolute; inset: 0;
background:
radial-gradient(ellipse at 12% 18%, rgba(var(--mg-primary-hover-rgb), 0.65) 0%, transparent 55%),
radial-gradient(ellipse at 85% 80%, rgba(var(--mg-bg-app-deep-rgb), 0.78) 0%, transparent 60%),
radial-gradient(ellipse at 50% 50%, rgba(var(--mg-primary-rgb), 0.40) 0%, transparent 70%),
linear-gradient(135deg, rgba(var(--mg-bg-aside-rgb), 0.85) 0%, rgba(var(--mg-primary-rgb), 0.48) 50%, rgba(var(--mg-bg-app-deep-rgb), 0.88) 100%);
z-index: 1;
}
/* 登录页:紫色网格层(更明显) */
.bg-overlay::after {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px);
background-size: 56px 56px, 56px 56px;
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
z-index: 1;
pointer-events: none;
animation: drift 18s ease-in-out infinite;
}
.bg-orb-a {
width: 460px; height: 460px;
top: -140px; left: -120px;
background: radial-gradient(circle, var(--mg-accent) 0%, var(--mg-primary) 50%, transparent 80%);
opacity: 0.6;
}
.bg-orb-b {
width: 580px; height: 580px;
bottom: -200px; right: -180px;
background: radial-gradient(circle, var(--mg-primary-hover) 0%, var(--mg-bg-app-3) 60%, transparent 85%);
opacity: 0.55;
animation-delay: -6s;
}
.bg-orb-c {
width: 320px; height: 320px;
top: 30%; right: 8%;
background: radial-gradient(circle, var(--mg-primary-light-8) 0%, var(--mg-primary-hover) 60%, transparent 90%);
opacity: 0.32;
animation-delay: -12s;
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, -30px) scale(1.05); }
}
.bg-stars { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.bg-stars span {
position: absolute;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
animation: twinkle 4s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1.0; transform: scale(1.2); }
}
/* ===== 卡片:双栏 hero ===== */
.login-card {
position: relative;
z-index: 2;
width: 880px;
max-width: calc(100vw - 32px);
min-height: 560px;
display: grid;
grid-template-columns: 340px 1fr;
overflow: hidden;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.65),
0 0 80px rgba(var(--mg-primary-rgb), 0.42),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.30) inset,
0 1px 0 rgba(255, 255, 255, 0.40) inset !important;
}
/* 顶部霓虹高光线 */
.login-card::before {
content: '';
position: absolute;
top: 0; left: 12%; right: 12%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--mg-accent-rgb), 0.6) 30%,
rgba(255, 255, 255, 0.95) 50%,
rgba(var(--mg-accent-rgb), 0.6) 70%,
transparent 100%);
pointer-events: none;
z-index: 3;
}
/* ===== 左侧 hero ===== */
.hero {
position: relative;
padding: 40px 32px;
color: #fff;
background:
linear-gradient(180deg, rgba(var(--mg-primary-hover-rgb), 0.42) 0%, rgba(var(--mg-bg-aside-rgb), 0.35) 100%);
border-right: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
}
.hero::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse at 80% 20%, rgba(var(--mg-accent-rgb), 0.35), transparent 60%);
pointer-events: none;
}
.hero-brand { display: flex; align-items: center; gap: 14px; }
.hero-mark {
width: 56px; height: 56px; border-radius: 14px;
background: linear-gradient(135deg, var(--mg-accent) 0%, var(--mg-primary-hover) 50%, var(--mg-primary-active) 100%);
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 700; color: #fff;
box-shadow:
0 14px 32px rgba(var(--mg-primary-rgb), 0.65),
0 0 36px rgba(var(--mg-primary-hover-rgb), 0.55),
0 0 0 1px rgba(255, 255, 255, 0.30) inset;
letter-spacing: 1px;
animation: mg-pulse-glow 3.6s ease-in-out infinite;
}
.hero-name {
font-size: 34px; font-weight: 700;
letter-spacing: 4px;
background: linear-gradient(90deg, #ffffff 0%, var(--mg-accent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 24px rgba(var(--mg-accent-rgb), 0.45);
}
.hero-tagline {
margin-top: 32px;
font-size: 18px;
font-weight: 500;
letter-spacing: 8px;
color: rgba(255, 255, 255, 0.94);
}
.hero-tagline-en {
margin-top: 4px;
font-size: 11px;
letter-spacing: 3px;
color: rgba(232, 215, 245, 0.6);
text-transform: uppercase;
}
.hero-divider {
margin: 22px 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
}
.hero-points {
list-style: none;
padding: 0; margin: 0;
font-size: 12.5px;
color: rgba(236, 225, 245, 0.85);
line-height: 1.9;
flex: 1;
}
.hero-points li { display: flex; align-items: center; gap: 8px; }
.hero-points .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--mg-accent);
box-shadow: 0 0 10px rgba(var(--mg-accent-rgb), 0.85);
flex-shrink: 0;
}
.hero-footnote {
margin-top: auto;
display: flex; align-items: center; gap: 10px;
font-size: 11px;
color: rgba(232, 215, 245, 0.55);
letter-spacing: 1px;
}
.hero-footnote .badge {
padding: 2px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
color: #fff;
font-weight: 600;
}
/* ===== 右侧表单 ===== */
.panel {
padding: 44px 44px 32px;
display: flex;
flex-direction: column;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
}
.panel-head { margin-bottom: 28px; }
.panel-title {
font-size: 30px; font-weight: 700;
letter-spacing: 12px;
background: linear-gradient(135deg, #ffffff 0%, var(--mg-accent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 22px rgba(var(--mg-primary-hover-rgb), 0.6);
}
.panel-sub {
margin-top: 6px;
font-size: 12.5px;
color: rgba(232, 215, 245, 0.65);
letter-spacing: 1px;
}
.glass-form { flex: 1; display: flex; flex-direction: column; }
.glass-form :deep(.el-form-item) { margin-bottom: 18px; }
/* 输入框 透明玻璃 */
.glass-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset !important;
border-radius: 10px !important;
transition: all .25s;
}
.glass-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.13) !important;
box-shadow: 0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
}
.glass-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.15) !important;
box-shadow:
0 0 0 1px rgba(var(--mg-accent-rgb), 0.85) inset,
0 0 20px rgba(var(--mg-primary-hover-rgb), 0.55) !important;
}
.glass-form :deep(.el-input__inner) {
color: #fff !important;
-webkit-text-fill-color: #fff !important;
font-size: 14px;
height: 42px;
}
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.45); }
.glass-form :deep(.el-input__prefix-inner > :first-child),
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.6); }
/* scope 双卡 */
.scope-item :deep(.el-form-item__content) { width: 100%; }
.scope-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: 100%; }
/* 启动模式(会话 N+1):与 scope 双卡共用 .scope-tab 样式,仅在外层做一些标题/留白调整 */
.launch-mode-item :deep(.el-form-item__content) { display: flex; flex-direction: column; align-items: stretch; }
.launch-mode-head {
display: flex; align-items: center; gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: rgba(232, 215, 245, 0.72);
letter-spacing: 1px;
}
.launch-mode-title { font-weight: 500; }
.launch-mode-help {
color: rgba(232, 215, 245, 0.55);
cursor: help;
font-size: 14px;
}
.launch-mode-help:hover { color: var(--mg-accent); }
.launch-wrap { /* 与 scope 一致,留位置给 hover 阴影 */ margin-bottom: 4px; }
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
.launch-tip > div + div { margin-top: 6px; }
.scope-tab {
appearance: none;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
padding: 14px 14px;
display: flex; align-items: center; gap: 12px;
color: rgba(255, 255, 255, 0.78);
cursor: pointer;
transition: all .2s;
text-align: left;
}
.scope-tab:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
transform: translateY(-1px);
}
.scope-tab.active {
background: linear-gradient(135deg,
rgba(var(--mg-primary-rgb), 0.62) 0%,
rgba(var(--mg-primary-hover-rgb), 0.42) 100%);
border-color: rgba(var(--mg-accent-rgb), 0.85);
color: #fff;
box-shadow:
0 0 24px rgba(var(--mg-primary-hover-rgb), 0.55),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset;
}
.scope-tab .el-icon { font-size: 20px; }
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.scope-name { font-size: 14px; font-weight: 600; }
.scope-desc { font-size: 11px; opacity: 0.75; margin-top: 2px; }
/* 行 - 记住选择 & 高级 */
.row { display: flex; align-items: center; }
.row-between { justify-content: space-between; margin: -6px 0 10px; }
.remember :deep(.el-checkbox__label) {
color: rgba(255, 255, 255, 0.92) !important;
font-size: 12.5px;
text-shadow: 0 1px 2px rgba(22, 4, 31, 0.4);
}
.remember :deep(.el-checkbox__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
}
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
background: var(--mg-primary);
border-color: var(--mg-primary);
}
.adv-link { color: rgba(232, 215, 245, 0.82) !important; font-size: 12.5px; }
.adv-link:hover { color: #fff !important; }
/* 高级折叠 */
.adv-collapse { background: transparent !important; border: none !important; margin-bottom: 16px; }
.adv-collapse :deep(.el-collapse-item__wrap),
.adv-collapse :deep(.el-collapse-item__header) {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(232, 215, 245, 0.82) !important;
}
.adv-title { font-size: 12px; letter-spacing: 1px; }
.adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; padding-top: 6px; }
.adv-grid :deep(.el-form-item__label) {
color: rgba(232, 215, 245, 0.7);
font-size: 12px;
}
.adv-grid :deep(.el-input-number) { width: 100%; }
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
/* 登录按钮:紫色霓虹 · 扫光动效 */
.btn-login {
position: relative;
overflow: hidden;
height: 46px !important;
border-radius: 10px;
margin-top: 6px;
font-size: 15px;
letter-spacing: 6px;
font-weight: 600;
background: linear-gradient(135deg, var(--mg-primary) 0%, var(--mg-primary-hover) 100%) !important;
border: none !important;
box-shadow:
0 12px 28px rgba(var(--mg-primary-rgb), 0.55),
0 0 28px rgba(var(--mg-primary-hover-rgb), 0.40),
0 0 0 1px rgba(255, 255, 255, 0.22) inset !important;
transition: all .28s cubic-bezier(.25, .8, .25, 1);
}
.btn-login::after {
content: '';
position: absolute;
top: 0; left: -120%;
width: 80%; height: 100%;
background: linear-gradient(110deg,
transparent 0%,
rgba(255, 255, 255, 0.0) 30%,
rgba(255, 255, 255, 0.40) 50%,
rgba(255, 255, 255, 0.0) 70%,
transparent 100%);
transition: left .6s cubic-bezier(.25, .8, .25, 1);
pointer-events: none;
}
.btn-login:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, var(--mg-primary-hover) 0%, var(--mg-primary-light-3) 100%) !important;
box-shadow:
0 18px 38px rgba(var(--mg-primary-rgb), 0.65),
0 0 42px rgba(var(--mg-primary-hover-rgb), 0.65),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
}
.btn-login:hover::after { left: 120%; }
.bottom-note {
margin-top: 16px;
font-size: 11.5px;
color: rgba(232, 215, 245, 0.55);
text-align: center;
line-height: 1.6;
}
/* 底部水印 */
.footer-stamp {
position: absolute;
bottom: 18px; left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 1.5px;
color: rgba(232, 215, 245, 0.4);
z-index: 2;
}
/* 错误提示 */
.glass-form :deep(.el-form-item__error) {
color: #ff9bb8;
padding-top: 4px;
}
/* 小屏自适应 */
@media (max-width: 720px) {
.login-card { grid-template-columns: 1fr; min-height: auto; }
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.12); padding: 28px; }
.panel { padding: 28px; }
.hero-points, .hero-divider { display: none; }
}
</style>
@@ -0,0 +1,66 @@
<template>
<div class="status-page mg-content">
<el-card shadow="never" class="status-card">
<template #header>
<span>SimpleLite Service Status</span>
<el-tag type="success" effect="dark" style="margin-left: 8px">Web-Enabled (Mock)</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="模式">Web-Enabled</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ startTime }}</el-descriptions-item>
<el-descriptions-item label="运行时长">{{ uptime }}</el-descriptions-item>
<el-descriptions-item label="节点角色"><el-tag type="success">Active (ROSE)</el-tag></el-descriptions-item>
<el-descriptions-item label="WebAPI">http://0.0.0.0:7001 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="WebSocket">ws://0.0.0.0:7002 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="webVRender">http://0.0.0.0:8223 <el-tag size="small" type="success">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="Platform.Server">:8080 (pid=12345)</el-descriptions-item>
<el-descriptions-item label="在线 Vue 客户端">admin=3, monitor=4</el-descriptions-item>
<el-descriptions-item label="调度循环 / 任务">50 Hz · 14 / 32</el-descriptions-item>
</el-descriptions>
<div class="status-actions">
<el-button>查看日志</el-button>
<el-button type="warning" plain>重启 Web</el-button>
<el-button type="danger" plain>关闭服务</el-button>
<el-button @click="back">返回</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const startTime = new Date().toLocaleString('zh-CN')
const uptime = ref('00:00:00')
let timer: number | undefined
const t0 = Date.now()
function tick() {
const ms = Date.now() - t0
const s = Math.floor(ms / 1000)
const h = String(Math.floor(s / 3600)).padStart(2, '0')
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
uptime.value = `${h}:${m}:${ss}`
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
function back() { router.back() }
</script>
<style scoped>
.status-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.status-card { width: 720px; }
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
</style>
@@ -0,0 +1,75 @@
<template>
<PermissionGuard widget-id="CadToolbar">
<el-card shadow="never">
<template #header><span>CAD 工具栏cad.tool.run</span></template>
<el-tabs v-model="active">
<el-tab-pane v-for="g in groups" :key="g.key" :name="g.key" :label="g.label">
<el-row :gutter="12">
<el-col v-for="t in g.tools" :key="t.id" :span="6" style="margin-bottom: 12px">
<el-card shadow="hover" class="tool-card" @click="run(t)">
<el-icon size="24" class="tool-icon"><Tools /></el-icon>
<div class="tool-name">{{ t.label }}</div>
<div class="tool-desc">{{ t.desc }}</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Tools } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
interface CadTool { id: string; label: string; desc: string }
const active = ref('Scene')
const groups: Array<{ key: string; label: string; tools: CadTool[] }> = [
{
key: 'Scene', label: '场景 (Scene)',
tools: [
{ id: 'grid', label: '生成网格', desc: '按间距生成站点网格' },
{ id: 'mirror', label: '镜像', desc: '镜像复制选中对象' },
{ id: 'align', label: '对齐', desc: '横向/纵向对齐多选' },
{ id: 'distribute', label: '等距分布', desc: '在两端之间均匀分布站点' }
]
},
{
key: 'Car', label: '车辆 (Car)',
tools: [
{ id: 'spawn', label: '批量创建', desc: '从模板批量创建车辆' },
{ id: 'reset', label: '回原点', desc: '将选中车辆送回原点' }
]
},
{
key: 'Project', label: '工程 (Project)',
tools: [
{ id: 'validate', label: '工程校验', desc: '检查轨道连通性 / 重复站点' },
{ id: 'snapshot', label: '快照', desc: '输出 problems/simplelite-scene-*.json' }
]
}
]
function run(t: CadTool) {
ElMessage.info(`占位:执行 CAD 工具 [${t.id}] ${t.label}`)
}
</script>
<style scoped>
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
.tool-name { font-weight: 600; margin-top: 6px; }
.tool-desc { color: rgba(220, 200, 252, 0.55); font-size: 12px; margin-top: 4px; }
.tool-icon {
color: var(--mg-accent);
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
}
.tool-card:hover .tool-icon {
color: #fff;
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="car-page">
<el-tabs v-model="tab" type="border-card" class="car-tabs admin-tabs" @tab-change="onTabChange">
<el-tab-pane label="车辆列表" name="list">
<ReflectionManagerPanel
v-if="tab === 'list'"
kind="car"
kind-label="车辆"
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
empty-text="当前没有车辆点击右上角新建车辆从已加载的 CarType 中选一个实例化"
/>
</el-tab-pane>
<el-tab-pane label="车型样式 / 报警颜色" name="style">
<CarStyleEditor v-if="tab === 'style'" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
import CarStyleEditor from './CarStyleEditor.vue'
const tab = ref<'list' | 'style'>('list')
function onTabChange(name: string | number) {
tab.value = name as typeof tab.value
sessionStorage.setItem('admin.cars.tab', tab.value)
}
onMounted(() => {
const cached = sessionStorage.getItem('admin.cars.tab')
if (cached === 'list' || cached === 'style') tab.value = cached
})
</script>
<style scoped>
.car-page {
height: calc(100vh - 56px);
padding: 12px 14px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.car-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.car-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
padding: 0;
}
.car-tabs :deep(.el-tab-pane) {
height: 100%;
overflow: hidden;
}
</style>
@@ -0,0 +1,368 @@
<template>
<div class="cse-root" v-loading="loading">
<!-- 报警调色板 -->
<el-card shadow="never" class="cse-card">
<template #header>
<div class="cse-card-header">
<span class="cse-card-title">报警 / 状态调色板</span>
<el-tag size="small" type="info" effect="plain">7 种默认状态</el-tag>
<span class="cse-card-spacer" />
<el-button size="small" :loading="resettingPalette" @click="resetPaletteToDefault">恢复默认</el-button>
<el-button size="small" type="primary" :disabled="!paletteDirty" :loading="savingPalette" @click="savePalette">
保存调色板写入 simple.json
</el-button>
</div>
</template>
<p class="cse-desc">
车体颜色的判定优先级
<el-tag size="small" effect="plain">1. AlarmInfo 非空 报警</el-tag>
<el-tag size="small" effect="plain">2. lstatus 包含关键字 对应键</el-tag>
<el-tag size="small" effect="plain">3. 兜底 上下线默认色</el-tag>
修改下方颜色 + 保存调色板即时生效
</p>
<div class="cse-palette">
<div v-for="entry in paletteEntries" :key="entry.key" class="cse-palette-item">
<div class="cse-palette-label">
<span class="cse-palette-key">{{ entry.key }}</span>
<el-tag v-if="!entry.isDefault" size="small" type="warning" effect="plain">自定义</el-tag>
</div>
<div class="cse-palette-pickers">
<el-color-picker
:model-value="argbToHex(getPaletteColor(entry.key))"
show-alpha
size="small"
@update:model-value="(v: string | null) => setPaletteColor(entry.key, v)"
/>
<code class="cse-color-code">#{{ argbToHex(getPaletteColor(entry.key)).toUpperCase() }}</code>
</div>
</div>
</div>
</el-card>
<!-- 车型样式 ─────────────────────────────────────────────── -->
<el-card shadow="never" class="cse-card cse-types-card">
<template #header>
<div class="cse-card-header">
<span class="cse-card-title">车型样式</span>
<el-tag size="small" type="info" effect="plain">{{ types.length }} 个已加载车型</el-tag>
<span class="cse-card-spacer" />
<el-button size="small" @click="refresh">
<el-icon><Refresh /></el-icon>
<span class="cse-btn-text">刷新</span>
</el-button>
</div>
</template>
<p class="cse-desc">
点击车型行展开编辑:尺寸(米)、车体 / 描边 / 标签颜色、是否显示名称、3D 模型文件路径。
每辆车按 .NET 类型 (`assemblyName::shortName`) 索引样式。修改后点行内「保存」即时写入并生效。
</p>
<el-collapse v-model="activeKey" accordion>
<el-collapse-item v-for="row in types" :key="row.typeName" :name="row.typeName">
<template #title>
<div class="cse-row-title">
<span class="cse-row-name">{{ row.label }}</span>
<code class="cse-row-fqn">{{ row.typeName }}</code>
<el-tag v-if="row.hasOverride" size="small" type="success" effect="plain">已覆盖</el-tag>
<el-tag v-else size="small" type="info" effect="plain">默认</el-tag>
</div>
</template>
<CarStyleEditorForm
v-if="forms[row.typeName]"
:type-full-name="row.typeName"
:model-value="forms[row.typeName]!"
:has-override="row.hasOverride"
:saving="savingTypeName === row.typeName"
@update:model-value="(v: CarStyleDto) => forms[row.typeName] = v"
@save="onSaveType(row)"
@reset="onResetType(row)"
/>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!loading && types.length === 0" description="未发现任何 Car 子类请确认 SimpleLite 已加载相应插件 dll" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import CarStyleEditorForm from './CarStyleEditorForm.vue'
import {
reflectionApi,
type AlarmColorEntry,
type CarStyleDto,
type CarStyleTypeRow
} from '@/api/reflection'
const loading = ref(false)
const savingPalette = ref(false)
const resettingPalette = ref(false)
const savingTypeName = ref<string | null>(null)
const types = ref<CarStyleTypeRow[]>([])
const forms = reactive<Record<string, CarStyleDto>>({})
const activeKey = ref<string | undefined>(undefined)
const defaultKeys = ref<string[]>([])
const paletteEntries = ref<AlarmColorEntry[]>([])
/** 当前正在编辑、尚未持久化的颜色映射 */
const paletteDraft = reactive<Record<string, number>>({})
/** refresh 时记录服务器返回的快照,用于 diff 判定 dirty */
const paletteSnapshot = reactive<Record<string, number>>({})
const paletteDirty = computed(() => {
const keys = new Set([...Object.keys(paletteDraft), ...Object.keys(paletteSnapshot)])
for (const k of keys) if (paletteDraft[k] !== paletteSnapshot[k]) return true
return false
})
function getPaletteColor(key: string): number {
return paletteDraft[key] ?? 0
}
function setPaletteColor(key: string, hex: string | null) {
paletteDraft[key] = hex ? hexToArgb(hex) : 0
}
/** Element Plus el-color-picker 用 `#RRGGBBAA`show-alpha)。 */
function argbToHex(argb: number): string {
const a = (argb >>> 24) & 0xff
const r = (argb >>> 16) & 0xff
const g = (argb >>> 8) & 0xff
const b = argb & 0xff
const hex = (n: number) => n.toString(16).padStart(2, '0')
return `${hex(r)}${hex(g)}${hex(b)}${hex(a)}`
}
function hexToArgb(hex: string): number {
const m = hex.replace(/^#/, '')
if (m.length === 6) {
const r = parseInt(m.slice(0, 2), 16)
const g = parseInt(m.slice(2, 4), 16)
const b = parseInt(m.slice(4, 6), 16)
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0
}
if (m.length === 8) {
const r = parseInt(m.slice(0, 2), 16)
const g = parseInt(m.slice(2, 4), 16)
const b = parseInt(m.slice(4, 6), 16)
const a = parseInt(m.slice(6, 8), 16)
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0
}
return 0
}
async function refresh() {
loading.value = true
try {
// 会话 N+2car-types 与 alarm-colors 独立 await,避免一边失败把另一边的成功也吞掉。
// 任意一边 SimpleLite 未启动 / 返回了非预期结构(YARP 502 / null payload),都不要让整个面板白屏崩。
const carTypesResp = await reflectionApi.getCarStyleTypes().catch((err) => {
console.warn('[CarStyleEditor] getCarStyleTypes failed:', err)
return null
})
const paletteResp = await reflectionApi.getAlarmColors().catch((err) => {
console.warn('[CarStyleEditor] getAlarmColors failed:', err)
return null
})
// 防御 1car-types 可能为 null / 缺 types 字段 / types 非数组。
const carTypesList = Array.isArray(carTypesResp?.types) ? carTypesResp!.types : []
types.value = carTypesList
for (const row of carTypesList) {
forms[row.typeName] = { ...row.style }
}
for (const k of Object.keys(forms)) {
if (!carTypesList.find((r) => r.typeName === k)) delete forms[k]
}
// 防御 2palette 可能为 null / 缺 entries 字段 / entries 非数组(用户截图正是此处崩)。
const safeEntries = Array.isArray(paletteResp?.entries) ? paletteResp!.entries : []
const safeDefaultKeys = Array.isArray(paletteResp?.defaultKeys) ? paletteResp!.defaultKeys : []
defaultKeys.value = safeDefaultKeys
paletteEntries.value = safeEntries
for (const k of Object.keys(paletteDraft)) delete paletteDraft[k]
for (const k of Object.keys(paletteSnapshot)) delete paletteSnapshot[k]
for (const e of safeEntries) {
if (!e?.key) continue
const argb = Number(e.colorArgb) >>> 0
paletteDraft[e.key] = argb
paletteSnapshot[e.key] = argb
}
if (!carTypesResp || !paletteResp) {
ElMessage.warning('车型样式 / 报警颜色加载部分失败,请确认 SimpleLite 已启动(端口 8222)。')
}
} catch (err) {
ElMessage.error(`加载车型样式失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
async function savePalette() {
if (!paletteDirty.value) return
savingPalette.value = true
try {
await reflectionApi.putAlarmColors({ ...paletteDraft })
ElMessage.success('调色板已写入并生效')
await refresh()
} catch (err) {
ElMessage.error(`保存调色板失败:${(err as Error).message}`)
} finally {
savingPalette.value = false
}
}
function resetPaletteToDefault() {
resettingPalette.value = true
const defaultArgb: Record<string, number> = {
避障: 0xffff9800,
运行: 0xff4caf50,
上线: 0xff2196f3,
下线: 0xff9e9e9e,
导航失联: 0xff9c27b0,
报警: 0xfff44336,
低电量: 0xffffc107
}
for (const k of Object.keys(paletteDraft)) delete paletteDraft[k]
for (const k of Object.keys(defaultArgb)) paletteDraft[k] = defaultArgb[k]
resettingPalette.value = false
ElMessage.info('已恢复为 7 种默认颜色,未写入。点「保存调色板」生效。')
}
async function onSaveType(row: CarStyleTypeRow) {
const body = forms[row.typeName]
if (!body) return
savingTypeName.value = row.typeName
try {
const saved = await reflectionApi.putCarStyle(row.typeName, body)
forms[row.typeName] = { ...saved }
row.hasOverride = true
ElMessage.success(`已保存 ${row.label} 车型样式`)
} catch (err) {
ElMessage.error(`保存 ${row.label} 失败:${(err as Error).message}`)
} finally {
savingTypeName.value = null
}
}
async function onResetType(row: CarStyleTypeRow) {
try {
await ElMessageBox.confirm(
`${row.label} (${row.typeName}) 的样式恢复为全局默认?删除条目后将与未覆盖车型一致。`,
'恢复默认',
{ confirmButtonText: '恢复', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
await reflectionApi.deleteCarStyle(row.typeName)
ElMessage.success(`已恢复 ${row.label} 为默认样式`)
await refresh()
} catch (err) {
ElMessage.error(`恢复默认失败:${(err as Error).message}`)
}
}
onMounted(refresh)
defineExpose({ refresh })
</script>
<style scoped>
.cse-root {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
box-sizing: border-box;
overflow: auto;
}
.cse-card :deep(.el-card__body) {
padding: 12px 16px;
}
.cse-card-header {
display: flex;
align-items: center;
gap: 10px;
}
.cse-card-title {
font-weight: 600;
}
.cse-card-spacer {
flex: 1;
}
.cse-desc {
margin: 0 0 12px 0;
color: var(--el-text-color-primary);
font-size: 13px;
line-height: 1.65;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.cse-palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.cse-palette-item {
border: 1px solid var(--el-border-color);
background: var(--el-fill-color-light);
border-radius: 6px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cse-palette-label {
display: flex;
align-items: center;
gap: 6px;
}
.cse-palette-key {
font-weight: 600;
color: var(--el-text-color-primary);
}
.cse-palette-pickers {
display: flex;
align-items: center;
gap: 8px;
}
.cse-color-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cse-types-card {
flex: 1;
}
.cse-row-title {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.cse-row-name {
font-weight: 600;
}
.cse-row-fqn {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
color: var(--el-text-color-secondary);
flex: 1;
text-align: left;
word-break: break-all;
}
.cse-btn-text {
margin-left: 4px;
}
</style>
@@ -0,0 +1,178 @@
<template>
<el-form label-width="120px" size="default" class="cse-form" :model="local">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="长度 (m)">
<el-input-number :model-value="local.bodyLengthM" :min="0.12" :max="4" :step="0.01" :precision="2" @update:model-value="(v) => update({ bodyLengthM: Number(v ?? 0) })" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="宽度 (m)">
<el-input-number :model-value="local.bodyWidthM" :min="0.08" :max="3" :step="0.01" :precision="2" @update:model-value="(v) => update({ bodyWidthM: Number(v ?? 0) })" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="显示名称">
<el-switch :model-value="local.showLabel" @update:model-value="(v: string | number | boolean) => update({ showLabel: Boolean(v) })" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="车体颜色">
<el-color-picker
:model-value="argbToHex(local.bodyColorArgb)"
show-alpha
@update:model-value="(v: string | null) => update({ bodyColorArgb: v ? hexToArgb(v) : 0 })"
/>
<code class="cse-color-code">#{{ argbToHex(local.bodyColorArgb).toUpperCase() }}</code>
<span class="cse-color-hint">无报警/上线时生效</span>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="描边颜色">
<el-color-picker
:model-value="argbToHex(local.outlineColorArgb)"
show-alpha
@update:model-value="(v: string | null) => update({ outlineColorArgb: v ? hexToArgb(v) : 0 })"
/>
<code class="cse-color-code">#{{ argbToHex(local.outlineColorArgb).toUpperCase() }}</code>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="标签颜色">
<el-color-picker
:model-value="argbToHex(local.labelColorArgb)"
show-alpha
@update:model-value="(v: string | null) => update({ labelColorArgb: v ? hexToArgb(v) : 0 })"
/>
<code class="cse-color-code">#{{ argbToHex(local.labelColorArgb).toUpperCase() }}</code>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="3D 模型路径">
<div class="cse-model-row">
<el-input
:model-value="local.modelPath"
placeholder=" ./models/agv-base.glb路径仅保存3D 替换矩形渲染由后续阶段接入"
clearable
@update:model-value="(v: string) => update({ modelPath: v ?? '' })"
>
<template #append>
<el-button @click="pickModelFile">浏览…</el-button>
</template>
</el-input>
<input
ref="fileInput"
type="file"
accept=".glb,.gltf,.obj,.fbx"
style="display:none"
@change="onFileSelected"
/>
</div>
<p class="cse-model-hint">
现版本仅记录文件路径作为元信息。3D 渲染替代矩形将在后续阶段实现(CycleGUI Workspace LoadModel)。
如需上传文件到服务器请先用其它方式将模型放到 SimpleLite 可访问的相对路径。
</p>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="$emit('save')">保存此车型</el-button>
<el-button v-if="hasOverride" type="warning" plain @click="$emit('reset')">恢复为默认</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { CarStyleDto } from '@/api/reflection'
const props = defineProps<{
typeFullName: string
modelValue: CarStyleDto
hasOverride: boolean
saving: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: CarStyleDto): void
(e: 'save'): void
(e: 'reset'): void
}>()
const local = computed<CarStyleDto>(() => props.modelValue)
function update(patch: Partial<CarStyleDto>) {
emit('update:modelValue', { ...local.value, ...patch })
}
const fileInput = ref<HTMLInputElement | null>(null)
function pickModelFile() {
fileInput.value?.click()
}
function onFileSelected(ev: Event) {
const target = ev.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
// 浏览器禁止读取真实绝对路径,这里只保留文件名作为提示,由用户改写为相对项目可访问的路径。
update({ modelPath: file.name })
target.value = ''
}
function argbToHex(argb: number): string {
const a = (argb >>> 24) & 0xff
const r = (argb >>> 16) & 0xff
const g = (argb >>> 8) & 0xff
const b = argb & 0xff
const hex = (n: number) => n.toString(16).padStart(2, '0')
return `${hex(r)}${hex(g)}${hex(b)}${hex(a)}`
}
function hexToArgb(hex: string): number {
const m = hex.replace(/^#/, '')
if (m.length === 6) {
const r = parseInt(m.slice(0, 2), 16)
const g = parseInt(m.slice(2, 4), 16)
const b = parseInt(m.slice(4, 6), 16)
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0
}
if (m.length === 8) {
const r = parseInt(m.slice(0, 2), 16)
const g = parseInt(m.slice(2, 4), 16)
const b = parseInt(m.slice(4, 6), 16)
const a = parseInt(m.slice(6, 8), 16)
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0
}
return 0
}
</script>
<style scoped>
.cse-form {
padding: 8px 4px;
}
.cse-color-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
.cse-color-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
.cse-model-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.cse-model-hint {
margin: 6px 0 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.55;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,433 @@
<template>
<div class="map-monitor-page">
<el-row :gutter="12" class="kpi-row">
<el-col :span="8">
<el-statistic title="在线车辆" :value="onlineCars">
<template #suffix><span class="muted">/ {{ cars.length }}</span></template>
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="运行中任务" :value="runningMissions" />
</el-col>
<el-col :span="8">
<el-statistic title="排队任务" :value="queuedMissions" />
</el-col>
</el-row>
<el-row :gutter="12" class="main-row">
<el-col :span="16">
<div class="canvas-with-alarms">
<Workspace3D
ref="workspaceRef"
:host="vrHost"
:scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''"
:read-only="false"
:embed-ui="true"
@pick="onPick"
@select="onSelect"
@ready="onWorkspaceReady"
/>
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
<WorkspaceCanvasToolbar />
</div>
</el-col>
<el-col :span="8" class="side-col">
<el-card shadow="never" class="side-card workbench-card">
<template #header>
<span>车辆监控台</span>
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
刷新
</el-button>
</template>
<VehicleMonitorPanel
:cars="cars"
:missions="missions"
:selected-id="selectedVehicleId"
@select="onVehicleSelect"
/>
</el-card>
<el-card shadow="never" class="side-card detail-card">
<template #header><span>选中信息</span></template>
<MonitorSelectionPanel
:selection="selection"
:refresh-key="tick"
:cars="cars"
:missions="missions"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Workspace3D from '@/components/Workspace3D.vue'
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { listCars, listMissions } from '@/api/projection'
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
import { reflectionApi } from '@/api/reflection'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const cars = ref<Car[]>([])
const missions = ref<Mission[]>([])
const selection = ref<SelectedObjectRef | null>(null)
const tick = ref(0)
const refreshing = ref(false)
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
/**
* 浮动报警:订阅后端 AlarmStreamService 推送的 `alarm` 事件,
* 维持一个滚动列表;超过 30 条丢最旧。clear 事件由 FloatingAlarmStack 自动过滤。
*
* 注意:本页只持有一个 useProjectionStream 实例,所有 SSE 事件(alarm / car-state /
* mission-status / selection-detail / ...)共用同一条 EventSource,避免历史上「自己 + useMapEditStream
* 各开一条」造成的两路 SSE 占用。
*/
const alarms = ref<AlarmEvent[]>([])
async function onAlarmLocate(a: AlarmEvent) {
// 通过 reflectionApi.setSelection 把 SimpleLite 3D 场景的高亮切到该车,
// CycleGUI 默认会把相机聚焦到 GetPosition 命中目标——视觉上等同于 flyTo。
// a.carId 已经是 numberAlarmEvent 类型保证),无须额外字符串处理;如果将来后端
// 改成字符串,这里 Number(...) 会返回 NaN 并由下面的 isFinite 拦下来。
const numId = Number(a.carId)
if (!Number.isFinite(numId)) {
ElMessage.warning(`报警车辆 id 无法解析:${a.carId}`)
return
}
try {
await reflectionApi.setSelection('car', numId)
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
} catch (err) {
ElMessage.error(`定位失败:${(err as Error).message}`)
}
}
function onAlarmAck(_a: AlarmEvent) {
// 当前后端无 ACK 接口,前端本地标记即可(FloatingAlarmStack 已处理)
}
let pollTimer: ReturnType<typeof setInterval> | null = null
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length)
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length)
const selectedVehicleId = computed(() => {
if (!selection.value || selection.value.kind !== 'vehicle') return null
const sel = selection.value
const match = cars.value.find((c) => c.id === sel.id || detailIdForCar(c) === sel.id)
return match?.id ?? null
})
function onPick(_p: { x: number; y: number }) { /* 坐标由 3D 选中对象驱动 */ }
// SimpleLite 3D 端发送的对象命名规则(见 SimpleUI.cs / UISite.cs / TrackUiHelper):
// - 车辆:`Car-{id}` 或 `UICar-{id}`(不同入口不同前缀,正则同时兼容)
// - 站点:`UISite-{id}`
// - 路径:`UITrack-{id}` / `UICircularArcTrack-{id}` / `UIBezierTrack-{id}` / `UINurbsTrack-{id}`
const RX_CAR = /^(?:UI)?(?:Car|Vehicle)-(\d+)$/i
const RX_SITE = /^UISite-(\d+)$/i
const RX_TRACK = /^(?:UITrack|UICircularArcTrack|UIBezierTrack|UINurbsTrack)-(\d+)$/i
function parseSceneObjectName(name: string): { kind: 'vehicle' | 'site' | 'track'; id: string } | null {
const car = RX_CAR.exec(name)
if (car) return { kind: 'vehicle', id: car[1]! }
const site = RX_SITE.exec(name)
if (site) return { kind: 'site', id: site[1]! }
const track = RX_TRACK.exec(name)
if (track) return { kind: 'track', id: track[1]! }
// 与地图编辑页一致:贪婪吃掉 UI 前缀字母段,兼容 UIBezierTrack-3 等
const m = /^UI([A-Za-z]+)-(\d+)$/.exec(name) ?? /^([A-Za-z]+)-(\d+)$/.exec(name)
if (!m) return null
const tn = m[1]!.toLowerCase()
const id = m[2]!
if (tn === 'site') return { kind: 'site', id }
if (tn === 'car' || tn === 'vehicle') return { kind: 'vehicle', id }
if (tn.includes('track')) return { kind: 'track', id }
return null
}
function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null): boolean {
if (!a && !b) return true
if (!a || !b) return false
return a.kind === b.kind && a.id === b.id
}
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
let next: SelectedObjectRef
if (parsed.kind === 'vehicle') {
const car = (rawName ? findSelectedCar(rawName) : undefined)
?? cars.value.find((c) => detailIdForCar(c) === parsed.id)
next = car
? { kind: 'vehicle', id: detailIdForCar(car), name: car.name }
: { kind: 'vehicle', id: parsed.id, name: `Vehicle ${parsed.id}` }
} else {
next = {
kind: parsed.kind,
id: parsed.id,
name: rawName || `${parsed.kind === 'site' ? 'Site' : 'Track'} ${parsed.id}`
}
}
if (sameSelection(selection.value, next)) return
selection.value = next
tick.value++
}
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
if (kind === 'car') {
const sid = String(id)
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
const car = findSelectedCar(rawName) ?? cars.value.find((c) => detailIdForCar(c) === sid || c.id === sid)
const next = car
? { kind: 'vehicle' as const, id: detailIdForCar(car), name: car.name }
: { kind: 'vehicle' as const, id: sid, name: `Vehicle ${sid}` }
if (!sameSelection(selection.value, next)) {
selection.value = next
tick.value++
}
return
}
const sid = String(id)
const rawName = names?.find((n) => {
const p = parseSceneObjectName(n)
return p?.kind === kind && p.id === sid
})
const next = {
kind,
id: sid,
name: rawName || `${kind === 'site' ? 'Site' : 'Track'} ${sid}`
}
if (!sameSelection(selection.value, next)) {
selection.value = next
tick.value++
}
}
/**
* SimpleLite 画布点选后通过 SSE `selection-detail` 广播(见 SimpleUI.BroadcastSelectionToWeb)。
* embed 模式通常不会 postMessage `workspace.select`,因此必须在此同步侧栏。
*/
function onSelectionDetailFromStream(e: SelectionDetailEvent) {
const siteN = e.siteIds?.length ?? 0
const trackN = e.trackIds?.length ?? 0
const carN = e.carIds?.length ?? 0
const nameN = Array.isArray(e.names) ? e.names.length : 0
if (siteN + trackN + carN + nameN === 0) {
selection.value = null
tick.value++
return
}
if (Array.isArray(e.names)) {
for (const n of e.names) {
const parsed = parseSceneObjectName(n)
if (parsed) {
applyParsedSelection(parsed, n)
return
}
}
}
if (e.kind && e.id) {
applySelectionFromKindId(e.kind, e.id, e.names)
return
}
if (siteN > 0) {
applySelectionFromKindId('site', e.siteIds![0]!, e.names)
return
}
if (trackN > 0) {
applySelectionFromKindId('track', e.trackIds![0]!, e.names)
return
}
if (carN > 0) {
applySelectionFromKindId('car', e.carIds![0]!, e.names)
}
}
function onSelect(names: string[]) {
if (!names.length) {
// embed 画布可能不发 select 或发空数组;勿清空,改读后端当前选中。
void fallbackSelectionFromBackend()
return
}
for (const n of names) {
const parsed = parseSceneObjectName(n)
if (!parsed) continue
applyParsedSelection(parsed, n)
return
}
void fallbackSelectionFromBackend()
}
function onWorkspaceReady() {
void fallbackSelectionFromBackend()
}
async function fallbackSelectionFromBackend() {
try {
const sel = await reflectionApi.getSelection()
if (!sel?.kind || sel.id == null) return
if (sel.kind !== 'car' && sel.kind !== 'site' && sel.kind !== 'track') return
applySelectionFromKindId(sel.kind, Number(sel.id), sel.name ? [sel.name] : undefined)
} catch {
// 仅作为命名兼容兜底,失败时保持当前 selection,不打断主流程。
}
}
async function onVehicleSelect(ref: SelectedObjectRef) {
if (!sameSelection(selection.value, ref)) {
selection.value = ref
tick.value++
}
const id = Number(ref.id)
if (!Number.isFinite(id)) return
try {
await reflectionApi.setSelection('car', id)
} catch (err) {
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
}
}
function findSelectedCar(name: string): Car | undefined {
const parsedId = parseWorkspaceCarId(name)
return cars.value.find((c) =>
c.name === name ||
c.id === name ||
(parsedId != null && detailIdForCar(c) === parsedId)
)
}
function parseWorkspaceCarId(name: string): string | null {
const m = /^(?:UI)?(?:Car|Vehicle)-(\d+)$/i.exec(name)
return m?.[1] ?? null
}
function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId)
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
}
async function refreshAll() {
refreshing.value = true
try {
cars.value = await listCars()
missions.value = await listMissions()
} catch (err) {
console.warn('[MapMonitor] refreshAll failed', err)
} finally {
tick.value++
refreshing.value = false
}
}
// SSE 实时通道:本页只起一条流,所有事件都从这里分发。
//
// 刷新策略:
// - car-state / mission-status:后端真正的"车辆/任务状态变更"事件 → 立刻 refreshAll(目前后端
// 还没真正实现这两条,但为未来事件型推送预留)。
// - alarm:直接更新 alarms 列表,不需要 refreshAll。
// - snapshot-tick:后端 1Hz 心跳,仅含 cars/missions/sites/tracks 数字,不带实际状态。**不要**
// 在这里触发 refreshAll,否则会等价"1s 全量轮询 listCars+listMissions",比原来的 3s 轮询还
// 重。snapshot-tick 仅用于 `stream.connected` 心跳判定。
// - selection-detail:画布点选后 SimpleLite 广播,驱动右侧「选中信息」面板(embed 模式主路径)。
// - 其它 object-* / pick-result / project-*:本页不处理,由地图编辑页订阅。
//
// 实时通道未连接(旧 SimpleLite / 离线)时退回 3s 轮询保底。
const stream = useProjectionStream({ autoConnect: true })
function onStreamEvent(e: StreamEvent) {
if (!e?.kind) return
switch (e.kind) {
case 'alarm': {
const a = e.payload as AlarmEvent | undefined
if (!a) return
// clear 事件 carId 同样有,但 info 为空;FloatingAlarmStack 内部按 info 是否为空过滤。
alarms.value = [a, ...alarms.value.filter((x) => x.carId !== a.carId)].slice(0, 30)
break
}
case 'car-state':
case 'mission-status':
void refreshAll()
break
case 'selection-detail':
if (e.payload && typeof e.payload === 'object') {
onSelectionDetailFromStream(e.payload as SelectionDetailEvent)
}
break
case 'monitor-config-updated':
// 运营维护保存后 SimpleLite 广播,刷新选中面板动作列表
tick.value++
break
// 'snapshot-tick' 故意不触发 refreshAll —— 见上面注释。
}
}
stream.on(onStreamEvent)
onMounted(async () => {
await refreshAll()
pollTimer = setInterval(() => {
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
if (!stream.connected.value || cars.value.length === 0) void refreshAll()
}, 3000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
stream.off(onStreamEvent)
})
</script>
<style scoped>
.map-monitor-page {
display: flex;
flex-direction: column;
gap: 14px;
height: calc(100vh - 56px - 36px - 32px);
}
.kpi-row { flex: none; }
.main-row { flex: 1; min-height: 0; }
.main-row > .el-col { display: flex; flex-direction: column; min-height: 0; }
.side-col { gap: 12px; }
.side-card {
display: flex;
flex-direction: column;
min-height: 0;
}
.workbench-card { flex: 1.2; }
.detail-card { flex: 1; }
.workbench-card :deep(.el-card__body),
.detail-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.muted { color: rgba(255, 255, 255, 0.78); font-size: 12px; }
.workbench-card :deep(.el-card__header),
.detail-card :deep(.el-card__header) {
color: #fff;
}
:deep(.el-row.main-row) { height: 100%; }
:deep(.el-col-16 > div) { height: 100%; min-height: 0; }
.canvas-with-alarms { position: relative; height: 100%; min-height: 0; flex: 1; display: flex; flex-direction: column; }
.canvas-with-alarms :deep(.workspace-3d-wrap) { flex: 1; }
</style>
@@ -0,0 +1,31 @@
<template>
<div class="mission-page">
<ReflectionManagerPanel
kind="process"
kind-label="任务"
title="任务编排(Mission / 进程,含插件 MissionType 实例化)"
empty-text="当前没有任务点击右上角新建任务从已加载的 MissionType 中选一个实例化"
/>
</div>
</template>
<script setup lang="ts">
/**
* /admin/missions 路由的页面入口。
*
* 由于「任务」与「进程」在后端 ReflectionApiController 共用 kind=process / mission
* UiDiscoveryCache.MissionCreatableTypes 同源),这里直接用 ReflectionManagerPanel
* 接入,按下「新建任务」会列出所有 MissionType 子类(含 taskflow / trigger)。
*
* 旧版本的 DataTablePro + listMissions mock 已经下线,所有数据走 SimpleLite 反射 API。
*/
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
</script>
<style scoped>
.mission-page {
height: calc(100vh - 56px);
padding: 12px 14px;
box-sizing: border-box;
}
</style>
@@ -0,0 +1,67 @@
<template>
<div class="orchestration-page">
<el-tabs v-model="tab" class="orch-tabs" type="border-card" @tab-change="onTabChange">
<el-tab-pane label="车辆" name="cars">
<CarPanelView v-if="tab === 'cars'" />
</el-tab-pane>
<el-tab-pane label="进程" name="process">
<MissionEditorView v-if="tab === 'process'" />
</el-tab-pane>
<el-tab-pane label="脚本" name="script">
<ReflectionKindTable
v-if="tab === 'script'"
kind="script"
empty-text="脚本继承 CarProgram 的子类型未发现可暴露条目"
/>
</el-tab-pane>
<el-tab-pane label="任务流" name="taskflow">
<ReflectionKindTable
v-if="tab === 'taskflow'"
kind="mission"
extra-filter="taskflow"
empty-text="未发现任务流条目"
/>
</el-tab-pane>
<el-tab-pane label="触发器" name="trigger">
<ReflectionKindTable
v-if="tab === 'trigger'"
kind="mission"
extra-filter="trigger"
empty-text="未发现触发器条目"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import CarPanelView from '@/views/admin/CarPanelView.vue'
import MissionEditorView from '@/views/admin/MissionEditorView.vue'
import ReflectionKindTable from '@/components/orchestration/ReflectionKindTable.vue'
const tab = ref<'cars' | 'process' | 'script' | 'taskflow' | 'trigger'>('cars')
function onTabChange(name: string | number) {
tab.value = name as typeof tab.value
}
onMounted(() => {
const cached = sessionStorage.getItem('orchestration.tab')
if (cached) tab.value = cached as typeof tab.value
})
</script>
<style scoped>
.orchestration-page {
height: calc(100vh - 56px);
padding: 12px;
}
.orch-tabs {
height: 100%;
}
.orch-tabs :deep(.el-tabs__content) {
height: calc(100% - 40px);
overflow: auto;
}
</style>
@@ -0,0 +1,54 @@
<template>
<el-card shadow="never">
<template #header><span>调度回放PlaybackPolicy.retentionDays={{ retention }} </span></template>
<el-row :gutter="12">
<el-col :span="6">
<el-input v-model="filter.kw" placeholder="按任务 / 车辆检索" clearable />
</el-col>
<el-col :span="8">
<el-date-picker
v-model="filter.range"
type="datetimerange"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 100%" />
</el-col>
<el-col :span="10">
<el-button type="primary">检索快照</el-button>
<el-button>下载日志</el-button>
<el-button :icon="VideoPlay" plain>回放选中</el-button>
</el-col>
</el-row>
<el-divider />
<el-table :data="rows" stripe>
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="ts" label="时间" width="180" />
<el-table-column prop="type" label="类型" width="140" />
<el-table-column prop="summary" label="概要" />
<el-table-column prop="size" label="大小" width="100" />
<el-table-column label="操作" width="160">
<template #default>
<el-button text size="small">回放</el-button>
<el-button text size="small">下载</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
const retention = 30
const filter = reactive<{ kw: string; range: [Date, Date] | null }>({ kw: '', range: null })
const rows = ref([
{ id: 'SNAP-001', ts: '2026-05-19 10:21:33', type: '调度异常', summary: 'AGV-005 上线超时 → 故障', size: '128 KB' },
{ id: 'SNAP-002', ts: '2026-05-19 14:05:17', type: '路口死锁', summary: '路口-N 等待链 3 节点 30s', size: '64 KB' },
{ id: 'SNAP-003', ts: '2026-05-20 09:11:02', type: '手动快照', summary: '用户 admin 触发 SnapshotExport', size: '420 KB' }
])
</script>
@@ -0,0 +1,22 @@
<template>
<div class="process-page">
<ReflectionManagerPanel
kind="process"
kind-label="进程"
title="进程管理(Mission / Process"
empty-text="当前没有进程点击右上角新建进程即可基于已加载的 MissionType 实例化一个"
/>
</div>
</template>
<script setup lang="ts">
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
</script>
<style scoped>
.process-page {
height: calc(100vh - 56px);
padding: 12px 14px;
box-sizing: border-box;
}
</style>
@@ -0,0 +1,104 @@
<template>
<div class="pp-page">
<el-tabs v-model="activeTab" type="border-card" class="pp-tabs admin-tabs" @tab-change="onTabChange">
<el-tab-pane label="项目属性 (Scene.conf)" name="project">
<FieldMemberEditor
v-if="activeTab === 'project'"
ref="projectEditor"
target-label="项目属性"
description="对应 SimpleLite 桌面端主菜单「项目属性…」。编辑字段后点「保存」即可一次完成内存生效 + 写回项目 JSON 文件(含全部场景/车辆/路径/属性)。"
:loader="loadProject"
:on-set="setProjectField"
:on-save="saveProject"
/>
</el-tab-pane>
<el-tab-pane label="核心配置 (simple.json)" name="app-config">
<FieldMemberEditor
v-if="activeTab === 'app-config'"
ref="appConfigEditor"
target-label="核心配置"
description="对应 SimpleLite 桌面端主菜单「核心配置 → 编辑配置…」。修改后点「保存」即可一次完成内存生效 + 写入可执行目录下的 simple.json。"
:loader="loadAppConfig"
:on-set="setAppConfigField"
:on-save="saveAppConfig"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FieldMemberEditor, { type FieldMemberEditorLoadResult } from '@/components/orchestration/FieldMemberEditor.vue'
import { reflectionApi } from '@/api/reflection'
type TabKey = 'project' | 'app-config'
const activeTab = ref<TabKey>('project')
const projectEditor = ref<InstanceType<typeof FieldMemberEditor> | null>(null)
const appConfigEditor = ref<InstanceType<typeof FieldMemberEditor> | null>(null)
function onTabChange(name: string | number) {
activeTab.value = name as TabKey
}
async function loadProject(): Promise<FieldMemberEditorLoadResult> {
const r = await reflectionApi.getProjectFields()
return {
fields: r.fields,
pathInfos: [
{ label: '最近加载/保存的项目 JSON', value: r.lastLoadedPath, emptyHint: '尚未通过 SimpleLite 加载或保存过项目' },
{ label: 'simple.json autoload', value: r.autoloadPath, emptyHint: '未配置' }
],
saveButtonLabel: '保存(写回项目 JSON'
}
}
function setProjectField(field: string, value: string) {
return reflectionApi.setProjectField(field, value)
}
async function saveProject() {
const r = await reflectionApi.saveProject()
return { path: r.path }
}
async function loadAppConfig(): Promise<FieldMemberEditorLoadResult> {
const r = await reflectionApi.getAppConfigFields()
return {
fields: r.fields,
pathInfos: [{ label: '保存目标', value: r.savePath, emptyHint: 'simple.json' }],
saveButtonLabel: '保存(写入 simple.json'
}
}
function setAppConfigField(field: string, value: string) {
return reflectionApi.setAppConfigField(field, value)
}
async function saveAppConfig() {
const r = await reflectionApi.saveAppConfig()
return { path: r.path }
}
</script>
<style scoped>
.pp-page {
height: calc(100vh - 56px);
padding: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.pp-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pp-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
padding: 0;
}
.pp-tabs :deep(.el-tab-pane) {
height: 100%;
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="scene-mgr-page">
<el-tabs v-model="activeTab" type="border-card" class="scene-tabs admin-tabs" @tab-change="onTabChange">
<el-tab-pane label="站点" name="site">
<ReflectionManagerPanel
ref="sitePanelRef"
kind="site"
kind-label="站点"
title="站点管理(Site / UISite,含禁用/启用、必空点等动作)"
empty-text="当前没有站点点击右上角新建站点 x/y 坐标添加"
/>
</el-tab-pane>
<el-tab-pane label="路径" name="track">
<ReflectionManagerPanel
ref="trackPanelRef"
kind="track"
kind-label="路径"
title="路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)"
empty-text="当前没有路径点击右上角新建路径选起止站点添加"
/>
</el-tab-pane>
<el-tab-pane label="装饰物" name="special">
<ReflectionManagerPanel
ref="specialPanelRef"
kind="special"
kind-label="装饰物"
title="装饰物管理(UI_Image / UI_Text / UI_Model"
empty-text="当前没有装饰物新建图片/文本/模型可用底部按钮或先在地图编辑里上传资产"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
/**
* 场景管理:用 3 个 ReflectionManagerPanel 实例承载 site/track/special 三个 subKind。
*
* 这里之所以用三个独立 panel 而不是单一 panel + 顶部切换:
* - 每个 subKind 列字段、可创建子类型完全不同(site 要 x/y、track 要 siteA/siteB、
* 装饰物要 imagePath/modelPath 等),共享一组 ref 会让创建表单状态错乱;
* - ReflectionManagerPanel 已经把 list/详情/方法/字段/SSE 全部包好,三份实例几乎零边际成本。
*
* SceneManagerView 自己只负责 tab 切换 + 默认走 site tab。
*/
import { ref } from 'vue'
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
const activeTab = ref<'site' | 'track' | 'special'>('site')
const sitePanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
const specialPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
function onTabChange(name: string | number) {
// 切到对应 tab 时主动 refresh 一次,确保 SSE 没接的情况下也能拿到最新列表。
const target =
name === 'site' ? sitePanelRef.value :
name === 'track' ? trackPanelRef.value :
name === 'special' ? specialPanelRef.value : null
target?.refresh?.()
}
</script>
<style scoped>
/* 会话 N+2tabs 通用样式提到全局 `.admin-tabs`(见 styles/theme.css),
这里只保留容器布局,避免 scoped 选择器把全局规则压住。 */
.scene-mgr-page {
height: calc(100vh - 56px);
padding: 12px 14px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.scene-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -0,0 +1,37 @@
<template>
<div class="script-page">
<!--
平台脚本管理= SimpleLite 工作台脚本ComposerDockPanel.RenderScriptTable Web 镜像
- 列表 = CarProgram.GetPrograms()最多 300 含历史 Mission 调度运行时自动产生与回收
- ID / 名称(任务名) / 类型(CarProgram 子类) / 车辆(plans[0].usingCar) / 状态(ProgramStatus.state)
- 不渲染新建按钮CarProgram 无法手动 new要新增脚本应去任务编排页建 Mission
- 不渲染删除按钮CarProgram 由调度器在环形队列里维护删除一律会被后端拒
右侧详情面板沿用通用 ReflectionManagerPanel 渲染fields 多为 typed/locked只读 status 通过反射
-->
<ReflectionManagerPanel
kind="script"
kind-label="脚本"
title="脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
empty-text="当前无 CarProgram 实例CarProgram Mission 运行时编译用户脚本后产生的对象要创建脚本请到任务编排页新建 Mission"
disable-create
disable-delete
show-summary-column
summary-label="车辆"
show-status-column
status-label="状态"
show-script-actions
/>
</div>
</template>
<script setup lang="ts">
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
</script>
<style scoped>
.script-page {
height: calc(100vh - 56px);
padding: 12px 14px;
box-sizing: border-box;
}
</style>
@@ -0,0 +1,13 @@
<template>
<!--
/admin/tracks 这条路由的页面入口
历史上只展示 Track mock 列表现在升级为"场景管理"
通过 SceneManagerView 同时管理 站点 / 路径 / 装饰物 三类 subKind
路由路径仍保留 /admin/tracks 以保护书签 / 导航键值
-->
<SceneManagerView />
</template>
<script setup lang="ts">
import SceneManagerView from './SceneManagerView.vue'
</script>
@@ -0,0 +1,57 @@
<template>
<ConfigPageBase
section="auth"
title="权限与角色管理"
description="角色、权限码、控件级 WidgetGrantauth.user.write / auth.role.write"
:defaults="DEFAULT_AUTH">
<template #default="{ payload }">
<el-tabs model-value="roles">
<el-tab-pane name="roles" label="角色">
<el-table :data="payload.roles" size="small" border>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="名称" prop="name" width="120" />
<el-table-column label="Scope" prop="scope" width="120" />
<el-table-column label="权限码">
<template #default="s">
<el-tag v-for="p in s.row.permissions" :key="p" size="small" effect="plain" style="margin: 2px">{{ p }}</el-tag>
</template>
</el-table-column>
<el-table-column label="WidgetGrant" width="280">
<template #default="s">
<el-tag v-for="g in s.row.widgetGrants" :key="g.widgetId" size="small" :type="grantTag(g.visibility)" effect="plain" style="margin: 2px">{{ g.widgetId }}={{ g.visibility }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="users" label="用户">
<el-table :data="payload.users" size="small" border>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="用户名" prop="username" width="140" />
<el-table-column label="角色">
<template #default="s">
<el-tag v-for="r in s.row.roles" :key="r" size="small">{{ r }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="s"><el-switch v-model="s.row.enabled" /></template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_AUTH } from '@/mock/data/configs'
function grantTag(v: string) {
switch (v) {
case 'hidden': return 'danger'
case 'readonly': return 'warning'
case 'interactive': return 'success'
default: return 'info'
}
}
</script>
@@ -0,0 +1,30 @@
<template>
<ConfigPageBase
section="charge"
title="充电逻辑"
description="充电优先级、空闲充电、任务中断充电(ChargePolicy"
:defaults="DEFAULT_CHARGE">
<template #default="{ payload, update }">
<el-form label-width="180px" :model="payload">
<el-form-item label="允许任务中断充电">
<el-switch :model-value="payload.allowMidTaskCharge" @update:model-value="(v: string | number | boolean) => update({ ...payload, allowMidTaskCharge: Boolean(v) })" />
</el-form-item>
<el-form-item label="空闲多少秒后充电">
<el-input-number :model-value="payload.idleChargeAfterSec" :min="0" :max="86400" @update:model-value="(v: number | undefined) => update({ ...payload, idleChargeAfterSec: v ?? 0 })" />
</el-form-item>
<el-form-item label="充电优先级规则">
<el-table :data="payload.priority" size="small">
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="条件" prop="condition" />
<el-table-column label="权重" prop="weight" width="100" />
</el-table>
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_CHARGE } from '@/mock/data/configs'
</script>
@@ -0,0 +1,38 @@
<template>
<ConfigPageBase
section="widget"
title="自定义控件"
description="呼叫/展示界面可自由定义(platform-vue + rcsmonitor-vue 双渲染器)"
:defaults="DEFAULT_WIDGETS">
<template #default="{ payload }">
<el-table :data="payload.items" size="small" border>
<el-table-column label="ID" prop="id" width="180" />
<el-table-column label="名称" prop="name" width="140" />
<el-table-column label="Schema" prop="schemaJson">
<template #default="s">
<pre class="js-mini">{{ s.row.schemaJson }}</pre>
</template>
</el-table-column>
<el-table-column label="Layout" prop="layoutJson">
<template #default="s">
<pre class="js-mini">{{ s.row.layoutJson }}</pre>
</template>
</el-table-column>
<el-table-column label="绑定 Scope">
<template #default="s">
<el-tag v-for="x in s.row.bindToScopes" :key="x" size="small" effect="plain" style="margin-right: 4px">{{ x }}</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_WIDGETS } from '@/mock/data/configs'
</script>
<style scoped>
.js-mini { margin: 0; font-size: 11px; background: #f5f7fa; padding: 4px 6px; border-radius: 4px; max-height: 60px; overflow: auto; }
</style>
@@ -0,0 +1,45 @@
<template>
<ConfigPageBase
section="device"
title="第三方设备统一接入"
description="电梯/卷帘门/安全门/充电桩/AP/交换机/摄像头/读码器/PLCDeviceManagementConfig"
:defaults="DEFAULT_DEVICE">
<template #default="{ payload }">
<el-tabs model-value="drivers">
<el-tab-pane name="drivers" label="驱动绑定">
<el-table :data="payload.drivers" size="small" border>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="设备类型" prop="deviceType" width="120" />
<el-table-column label="驱动名" prop="driverName" />
<el-table-column label="版本" prop="version" width="100" />
</el-table>
</el-tab-pane>
<el-tab-pane name="devices" label="设备实例">
<el-table :data="payload.devices" size="small" border>
<el-table-column label="ID" prop="id" width="120" />
<el-table-column label="名称" prop="name" width="140" />
<el-table-column label="类型" prop="deviceType" width="100" />
<el-table-column label="协议" prop="protocol" width="120" />
<el-table-column label="地址" prop="address" />
<el-table-column label="启用" width="80">
<template #default="s"><el-switch v-model="s.row.enabled" /></template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="policy" label="健康 / 告警">
<el-descriptions :column="2" border>
<el-descriptions-item label="心跳周期 (s)">{{ payload.healthPolicy.heartbeatSec }}</el-descriptions-item>
<el-descriptions-item label="离线判定 (s)">{{ payload.healthPolicy.offlineSec }}</el-descriptions-item>
<el-descriptions-item label="告警启用">{{ payload.alarmPolicy.enabled ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="规则数">{{ payload.alarmPolicy.rules.length }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_DEVICE } from '@/mock/data/configs'
</script>
@@ -0,0 +1,62 @@
<template>
<ConfigPageBase
section="integrations"
title="外部系统对接"
description="MES / WMS / RCS 等标准接口配置(ExternalIntegrations"
:defaults="DEFAULT_INTEGRATIONS">
<template #default="{ payload, update }">
<el-tabs model-value="mes">
<el-tab-pane v-for="kind in (['mes','wms','rcs'] as const)" :key="kind" :name="kind" :label="kind.toUpperCase()">
<el-table :data="payload[kind]" size="small" border>
<el-table-column label="ID" width="120">
<template #default="s">
<el-input v-model="s.row.id" />
</template>
</el-table-column>
<el-table-column label="名称" min-width="160">
<template #default="s"><el-input v-model="s.row.name" /></template>
</el-table-column>
<el-table-column label="URL" min-width="260">
<template #default="s"><el-input v-model="s.row.url" /></template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="s"><el-switch v-model="s.row.enabled" /></template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="s">
<el-button text type="danger" size="small" @click="remove(payload, update, kind, s.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" :icon="Plus" style="margin-top: 8px" @click="add(payload, update, kind)">新增 {{ kind.toUpperCase() }} 端点</el-button>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_INTEGRATIONS } from '@/mock/data/configs'
import type { ExternalIntegrations } from '@/types/config'
type Kind = 'mes' | 'wms' | 'rcs'
function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind) {
const id = `${kind}-${Date.now()}`
const next: ExternalIntegrations = {
...payload,
[kind]: [...payload[kind], { id, name: '新端点', url: 'http://', enabled: false }]
}
update(next)
}
function remove(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind, idx: number) {
const next: ExternalIntegrations = {
...payload,
[kind]: payload[kind].filter((_, i) => i !== idx)
}
update(next)
}
</script>
@@ -0,0 +1,49 @@
<template>
<ConfigPageBase
section="fleet"
title="车队全生命周期"
description="分区域/跨楼层/多车协作;OTA、批量操作、网络诊断(FleetLifecycleConfig"
:defaults="DEFAULT_FLEET">
<template #default="{ payload }">
<el-tabs model-value="groups">
<el-tab-pane name="groups" label="车队分组">
<el-table :data="payload.groups" size="small" border>
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="名称" prop="name" width="140" />
<el-table-column label="楼层" prop="floor" width="80" />
<el-table-column label="区域" prop="region" width="80" />
<el-table-column label="车辆">
<template #default="s">
<el-tag v-for="c in s.row.carIds" :key="c" size="small" style="margin: 2px">{{ c }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="ota" label="OTA 升级">
<el-descriptions :column="2" border>
<el-descriptions-item label="启用">{{ payload.ota.enabled ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="批次大小">{{ payload.ota.batchSize }}</el-descriptions-item>
<el-descriptions-item label="失败回滚">{{ payload.ota.rollbackOnFail ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane name="batch" label="批量操作">
<el-descriptions :column="2" border>
<el-descriptions-item label="需确认">{{ payload.batchOps.confirmationRequired ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="最大批量">{{ payload.batchOps.maxBatch }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane name="diag" label="网络诊断">
<el-descriptions :column="2" border>
<el-descriptions-item label="RTT 阈值 (ms)">{{ payload.networkDiag.rttThresholdMs }}</el-descriptions-item>
<el-descriptions-item label="丢包阈值">{{ payload.networkDiag.packetLossThreshold }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_FLEET } from '@/mock/data/configs'
</script>
@@ -0,0 +1,39 @@
<template>
<ConfigPageBase
section="location"
title="库位管理"
description="出入库、库存、库位可视化(LocationManagement"
:defaults="DEFAULT_LOCATION">
<template #default="{ payload }">
<el-tabs model-value="locs">
<el-tab-pane name="locs" label="库位">
<el-table :data="payload.locations" size="small" border>
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="编码" prop="code" width="120" />
<el-table-column label="名称" prop="name" />
<el-table-column label="站点" prop="siteId" width="100" />
<el-table-column label="容量" prop="capacity" width="100" />
<el-table-column label="占用">
<template #default="s">
<el-progress :percentage="Math.round((s.row.occupied / s.row.capacity) * 100)" :stroke-width="10" />
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="rules" label="库存规则">
<el-table :data="payload.inventoryRules" size="small" border>
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="物料类型" prop="itemType" />
<el-table-column label="下限" prop="minQty" width="100" />
<el-table-column label="上限" prop="maxQty" width="100" />
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_LOCATION } from '@/mock/data/configs'
</script>
@@ -0,0 +1,169 @@
<template>
<PermissionGuard widget-id="ConfigCenter">
<el-card shadow="never" class="mmc-card">
<template #header>
<div class="mmc-header">
<span class="mmc-title">地图监控配置</span>
<el-tag size="small" effect="plain">section=map-monitor</el-tag>
<el-tag v-if="dirty" size="small" type="warning" effect="dark">未保存</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" :disabled="!dirty" @click="save">
保存
</el-button>
</div>
</template>
<p class="mmc-desc">
勾选地图监控右侧选中信息面板要展示的内容白名单按对象类型 - 车辆 / 站点 / 路径 分组
三组分别控制 <b>属性</b><b>状态</b><b>动作</b> Tab 里可见的字段 / 状态项 / 可执行方法
每组留空 = 显示全部勾选任何一项即进入白名单模式仅显示已勾选保存后直接写入 SimpleLite
<code>simple.json</code> <code>Configuration.conf.monitorVisibility</code>
</p>
<el-tabs v-model="activeKind" class="mmc-tabs">
<el-tab-pane v-for="k in KINDS" :key="k.value" :label="k.label" :name="k.value">
<div v-loading="loading" class="mmc-tab-body">
<MapMonitorConfigGroup
title="属性字段(Properties"
:available="available[k.value].fields"
:selected="config[k.value].fields"
empty-tip="该类型暂未发现任何 [FieldMember] SimpleLite 端添加字段后点重载会刷新"
@update:selected="(v: string[]) => onUpdate(k.value, 'fields', v)" />
<MapMonitorConfigGroup
title="状态项(Status"
:available="available[k.value].status"
:selected="config[k.value].status"
empty-tip="该类型暂未发现任何 status 字段来自 obj.status 反射"
@update:selected="(v: string[]) => onUpdate(k.value, 'status', v)" />
<MapMonitorConfigGroup
title="可执行动作(Methods"
:available="available[k.value].methods"
:selected="config[k.value].methods"
empty-tip="该类型暂未发现任何 [MethodMember] 方法"
@update:selected="(v: string[]) => onUpdate(k.value, 'methods', v)" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Refresh, Check } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
import MapMonitorConfigGroup from '@/components/config/MapMonitorConfigGroup.vue'
import {
reflectionApi,
emptyMonitorVisibilityMap,
emptyMonitorAvailableMap,
type MonitorVisibilityKind,
type MonitorVisibilityMap
} from '@/api/reflection'
// 三种 kind 在这一页是写死的:地图监控当前只暴露车辆 / 站点 / 路径(Configuration.conf.monitorVisibility
// 也是这三组)。新增 kind(比如 special / image)需要同步:后端 MonitorVisibilityConfig 加字段、
// ReflectionApiController.BuildAvailableForKind 加 case、这里加一行。
const KINDS: ReadonlyArray<{ value: MonitorVisibilityKind; label: string }> = [
{ value: 'car', label: '车辆 (Car)' },
{ value: 'site', label: '站点 (Site)' },
{ value: 'track', label: '路径 (Track)' }
]
const activeKind = ref<MonitorVisibilityKind>('car')
const loading = ref(false)
const saving = ref(false)
// reactive 而非 ref:双层嵌套结构 + 内部数组要被子组件直接 set,用 reactive 让 Vue 能跟踪到
// `config.car.fields = [...]` 这类替换写法。每次重载用 Object.assign 替换字段,而不是替换整棵
// 树,避免和子组件已经绑定的引用脱钩。
const config = reactive<MonitorVisibilityMap>(emptyMonitorVisibilityMap())
const available = reactive<MonitorVisibilityMap>(emptyMonitorAvailableMap())
// 上一次保存(或最近一次 reload)的 config 快照,用来判断 dirty。stringify 比较够用——
// 字段全是 string[]stringify 在百级规模性能不是瓶颈。
const baseline = ref<string>(JSON.stringify(emptyMonitorVisibilityMap()))
const dirty = computed(() => baseline.value !== JSON.stringify(config))
function applyMap(target: MonitorVisibilityMap, src: MonitorVisibilityMap) {
for (const k of KINDS) {
target[k.value].fields = [...(src[k.value]?.fields ?? [])]
target[k.value].status = [...(src[k.value]?.status ?? [])]
target[k.value].methods = [...(src[k.value]?.methods ?? [])]
}
}
async function reload(force = false) {
loading.value = true
try {
const payload = await reflectionApi.getMonitorConfig()
applyMap(config, payload.config)
applyMap(available, payload.available)
baseline.value = JSON.stringify(config)
if (force) ElMessage.success('已重载')
} catch (e) {
ElMessage.error(`加载失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const saved = await reflectionApi.saveMonitorConfig({
car: { ...config.car },
site: { ...config.site },
track: { ...config.track }
})
// 后端会清洗(去重 / 去空 / 截断超长 key),可能与前端发的不完全相等 —— 以服务端返回为准。
applyMap(config, saved)
baseline.value = JSON.stringify(config)
ElMessage.success('已保存(已写入 simple.json')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
function onUpdate(kind: MonitorVisibilityKind, group: 'fields' | 'status' | 'methods', next: string[]) {
// 创建新数组而非 splice:让 Vue 的依赖追踪稳定捕获到 set;同时让 dirty 计算的 stringify
// 拿到的是最新引用。
config[kind][group] = [...next]
}
onMounted(() => reload())
</script>
<style scoped>
.mmc-card { margin: 0; }
.mmc-header { display: flex; align-items: center; gap: 8px; }
.mmc-title { font-weight: 600; font-size: 15px; color: #fff; }
.mmc-header .spacer { flex: 1; }
.mmc-desc {
color: rgba(232, 215, 245, 0.78);
font-size: 12.5px;
margin: 0 0 12px;
line-height: 1.7;
}
.mmc-desc code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.mmc-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
.mmc-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
.mmc-tab-body {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
</style>
@@ -0,0 +1,284 @@
<template>
<ConfigPageBase
section="ops"
title="运营维护"
description="调度回放、日志管理、版本维护(OpsConfig"
:defaults="DEFAULT_OPS"
:normalize-payload="normalizeOpsConfig"
:after-load="onOpsConfigLoaded"
:before-save="saveMonitorSettingsFromOps">
<template #default="{ payload, update }">
<el-form label-width="200px" :model="payload">
<el-divider content-position="left">调度回放</el-divider>
<el-form-item label="保留时长 (天)">
<el-input-number :model-value="payload.playback.retentionDays" :min="1" :max="3650" @update:model-value="(v: number | undefined) => update({ ...payload, playback: { ...payload.playback, retentionDays: v ?? 0 } })" />
</el-form-item>
<el-form-item label="采样频率 (Hz)">
<el-input-number :model-value="payload.playback.samplingHz" :min="1" :max="100" @update:model-value="(v: number | undefined) => update({ ...payload, playback: { ...payload.playback, samplingHz: v ?? 0 } })" />
</el-form-item>
<el-divider content-position="left">日志保留</el-divider>
<el-form-item label="热数据 ()">
<el-input-number :model-value="payload.logRetention.hotDays" :min="1" :max="365" @update:model-value="(v: number | undefined) => update({ ...payload, logRetention: { ...payload.logRetention, hotDays: v ?? 0 } })" />
</el-form-item>
<el-form-item label="冷数据 ()">
<el-input-number :model-value="payload.logRetention.coldDays" :min="1" :max="3650" @update:model-value="(v: number | undefined) => update({ ...payload, logRetention: { ...payload.logRetention, coldDays: v ?? 0 } })" />
</el-form-item>
<el-divider content-position="left">版本维护</el-divider>
<el-form-item label="保留发布版本">
<el-input-number :model-value="payload.version.keepReleases" :min="1" :max="100" @update:model-value="(v: number | undefined) => update({ ...payload, version: { keepReleases: v ?? 0 } })" />
</el-form-item>
<el-divider content-position="left">地图监控信息与动作管理</el-divider>
<el-alert
type="info"
:closable="false"
show-icon
:title="autoSaving ? '正在自动保存…' : '支持直接多选,修改后会自动保存到地图监控(simple.json),无需点击上方「保存」。若某组不勾选,表示该组在地图监控中显示全部。'"
style="margin-bottom: 10px" />
<el-form-item label="按车型动作配置">
<el-collapse class="type-actions">
<el-collapse-item
v-for="row in carTypeActions"
:key="row.typeKey"
:name="row.typeKey"
:title="`${row.title}${row.methods.length}`">
<el-checkbox-group
:model-value="getCarTypeActions(payload, row)"
class="cfg-check-grid"
@update:model-value="(v) => updateCarTypeActions(update, payload, row, v as string[])">
<el-checkbox v-for="m in row.methods" :key="`${row.typeKey}-${m.methodName}`" :label="m.methodName">
{{ m.label }}
</el-checkbox>
</el-checkbox-group>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item label="站点动作显示项">
<el-checkbox-group
:model-value="getMonitor(payload).site.actionKeys"
class="cfg-check-grid"
@update:model-value="(v) => updateMonitorList(update, payload, 'site', 'actionKeys', v as string[])">
<el-checkbox v-for="m in siteMethods" :key="`sa-${m.methodName}`" :label="m.methodName">
{{ m.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="路径动作显示项">
<el-checkbox-group
:model-value="getMonitor(payload).track.actionKeys"
class="cfg-check-grid"
@update:model-value="(v) => updateMonitorList(update, payload, 'track', 'actionKeys', v as string[])">
<el-checkbox v-for="m in trackMethods" :key="`ta-${m.methodName}`" :label="m.methodName">
{{ m.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_OPS } from '@/mock/data/configs'
import { reflectionApi, emptyMonitorAvailableMap, type MonitorVisibilityMap } from '@/api/reflection'
import type { MonitorOpsPolicy, MonitorPanelPolicy, OpsConfig } from '@/types/config'
import {
loadMonitorSettingsIntoOps,
normalizeOpsConfig,
saveMonitorSettingsFromOps
} from '@/utils/opsMonitorConfig'
type MonitorKind = 'car' | 'site' | 'track'
type MonitorKey = keyof MonitorPanelPolicy
interface MethodOption {
methodName: string
label: string
}
interface CarTypeActionRow {
typeKey: string
shortTypeName: string
title: string
methods: MethodOption[]
}
const available = reactive<MonitorVisibilityMap>(emptyMonitorAvailableMap())
const carTypeActions = ref<CarTypeActionRow[]>([])
const siteMethods = ref<MethodOption[]>([])
const trackMethods = ref<MethodOption[]>([])
// 地图监控信息与动作管理这三组多选写入 SimpleLitesimple.json),与上方「保存」按钮走的
// Platform ops 配置是两条独立链路。这里做勾选即自动保存:防抖合并连续勾选,串行避免并发
// getMonitorConfig/saveMonitorConfig 竞态,卸载时把窗口内未落盘的改动补存。
const autoSaving = ref(false)
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
let pendingMonitorPayload: OpsConfig | null = null
const AUTO_SAVE_DELAY_MS = 450
function scheduleMonitorAutoSave(next: OpsConfig) {
pendingMonitorPayload = next
if (autoSaveTimer) clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(() => { void flushMonitorAutoSave() }, AUTO_SAVE_DELAY_MS)
}
async function flushMonitorAutoSave() {
autoSaveTimer = null
if (autoSaving.value) {
// 上一次保存仍在进行:稍后重试,确保最新勾选最终落盘。
autoSaveTimer = setTimeout(() => { void flushMonitorAutoSave() }, 200)
return
}
const target = pendingMonitorPayload
pendingMonitorPayload = null
if (!target) return
autoSaving.value = true
try {
await saveMonitorSettingsFromOps(target)
ElMessage.success({ message: '地图监控配置已自动保存', duration: 1200, grouping: true })
} catch (e) {
ElMessage.error(`地图监控配置自动保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
autoSaving.value = false
if (pendingMonitorPayload) scheduleMonitorAutoSave(pendingMonitorPayload)
}
}
onBeforeUnmount(() => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
if (pendingMonitorPayload && !autoSaving.value) void flushMonitorAutoSave()
})
function normalizeMonitor(monitor?: MonitorOpsPolicy): MonitorOpsPolicy {
return normalizeOpsConfig({ monitor } as OpsConfig).monitor
}
async function onOpsConfigLoaded(payload: OpsConfig, update: (next: OpsConfig) => void) {
update(await loadMonitorSettingsIntoOps(payload))
}
function getMonitor(payload: OpsConfig): MonitorOpsPolicy {
return normalizeMonitor(payload.monitor)
}
function updateMonitorList(
update: (next: OpsConfig) => void,
payload: OpsConfig,
kind: MonitorKind,
key: MonitorKey,
list: string[]
) {
const monitor = getMonitor(payload)
const next: OpsConfig = {
...payload,
monitor: {
...monitor,
[kind]: {
...monitor[kind],
[key]: [...list]
}
}
}
update(next)
scheduleMonitorAutoSave(next)
}
function updateCarTypeActions(
update: (next: OpsConfig) => void,
payload: OpsConfig,
row: CarTypeActionRow,
methods: string[]
) {
const monitor = getMonitor(payload)
const nextMap = {
...monitor.carActionByType,
[row.typeKey]: [...methods]
}
if (row.shortTypeName !== row.typeKey) {
delete nextMap[row.shortTypeName]
}
const next: OpsConfig = {
...payload,
monitor: {
...monitor,
carActionByType: nextMap
}
}
update(next)
scheduleMonitorAutoSave(next)
}
function getCarTypeActions(payload: OpsConfig, row: CarTypeActionRow): string[] {
const monitor = getMonitor(payload)
return monitor.carActionByType[row.typeKey]
?? monitor.carActionByType[row.shortTypeName]
?? []
}
onMounted(async () => {
try {
const mc = await reflectionApi.getMonitorConfig()
available.car = mc.available.car
available.site = mc.available.site
available.track = mc.available.track
} catch {
available.car = { fields: [], status: [], methods: [] }
available.site = { fields: [], status: [], methods: [] }
available.track = { fields: [], status: [], methods: [] }
}
try {
const rows = await reflectionApi.listMethodsByType('car')
carTypeActions.value = rows
.map((r) => ({
typeKey: r.fullTypeName ?? r.typeName,
shortTypeName: r.typeName,
title: `${r.typeLabel ?? r.typeName} / ${r.fullTypeName ?? r.typeName} [${r.assemblyName}]`,
methods: r.methods
.map((m) => ({ methodName: m.methodName, label: m.label || m.methodName }))
.filter((m) => !!m.methodName)
}))
.filter((r) => r.methods.length > 0)
.sort((a, b) => a.title.localeCompare(b.title))
} catch {
carTypeActions.value = []
}
try {
const rows = await reflectionApi.listMethodsByType('site')
siteMethods.value = dedupeMethodOptions(rows.flatMap((r) => r.methods))
} catch {
siteMethods.value = available.site.methods.map((m) => ({ methodName: m, label: m }))
}
try {
const rows = await reflectionApi.listMethodsByType('track')
trackMethods.value = dedupeMethodOptions(rows.flatMap((r) => r.methods))
} catch {
trackMethods.value = available.track.methods.map((m) => ({ methodName: m, label: m }))
}
})
function dedupeMethodOptions(methods: Array<{ methodName: string; label?: string | null }>): MethodOption[] {
const map = new Map<string, MethodOption>()
for (const m of methods) {
if (!m.methodName) continue
if (!map.has(m.methodName)) {
map.set(m.methodName, { methodName: m.methodName, label: m.label || m.methodName })
}
}
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label))
}
</script>
<style scoped>
.cfg-check-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 4px 10px;
}
.type-actions {
width: 100%;
}
</style>
@@ -0,0 +1,51 @@
<template>
<ConfigPageBase
section="routing"
title="路径规划策略"
description="算法选择、权重、避障规则、区域限速(RoutingPolicy"
:defaults="DEFAULT_ROUTING">
<template #default="{ payload, update }">
<el-form label-width="140px" :model="payload">
<el-form-item label="算法">
<el-select :model-value="payload.algorithm" @update:model-value="(v: any) => update({ ...payload, algorithm: v })">
<el-option label="Dijkstra" value="dijkstra" />
<el-option label="A*" value="astar" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="权重">
<el-table :data="weightRows(payload.weights)" size="small">
<el-table-column label="维度" prop="k" width="140" />
<el-table-column label="权重">
<template #default="s">
<el-input-number :model-value="s.row.v" :min="0" :max="10" :step="0.1" @update:model-value="(v: number | undefined) => update({ ...payload, weights: { ...payload.weights, [s.row.k]: v ?? 0 } })" />
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="避障规则">
<el-table :data="payload.avoidance" size="small">
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="区域 ID" prop="zoneId" width="140" />
<el-table-column label="规则" prop="rule" />
</el-table>
</el-form-item>
<el-form-item label="区域限速">
<el-table :data="payload.zoneSpeedLimits" size="small">
<el-table-column label="区域" prop="zoneId" width="140" />
<el-table-column label="最大速度 (m/s)" prop="maxSpeedMps" />
</el-table>
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_ROUTING } from '@/mock/data/configs'
function weightRows(weights: Record<string, number>) {
return Object.entries(weights).map(([k, v]) => ({ k, v }))
}
</script>
@@ -0,0 +1,43 @@
<template>
<ConfigPageBase
section="scenario"
title="业务场景模板化"
description="SPS / Pack / 环线 / 点对点;DSL/低代码扩展(ScenarioTemplateConfig"
:defaults="DEFAULT_SCENARIO">
<template #default="{ payload }">
<el-row :gutter="12">
<el-col v-for="t in payload.templates" :key="t.id" :span="6">
<el-card shadow="hover" class="tpl-card">
<div class="tpl-title">{{ t.name }}</div>
<div class="tpl-meta">
<el-tag size="small">{{ t.category }}</el-tag>
<el-tag size="small" type="info">v{{ t.version }}</el-tag>
</div>
<pre class="tpl-baseline">{{ t.baselineJson || '{}' }}</pre>
</el-card>
</el-col>
</el-row>
<el-divider />
<el-descriptions :column="2" border>
<el-descriptions-item label="DSL 启用">{{ payload.dslPolicy.enabled ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="Schema 版本">{{ payload.dslPolicy.schemaVersion }}</el-descriptions-item>
<el-descriptions-item label="低代码">{{ payload.lowCode.enabled ? '启用' : '关闭' }}</el-descriptions-item>
<el-descriptions-item label="编辑器">{{ payload.lowCode.editor }}</el-descriptions-item>
<el-descriptions-item label="保留版本">{{ payload.versionPolicy.keepVersions }}</el-descriptions-item>
<el-descriptions-item label="允许回滚">{{ payload.versionPolicy.allowRollback ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_SCENARIO } from '@/mock/data/configs'
</script>
<style scoped>
.tpl-card { margin-bottom: 12px; }
.tpl-title { font-weight: 600; margin-bottom: 6px; }
.tpl-meta { display: flex; gap: 6px; margin-bottom: 8px; }
.tpl-baseline { margin: 0; font-size: 11px; background: #f5f7fa; padding: 6px; border-radius: 4px; max-height: 80px; overflow: auto; }
</style>
@@ -0,0 +1,146 @@
<template>
<ConfigPageBase
section="system"
title="系统级配置"
description="运行参数、日志策略、安全策略(对齐 SystemConfig"
:defaults="DEFAULT_SYSTEM">
<template #default="{ payload, update }">
<el-form label-width="160px" :model="payload">
<el-form-item label="调度循环 (Hz)">
<el-input-number :model-value="payload.dispatchLoopHz" :min="1" :max="200" @update:model-value="(v: number | undefined) => update({ ...payload, dispatchLoopHz: v ?? 0 })" />
</el-form-item>
<el-divider content-position="left">日志策略</el-divider>
<el-form-item label="日志级别">
<el-select :model-value="payload.log.level" @update:model-value="(v: any) => update({ ...payload, log: { ...payload.log, level: v } })">
<el-option v-for="lv in ['trace','debug','info','warn','error']" :key="lv" :label="lv" :value="lv" />
</el-select>
</el-form-item>
<el-form-item label="日志保留 ()">
<el-input-number :model-value="payload.log.rollDays" :min="1" :max="365" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, rollDays: v ?? 0 } })" />
</el-form-item>
<el-form-item label="单文件上限 (MB)">
<el-input-number :model-value="payload.log.maxSizeMB" :min="1" :max="4096" @update:model-value="(v: number | undefined) => update({ ...payload, log: { ...payload.log, maxSizeMB: v ?? 0 } })" />
</el-form-item>
<el-divider content-position="left">安全策略</el-divider>
<el-form-item label="JWT 过期 (分钟)">
<el-input-number :model-value="payload.security.jwtExpireMin" :min="5" :max="43200" @update:model-value="(v: number | undefined) => update({ ...payload, security: { ...payload.security, jwtExpireMin: v ?? 0 } })" />
</el-form-item>
<el-form-item label="启用 Swagger">
<el-switch :model-value="payload.security.enableSwagger" @update:model-value="(v: string | number | boolean) => update({ ...payload, security: { ...payload.security, enableSwagger: Boolean(v) } })" />
</el-form-item>
<el-form-item label="CORS 白名单">
<el-input
type="textarea"
:rows="3"
:model-value="payload.security.corsWhitelist.join('\n')"
@update:model-value="(v: string) => update({ ...payload, security: { ...payload.security, corsWhitelist: v.split(/\s+/).filter(Boolean) } })" />
</el-form-item>
<el-divider content-position="left">界面主题配色</el-divider>
<el-form-item label="主题取色盘">
<ThemeCustomizer />
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
<el-card class="ai-service-card" shadow="never">
<template #header>
<span>AI 服务(地图编辑器「AI 生图」依赖)</span>
<el-tag v-if="aiCfg.configured" type="success" size="small" effect="dark" class="ml-8">已配置</el-tag>
<el-tag v-else type="warning" size="small" effect="dark" class="ml-8">未配置</el-tag>
</template>
<el-form label-width="140px" size="default">
<el-form-item label="Endpoint">
<el-input v-model="aiCfg.endpoint" placeholder="https://api.openai.com/v1/chat/completions" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="aiCfg.apiKey" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-form-item label="模型">
<el-input v-model="aiCfg.model" placeholder="gpt-4o-mini" />
</el-form-item>
<el-form-item label="System Prompt">
<el-input v-model="aiCfg.systemPrompt" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="aiCfg.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="aiCfg.maxTokens" :min="64" :max="32000" :step="64" />
</el-form-item>
<el-form-item label="超时 (秒)">
<el-input-number v-model="aiCfg.timeoutSec" :min="5" :max="600" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="aiSaving" @click="onSaveAi">保存 AI 配置</el-button>
<el-button :loading="aiTesting" @click="onTestAi">测试连通</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
import { DEFAULT_SYSTEM } from '@/mock/data/configs'
import { onMounted, reactive, ref } from 'vue'
import { aiConfigApi, mapEditApi, type AiConfig } from '@/api/mapEdit'
import { ElMessage } from 'element-plus'
const aiCfg = reactive<AiConfig>({
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
model: 'gpt-4o-mini',
systemPrompt: '你是一个 AGV 路网设计师。',
temperature: 0.4,
maxTokens: 4096,
timeoutSec: 60,
configured: false
})
const aiSaving = ref(false)
const aiTesting = ref(false)
onMounted(async () => {
try {
const r = await aiConfigApi.get()
Object.assign(aiCfg, r)
} catch (err) {
ElMessage.warning(`无法读取 AI 配置:${(err as Error).message}`)
}
})
async function onSaveAi() {
try {
aiSaving.value = true
await aiConfigApi.save({ ...aiCfg })
ElMessage.success('AI 配置已保存')
aiCfg.configured = !!aiCfg.apiKey
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
aiSaving.value = false
}
}
async function onTestAi() {
try {
aiTesting.value = true
const r = await mapEditApi.aiMapGenerate({
prompt: '测试连通:仅返回 "ok",不要生成任何站点或路径。',
mode: 'sites',
bounds: [0, 0, 0, 0]
})
ElMessage.success(`AI 服务连通正常:返回工具调用 ${r.usedTools}`)
} catch (err) {
ElMessage.error(`连通失败:${(err as Error).message}`)
} finally {
aiTesting.value = false
}
}
</script>
<style scoped>
.ai-service-card { margin: 12px 0; }
.ml-8 { margin-left: 8px; }
</style>
@@ -0,0 +1,31 @@
<template>
<ConfigPageBase
section="task"
title="任务分配机制"
description="负载均衡、就近分配、优先级调度(TaskAllocationPolicy"
:defaults="DEFAULT_TASK">
<template #default="{ payload, update }">
<el-form label-width="160px" :model="payload">
<el-form-item label="分配模式">
<el-radio-group :model-value="payload.mode" @update:model-value="(v: any) => update({ ...payload, mode: v })">
<el-radio-button label="roundRobin">轮询</el-radio-button>
<el-radio-button label="nearest">就近</el-radio-button>
<el-radio-button label="leastLoad">最少负载</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="启用负载均衡">
<el-switch :model-value="payload.loadBalance" @update:model-value="(v: string | number | boolean) => update({ ...payload, loadBalance: Boolean(v) })" />
</el-form-item>
<el-form-item label="单车队列上限">
<el-input-number :model-value="payload.maxQueuePerCar" :min="1" :max="100" @update:model-value="(v: number | undefined) => update({ ...payload, maxQueuePerCar: v ?? 0 })" />
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_TASK } from '@/mock/data/configs'
</script>
@@ -0,0 +1,42 @@
<template>
<ConfigPageBase
section="traffic"
title="交通管制规则"
description="路口策略、区域互斥、动态让行(TrafficRule"
:defaults="DEFAULT_TRAFFIC">
<template #default="{ payload }">
<el-tabs model-value="ix">
<el-tab-pane name="ix" label="路口策略">
<el-table :data="payload.intersections" size="small">
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="站点 IDs" prop="siteIds">
<template #default="s">{{ s.row.siteIds.join(', ') }}</template>
</el-table-column>
<el-table-column label="模式" prop="mode" width="100" />
</el-table>
</el-tab-pane>
<el-tab-pane name="mz" label="区域互斥">
<el-table :data="payload.mutex" size="small">
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="区域 IDs" prop="zoneIds">
<template #default="s">{{ s.row.zoneIds.join(', ') }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="yd" label="动态让行">
<el-table :data="payload.yields" size="small">
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="From" prop="from" width="140" />
<el-table-column label="To" prop="to" width="140" />
<el-table-column label="条件" prop="condition" />
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_TRAFFIC } from '@/mock/data/configs'
</script>
@@ -0,0 +1,37 @@
<template>
<ConfigPageBase
section="vehicle"
title="车辆维护策略"
description="电量阈值、故障上报、自动报修(VehicleMaintenancePolicy"
:defaults="DEFAULT_VEHICLE">
<template #default="{ payload, update }">
<el-form label-width="160px" :model="payload">
<el-form-item label="低电量阈值">
<el-slider :model-value="payload.lowBatteryThreshold * 100" :min="0" :max="100" :format-tooltip="v => `${v}%`" @update:model-value="(v: any) => update({ ...payload, lowBatteryThreshold: (Number(v) || 0) / 100 })" />
</el-form-item>
<el-form-item label="临界电量阈值">
<el-slider :model-value="payload.criticalBatteryThreshold * 100" :min="0" :max="100" :format-tooltip="v => `${v}%`" @update:model-value="(v: any) => update({ ...payload, criticalBatteryThreshold: (Number(v) || 0) / 100 })" />
</el-form-item>
<el-divider content-position="left">故障上报</el-divider>
<el-form-item label="启用">
<el-switch :model-value="payload.faultReport.enabled" @update:model-value="(v: string | number | boolean) => update({ ...payload, faultReport: { ...payload.faultReport, enabled: Boolean(v) } })" />
</el-form-item>
<el-form-item label="接收邮箱">
<el-input :model-value="payload.faultReport.emailTo.join(', ')" @update:model-value="(v: string) => update({ ...payload, faultReport: { ...payload.faultReport, emailTo: v.split(/[,\s]+/).filter(Boolean) } })" />
</el-form-item>
<el-divider content-position="left">自动报修</el-divider>
<el-form-item label="启用">
<el-switch :model-value="payload.autoRepair.enabled" @update:model-value="(v: string | number | boolean) => update({ ...payload, autoRepair: { ...payload.autoRepair, enabled: Boolean(v) } })" />
</el-form-item>
<el-form-item label="冷却时间 ()">
<el-input-number :model-value="payload.autoRepair.cooldownSec" :min="0" :max="86400" @update:model-value="(v: number | undefined) => update({ ...payload, autoRepair: { ...payload.autoRepair, cooldownSec: v ?? 0 } })" />
</el-form-item>
</el-form>
</template>
</ConfigPageBase>
</template>
<script setup lang="ts">
import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_VEHICLE } from '@/mock/data/configs'
</script>
@@ -0,0 +1,86 @@
<template>
<el-card shadow="never">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<span>运营备注monitor.note.write · platform.db.Annotations</span>
<el-tag size="small" type="success" v-if="auth.hasOp('monitor.note.write')">可写</el-tag>
<el-tag size="small" type="danger" v-else>只读</el-tag>
</div>
</template>
<el-form inline @submit.prevent>
<el-form-item label="标题">
<el-input v-model="form.title" style="width: 240px" />
</el-form-item>
<el-form-item label="关联目标">
<el-input v-model="form.target" placeholder="例如 C01 / M02 / S006" style="width: 200px" />
</el-form-item>
<el-form-item label="层级">
<el-select v-model="form.level" style="width: 120px">
<el-option v-for="lv in ['info','warn','error']" :key="lv" :label="lv" :value="lv" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!auth.hasOp('monitor.note.write')" @click="submit">提交</el-button>
</el-form-item>
</el-form>
<el-input
v-model="form.content"
type="textarea"
:rows="4"
placeholder="描述备注内容..."
:disabled="!auth.hasOp('monitor.note.write')" />
<el-divider />
<el-table :data="notes" size="small" max-height="400">
<el-table-column prop="ts" label="时间" width="180" />
<el-table-column prop="author" label="作者" width="100" />
<el-table-column prop="target" label="目标" width="100" />
<el-table-column prop="level" label="层级" width="80">
<template #default="s"><el-tag size="small" :type="levelTag(s.row.level)">{{ s.row.level }}</el-tag></template>
</el-table-column>
<el-table-column prop="title" label="标题" />
<el-table-column prop="content" label="内容" />
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const form = reactive({ title: '', target: '', level: 'info', content: '' })
interface NoteRow { id: string; ts: string; author: string; target: string; level: string; title: string; content: string }
const notes = ref<NoteRow[]>([
{ id: 'N001', ts: '2026-05-19 10:25', author: 'ops', target: 'C05', level: 'warn', title: 'AGV-005 故障观察', content: '出库点附近频繁停顿,怀疑激光被遮挡' }
])
function levelTag(lv: string) {
if (lv === 'error') return 'danger'
if (lv === 'warn') return 'warning'
return 'info'
}
function submit() {
if (!form.title) { ElMessage.warning('请填写标题'); return }
const id = `N${String(notes.value.length + 1).padStart(3, '0')}`
notes.value.unshift({
id,
ts: new Date().toLocaleString('zh-CN'),
author: auth.user?.username ?? 'mock',
target: form.target,
level: form.level,
title: form.title,
content: form.content
})
ElMessage.success('已记录(Mock,未写入数据库)')
form.title = ''
form.content = ''
form.target = ''
}
</script>
@@ -0,0 +1,82 @@
<template>
<div>
<el-row :gutter="14" class="kpi-row">
<el-col :span="6"><el-statistic title="车辆在线" :value="onlineCars">
<template #suffix><span class="muted">/ {{ cars.length }}</span></template>
</el-statistic></el-col>
<el-col :span="6"><el-statistic title="任务进行中" :value="runningCount" /></el-col>
<el-col :span="6"><el-statistic title="故障车辆" :value="faultCount" /></el-col>
<el-col :span="6"><el-statistic title="今日运维动作" :value="audits.length" /></el-col>
</el-row>
<el-row :gutter="14" style="margin-top: 14px">
<el-col :span="14">
<el-card shadow="never">
<template #header><span>实时车辆</span></template>
<el-table :data="cars" size="small" max-height="360" stripe>
<el-table-column prop="name" label="名称" width="100" />
<el-table-column prop="state" label="状态" width="90">
<template #default="s"><el-tag size="small" :type="stateTag(s.row.state)">{{ s.row.state }}</el-tag></template>
</el-table-column>
<el-table-column prop="group" label="分组" width="100" />
<el-table-column label="电量" width="160">
<template #default="s"><el-progress :percentage="Math.round(s.row.batterySoc * 100)" :stroke-width="8" /></template>
</el-table-column>
<el-table-column prop="missionId" label="当前任务" />
</el-table>
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="never">
<template #header><span>最近运维动作</span></template>
<el-table :data="audits" size="small" max-height="360" stripe>
<el-table-column prop="ts" label="时间" width="180" />
<el-table-column prop="opCode" label="动作" />
<el-table-column prop="target" label="目标" width="100" />
<el-table-column prop="result" label="结果" width="80">
<template #default="s">
<el-tag size="small" :type="s.row.result === 'ok' ? 'success' : 'danger'">{{ s.row.result }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { listCars, listMissions } from '@/api/projection'
import { listAudits } from '@/api/ops'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { OpsAuditEntry } from '@/types/ops'
const cars = ref<Car[]>([])
const missions = ref<Mission[]>([])
const audits = ref<OpsAuditEntry[]>([])
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
const runningCount = computed(() => missions.value.filter((m) => m.status === 'running').length)
const faultCount = computed(() => cars.value.filter((c) => c.state === 'fault').length)
function stateTag(state: string) {
switch (state) {
case 'running': return 'success'
case 'idle': return 'info'
case 'charging': return 'warning'
case 'paused': return 'warning'
case 'fault': return 'danger'
case 'offline': return 'info'
default: return 'info'
}
}
onMounted(async () => {
;[cars.value, missions.value, audits.value] = await Promise.all([listCars(), listMissions(), listAudits()])
})
</script>
<style scoped>
.muted { color: rgba(232, 215, 245, 0.55); font-size: 12px; }
</style>
@@ -0,0 +1,131 @@
<template>
<div class="monitor-map-page">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
RCSMonitor 只读视图iframe 嵌入 SimpleLite webVRender{{ vrHost }}右侧可执行白名单运维动作
</el-alert>
<el-row :gutter="12" class="main-row">
<el-col :span="17">
<div class="canvas-with-toolbar">
<Workspace3D
:host="vrHost"
:scope="'RCSMonitor'"
:token="auth.token ?? ''"
:read-only="true"
:embed-ui="true"
@pick="onPick"
@select="onSelect" />
<WorkspaceCanvasToolbar />
</div>
</el-col>
<el-col :span="7">
<el-card shadow="never">
<template #header><span>选中目标</span></template>
<el-radio-group v-model="targetType" size="small">
<el-radio-button label="car">车辆</el-radio-button>
<el-radio-button label="task">任务</el-radio-button>
</el-radio-group>
<el-select v-model="targetId" placeholder="选择目标 ID" filterable style="width: 100%; margin-top: 8px">
<el-option v-for="opt in currentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-descriptions :column="1" border size="small" style="margin-top: 8px">
<el-descriptions-item label="最近 Pick">
{{ lastPick ? `(${lastPick.x.toFixed(0)}, ${lastPick.y.toFixed(0)})` : '—' }}
</el-descriptions-item>
<el-descriptions-item label="最近 Select">
{{ lastSelect.length ? lastSelect.join(', ') : '—' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<template #header><span>运维白名单按权限隐藏</span></template>
<div class="ops-grid">
<template v-for="op in OPS_WHITELIST" :key="op.code">
<el-button
v-if="auth.hasOp(op.code) && opMatchTarget(op)"
:type="op.needConfirm ? 'warning' : 'primary'"
size="small"
plain
@click="execute(op)">
{{ op.label }}
<el-tooltip :content="op.description"><el-icon style="margin-left: 4px"><InfoFilled /></el-icon></el-tooltip>
</el-button>
</template>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue'
import Workspace3D from '@/components/Workspace3D.vue'
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
import { useAuthStore } from '@/stores/auth'
import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
import { listCars, listMissions } from '@/api/projection'
import { executeOp } from '@/api/ops'
const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const targetType = ref<'car' | 'task'>('car')
const targetId = ref('')
const carOptions = ref<Array<{ label: string; value: string }>>([])
const missionOptions = ref<Array<{ label: string; value: string }>>([])
const lastPick = ref<{ x: number; y: number } | null>(null)
const lastSelect = ref<string[]>([])
const currentOptions = computed(() => (targetType.value === 'car' ? carOptions.value : missionOptions.value))
function opMatchTarget(op: OpsAction) {
return op.target === targetType.value || op.target === 'note'
}
async function execute(op: OpsAction) {
if (!targetId.value && op.target !== 'note') {
ElMessage.warning('请先选择目标')
return
}
if (op.needConfirm) {
try {
await ElMessageBox.confirm(`确认执行 [${op.label}]\n目标:${targetId.value}`, '二次确认', { type: 'warning' })
} catch { return }
}
try {
const resp = await executeOp({ opCode: op.code, targetId: targetId.value, reason: '' })
ElMessage.success(`已执行 ${op.label}auditId=${resp.auditId}`)
} catch (e) {
ElMessage.error(`执行失败:${e instanceof Error ? e.message : String(e)}`)
}
}
function onPick(p: { x: number; y: number }) { lastPick.value = p }
function onSelect(names: string[]) {
lastSelect.value = names
if (names.length === 1 && names[0].startsWith('UICar-')) {
targetType.value = 'car'
targetId.value = names[0].replace('UICar-', '')
}
}
onMounted(async () => {
const cars = await listCars()
carOptions.value = cars.map((c) => ({ label: `${c.name} (${c.id})`, value: c.id }))
const missions = await listMissions()
missionOptions.value = missions.map((m) => ({ label: `${m.name} (${m.id})`, value: m.id }))
})
</script>
<style scoped>
.monitor-map-page { display: flex; flex-direction: column; height: calc(100vh - 56px - 36px - 32px); }
.main-row { flex: 1; min-height: 0; }
.main-row > .el-col { display: flex; flex-direction: column; }
.main-row > .el-col:first-child { min-height: 0; }
.ops-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.canvas-with-toolbar { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.canvas-with-toolbar :deep(.workspace-3d-wrap) { flex: 1; min-height: 0; }
</style>
@@ -0,0 +1,85 @@
<template>
<PermissionGuard widget-id="OpsActionPanel">
<el-card shadow="never">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<span>运维操作架构 §5.1 白名单</span>
<el-tag size="small" type="info">scope=RCSMonitor</el-tag>
<el-tag size="small" type="success">{{ allowedOps.length }} / {{ OPS_WHITELIST.length }} 可用</el-tag>
</div>
</template>
<el-table :data="OPS_WHITELIST" size="small" border>
<el-table-column prop="code" label="权限码" width="200">
<template #default="s"><code>{{ s.row.code }}</code></template>
</el-table-column>
<el-table-column prop="label" label="操作" width="140" />
<el-table-column prop="target" label="目标" width="80" />
<el-table-column prop="needConfirm" label="二次确认" width="100">
<template #default="s">
<el-tag v-if="s.row.needConfirm" size="small" type="warning"></el-tag>
<el-tag v-else size="small" effect="plain"></el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" />
<el-table-column label="操作" width="200">
<template #default="s">
<el-input v-model="targets[s.row.code]" placeholder="目标 ID" size="small" style="width: 100px; margin-right: 6px" />
<el-button
size="small"
:type="s.row.needConfirm ? 'warning' : 'primary'"
:disabled="!auth.hasOp(s.row.code)"
@click="execute(s.row)">执行</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<template #header><span>本地操作记录占位 Mock</span></template>
<el-timeline>
<el-timeline-item v-for="a in audits" :key="a.id" :timestamp="a.ts" :type="a.result === 'ok' ? 'success' : 'danger'">
<strong>{{ a.opCode }}</strong> {{ a.target }}{{ a.user }}
</el-timeline-item>
</el-timeline>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
import { OPS_WHITELIST, type OpsAction, type OpsAuditEntry } from '@/types/ops'
import { useAuthStore } from '@/stores/auth'
import { executeOp, listAudits } from '@/api/ops'
const auth = useAuthStore()
const targets = reactive<Record<string, string>>({})
const audits = ref<OpsAuditEntry[]>([])
const allowedOps = computed(() => OPS_WHITELIST.filter((op) => auth.hasOp(op.code)))
async function execute(op: OpsAction) {
const tid = targets[op.code]
if (!tid && op.target !== 'note') {
ElMessage.warning('请填写目标 ID')
return
}
if (op.needConfirm) {
try {
await ElMessageBox.confirm(`确认执行 [${op.label}]\n目标:${tid}`, '二次确认', { type: 'warning' })
} catch { return }
}
try {
const resp = await executeOp({ opCode: op.code, targetId: tid })
ElMessage.success(`成功,auditId=${resp.auditId}`)
audits.value = await listAudits()
} catch (e) {
ElMessage.error(`失败:${e instanceof Error ? e.message : String(e)}`)
}
}
onMounted(async () => {
audits.value = await listAudits()
})
</script>