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:
@@ -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+2:car-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
|
||||
})
|
||||
|
||||
// 防御 1:car-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]
|
||||
}
|
||||
|
||||
// 防御 2:palette 可能为 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 已经是 number(AlarmEvent 类型保证),无须额外字符串处理;如果将来后端
|
||||
// 改成字符串,这里 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+2:tabs 通用样式提到全局 `.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="角色、权限码、控件级 WidgetGrant(auth.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/交换机/摄像头/读码器/PLC(DeviceManagementConfig)"
|
||||
: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[]>([])
|
||||
|
||||
// 地图监控信息与动作管理这三组多选写入 SimpleLite(simple.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>
|
||||
Reference in New Issue
Block a user