feat(platform): 部署配置向导 + 地图管理,地图编辑器接入统一存取与 AI 助手

- 配置向导:登录按 deployment 画像引导平台选型(导航方式/模块/场景),未完成则路由守卫强制进入 /wizard;选型驱动菜单按需裁剪,并联动 SimpleLite 写 plugins/active-scenes.json + 透传 --scenes 选择性加载导航场景插件
- 地图管理页:服务器地图列表/使用/重命名/删除、地图合并、多地图连接管理
- 地图编辑器:项目存取改为存入地图管理统一目录(同名替换确认),支持 ?map=/?new= 进入,新增右侧可停靠 AI 助手面板
- 集成 PTL 拣选模块;新增车队分配面板(运维总览/筛选联动)
- SimpleLiteBuildSync 同步运行时依赖 DLL;重新构建前端静态资源

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-03 09:37:07 +08:00
co-authored by Cursor
parent e2269e430d
commit 7382e85598
109 changed files with 3481 additions and 398 deletions
@@ -6,27 +6,36 @@
<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]" />
<span v-for="(s, i) in starStyles" :key="i" :style="s" />
</div>
<div class="login-card mg-glass-lg">
<div class="login-card">
<!-- 左侧品牌 hero -->
<div class="hero">
<div class="hero-stars" aria-hidden="true">
<span v-for="(s, i) in heroStarStyles" :key="i" :style="s" />
</div>
<div class="hero-brand">
<div class="hero-mark"></div>
<div class="hero-name">迷毂</div>
<span class="hero-mark">
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="hero-mark-img" />
</span>
<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>
<li v-for="f in features" :key="f.title">
<span class="dot" />
<div class="feat">
<span class="feat-title">{{ f.title }}</span>
<span class="feat-desc">{{ f.desc }}</span>
</div>
</li>
</ul>
<div class="hero-footnote">
<span class="badge">v1.7</span>
<span>{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
<span>稳定 · 高效 · 安全的智能调度内核</span>
</div>
</div>
@@ -34,7 +43,7 @@
<div class="panel">
<div class="panel-head">
<div class="panel-title"> </div>
<div class="panel-sub">访问 SimpleLite Vue 外壳</div>
<div class="panel-sub">登录以进入智能调度平台</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
@@ -146,7 +155,7 @@
</div>
<div class="footer-stamp">
· {{ year }} · ARCHITECTURE v1.6 · powered by webVRender
· 智能调度平台 · {{ year }}
</div>
</div>
</template>
@@ -158,13 +167,20 @@ 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 features = [
{ title: '路径规划与导航', desc: '全局 + 实时双层路径算法,智能寻优路线' },
{ title: '交通管制', desc: '任务队列 + 优先级动态调度,提前规避路口拥堵冲突' },
{ title: '安全可靠性', desc: '约束设备运行规范,实现 AGV 与产线设备协同安全作业' },
{ title: '高级任务调度', desc: '按任务优先级、现场资源动态分配搬运任务' },
{ title: '插件扩展', desc: '插件化架构,支持功能定制、二次开发适配场景' }
]
const formRef = ref<FormInstance>()
const loading = ref(false)
@@ -196,22 +212,24 @@ const rules: FormRules = {
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>
})
// 生成 n 个随机分布、随机大小与闪烁节奏的小白点,营造梦幻星点。
function makeStars(n: number): Record<string, string>[] {
return Array.from({ length: n }, () => {
const size = 1.5 + Math.random() * 2.8
return {
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${size}px`,
height: `${size}px`,
opacity: `${0.35 + Math.random() * 0.5}`,
animationDelay: `${-Math.random() * 6}s`,
animationDuration: `${2.6 + Math.random() * 3.4}s`
} as Record<string, string>
})
}
// 背景层(卡片之外)+ 品牌区(卡片内深色区)两组星点
const starStyles = makeStars(48)
const heroStarStyles = makeStars(22)
async function submit() {
if (!formRef.value) return
@@ -251,104 +269,130 @@ async function submit() {
</script>
<style scoped>
/* ===== 背景层 ===== */
/* ════════════════════════════════════════════════════════════════════════
* 登录页固定配色(不随全局主题切换变化)。
* 统一用一组本地 --lg-* 变量(午夜靛蓝 + 青色霓虹),保证无论用户切到哪个
* 主题,登录界面始终是同一套高级、克制的深色质感。
* ════════════════════════════════════════════════════════════════════════ */
.login-page {
/* 梦幻晨曦渐变:粉紫(左上) → 蓝紫(中) → 淡蓝(右下),高明度柔光,参考产品视觉稿。 */
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
color: var(--lg-text);
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);
/* 亮调下仅保留极淡纹理,不压暗整体。 */
filter: saturate(0.6) brightness(1.1) contrast(0.9);
opacity: 0.07;
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%);
radial-gradient(ellipse at 14% 16%, rgba(255, 255, 255, 0.38) 0%, transparent 50%),
radial-gradient(ellipse at 86% 84%, rgba(174, 203, 245, 0.45) 0%, transparent 56%),
radial-gradient(ellipse at 50% 50%, rgba(220, 190, 247, 0.22) 0%, transparent 72%);
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%);
linear-gradient(rgba(var(--lg-accent-rgb), 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--lg-accent-rgb), 0.06) 1px, transparent 1px);
background-size: 60px 60px, 60px 60px;
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, transparent 72%);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, transparent 72%);
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
filter: blur(100px);
z-index: 1;
pointer-events: none;
animation: drift 18s ease-in-out infinite;
animation: drift 20s 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;
top: -150px; left: -130px;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 52%, transparent 80%);
opacity: 0.24;
}
.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;
width: 600px; height: 600px;
bottom: -220px; right: -190px;
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary-deep) 58%, transparent 86%);
opacity: 0.18;
animation-delay: -7s;
}
.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;
top: 32%; right: 9%;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 60%, transparent 90%);
opacity: 0.14;
animation-delay: -13s;
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, -30px) scale(1.05); }
50% { transform: translate(18px, -28px) scale(1.05); }
}
.bg-stars { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.bg-stars span {
position: absolute;
background: #fff;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
box-shadow:
0 0 6px rgba(255, 255, 255, 0.85),
0 0 12px rgba(150, 130, 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); }
0%, 100% { opacity: 0.35; transform: scale(0.8); }
50% { opacity: 0.95; transform: scale(1.2); }
}
/* ===== 卡片:双栏 hero ===== */
.login-card {
position: relative;
z-index: 2;
width: 880px;
width: 920px;
max-width: calc(100vw - 32px);
min-height: 560px;
min-height: 588px;
display: grid;
grid-template-columns: 340px 1fr;
grid-template-columns: 360px 1fr;
overflow: hidden;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
/* 登录框本体:紫 → 靛蓝的「紫蓝混合」渐变,梦幻且不发灰;高不透明度保证文字清晰 */
background:
linear-gradient(140deg,
rgba(48, 26, 96, 0.95) 0%,
rgba(33, 26, 92, 0.955) 50%,
rgba(22, 26, 84, 0.96) 100%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
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;
0 44px 110px rgba(0, 0, 0, 0.62),
0 0 70px rgba(var(--lg-primary-rgb), 0.24),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.16) inset,
0 1px 0 rgba(255, 255, 255, 0.18) inset;
}
/* 顶部霓虹高光线 */
.login-card::before {
@@ -358,9 +402,9 @@ async function submit() {
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%,
rgba(var(--lg-accent-rgb), 0.55) 30%,
rgba(255, 255, 255, 0.92) 50%,
rgba(var(--lg-accent-rgb), 0.55) 70%,
transparent 100%);
pointer-events: none;
z-index: 3;
@@ -369,88 +413,134 @@ async function submit() {
/* ===== 左侧 hero ===== */
.hero {
position: relative;
padding: 40px 32px;
padding: 44px 34px 34px;
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);
radial-gradient(ellipse at 16% 18%, rgba(123, 77, 255, 0.34) 0%, transparent 56%),
radial-gradient(ellipse at 88% 84%, rgba(94, 124, 255, 0.32) 0%, transparent 58%),
linear-gradient(170deg, rgba(var(--lg-primary-rgb), 0.26) 0%, rgba(var(--lg-aside-rgb), 0.42) 60%, rgba(var(--lg-deep-rgb), 0.55) 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
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%);
background: radial-gradient(ellipse at 78% 16%, rgba(var(--lg-accent-rgb), 0.24), transparent 58%);
pointer-events: none;
}
.hero > * { position: relative; z-index: 1; }
/* 品牌区梦幻星点:浮在 hero 底色之上、文字之下,闪烁 */
.hero .hero-stars {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.hero-stars span {
position: absolute;
background: rgba(255, 255, 255, 0.92);
border-radius: 50%;
box-shadow:
0 0 6px rgba(255, 255, 255, 0.8),
0 0 12px rgba(150, 130, 255, 0.6);
animation: twinkle 4s ease-in-out infinite;
}
.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;
display: inline-flex; align-items: center; justify-content: center;
height: 56px;
padding: 0 14px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.55) 0%, rgba(var(--lg-accent-rgb), 0.30) 100%);
border: 1px solid rgba(255, 255, 255, 0.20);
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;
0 12px 30px rgba(var(--lg-primary-rgb), 0.45),
0 0 30px rgba(var(--lg-accent-rgb), 0.30),
0 0 0 1px rgba(255, 255, 255, 0.16) inset;
animation: mg-mark-glow 4s ease-in-out infinite;
}
@keyframes mg-mark-glow {
0%, 100% { box-shadow: 0 12px 30px rgba(var(--lg-primary-rgb), 0.45), 0 0 24px rgba(var(--lg-accent-rgb), 0.24), 0 0 0 1px rgba(255, 255, 255, 0.16) inset; }
50% { box-shadow: 0 14px 34px rgba(var(--lg-primary-rgb), 0.55), 0 0 40px rgba(var(--lg-accent-rgb), 0.42), 0 0 0 1px rgba(255, 255, 255, 0.22) inset; }
}
.hero-mark-img {
display: block;
height: 34px;
width: auto;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35));
}
.hero-name {
font-size: 34px; font-weight: 700;
letter-spacing: 4px;
background: linear-gradient(90deg, #ffffff 0%, var(--mg-accent) 100%);
letter-spacing: 6px;
/* 紫蓝混合渐变字:白 → 浅紫 → 浅蓝 */
background: linear-gradient(95deg, #ffffff 0%, #c3b6ff 52%, #93a7ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 24px rgba(var(--mg-accent-rgb), 0.45);
text-shadow: 0 0 24px rgba(120, 130, 255, 0.4);
}
.hero-tagline {
margin-top: 32px;
font-size: 18px;
margin-top: 30px;
font-size: 17px;
font-weight: 500;
letter-spacing: 8px;
color: rgba(255, 255, 255, 0.94);
color: rgba(255, 255, 255, 0.95);
}
.hero-tagline-en {
margin-top: 4px;
font-size: 11px;
margin-top: 5px;
font-size: 10.5px;
letter-spacing: 3px;
color: rgba(232, 215, 245, 0.6);
color: var(--lg-text-dim);
text-transform: uppercase;
}
.hero-divider {
margin: 22px 0;
margin: 22px 0 18px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--lg-accent-rgb), 0.5), 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;
display: flex;
flex-direction: column;
gap: 13px;
flex: 1;
}
.hero-points li { display: flex; align-items: center; gap: 8px; }
.hero-points li { display: flex; align-items: flex-start; gap: 10px; }
.hero-points .dot {
margin-top: 7px;
width: 6px; height: 6px; border-radius: 50%;
background: var(--mg-accent);
box-shadow: 0 0 10px rgba(var(--mg-accent-rgb), 0.85);
background: var(--lg-accent);
box-shadow: 0 0 10px rgba(var(--lg-accent-rgb), 0.85);
flex-shrink: 0;
}
.feat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.feat-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0.5px;
color: #fff;
}
.feat-desc {
font-size: 11.5px;
line-height: 1.55;
color: var(--lg-text-soft);
}
.hero-footnote {
margin-top: auto;
margin-top: 22px;
display: flex; align-items: center; gap: 10px;
font-size: 11px;
color: rgba(232, 215, 245, 0.55);
letter-spacing: 1px;
color: var(--lg-text-dim);
letter-spacing: 0.6px;
}
.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);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.20);
color: #fff;
font-weight: 600;
}
@@ -460,23 +550,24 @@ async function submit() {
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));
linear-gradient(135deg, rgba(123, 77, 255, 0.14) 0%, rgba(94, 124, 255, 0.10) 100%);
}
.panel-head { margin-bottom: 28px; }
.panel-head { margin-bottom: 26px; }
.panel-title {
font-size: 30px; font-weight: 700;
letter-spacing: 12px;
background: linear-gradient(135deg, #ffffff 0%, var(--mg-accent) 100%);
background: linear-gradient(135deg, #ffffff 0%, var(--lg-accent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 22px rgba(var(--mg-primary-hover-rgb), 0.6);
text-shadow: 0 0 22px rgba(var(--lg-primary-hover-rgb), 0.5);
}
.panel-sub {
margin-top: 6px;
font-size: 12.5px;
color: rgba(232, 215, 245, 0.65);
color: var(--lg-text-dim);
letter-spacing: 1px;
}
@@ -485,20 +576,20 @@ async function submit() {
/* 输入框 透明玻璃 */
.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;
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) 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;
background: rgba(255, 255, 255, 0.11) !important;
box-shadow: 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
}
.glass-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.15) !important;
background: rgba(255, 255, 255, 0.13) !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;
0 0 0 1px rgba(var(--lg-accent-rgb), 0.85) inset,
0 0 20px rgba(var(--lg-primary-hover-rgb), 0.45) !important;
}
.glass-form :deep(.el-input__inner) {
color: #fff !important;
@@ -506,9 +597,9 @@ async function submit() {
font-size: 14px;
height: 42px;
}
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.45); }
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.42); }
.glass-form :deep(.el-input__prefix-inner > :first-child),
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.6); }
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.55); }
/* scope 双卡 */
.scope-item :deep(.el-form-item__content) { width: 100%; }
@@ -520,23 +611,23 @@ async function submit() {
display: flex; align-items: center; gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: rgba(232, 215, 245, 0.72);
color: var(--lg-text-soft);
letter-spacing: 1px;
}
.launch-mode-title { font-weight: 500; }
.launch-mode-help {
color: rgba(232, 215, 245, 0.55);
color: var(--lg-text-dim);
cursor: help;
font-size: 14px;
}
.launch-mode-help:hover { color: var(--mg-accent); }
.launch-wrap { /* 与 scope 一致,留位置给 hover 阴影 */ margin-bottom: 4px; }
.launch-mode-help:hover { color: var(--lg-accent); }
.launch-wrap { 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);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
padding: 14px 14px;
display: flex; align-items: center; gap: 12px;
@@ -546,19 +637,19 @@ async function submit() {
text-align: left;
}
.scope-tab:hover {
background: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.11);
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);
rgba(var(--lg-primary-rgb), 0.55) 0%,
rgba(var(--lg-accent-rgb), 0.28) 100%);
border-color: rgba(var(--lg-accent-rgb), 0.75);
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;
0 0 22px rgba(var(--lg-primary-hover-rgb), 0.45),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.45) inset;
}
.scope-tab .el-icon { font-size: 20px; }
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
@@ -569,19 +660,19 @@ async function submit() {
.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;
color: rgba(255, 255, 255, 0.9) !important;
font-size: 12.5px;
text-shadow: 0 1px 2px rgba(22, 4, 31, 0.4);
text-shadow: 0 1px 2px rgba(6, 9, 18, 0.4);
}
.remember :deep(.el-checkbox__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
border-color: rgba(255, 255, 255, 0.32);
}
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
background: var(--mg-primary);
border-color: var(--mg-primary);
background: var(--lg-primary);
border-color: var(--lg-primary);
}
.adv-link { color: rgba(232, 215, 245, 0.82) !important; font-size: 12.5px; }
.adv-link { color: var(--lg-text-soft) !important; font-size: 12.5px; }
.adv-link:hover { color: #fff !important; }
/* 高级折叠 */
@@ -589,19 +680,19 @@ async function submit() {
.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;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--lg-text-soft) !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);
color: var(--lg-text-soft);
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;
@@ -611,12 +702,13 @@ async function submit() {
font-size: 15px;
letter-spacing: 6px;
font-weight: 600;
background: linear-gradient(135deg, var(--mg-primary) 0%, var(--mg-primary-hover) 100%) !important;
color: #fff !important;
background: linear-gradient(135deg, var(--lg-primary) 0%, var(--lg-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;
0 12px 28px rgba(var(--lg-primary-rgb), 0.5),
0 0 26px rgba(var(--lg-primary-hover-rgb), 0.36),
0 0 0 1px rgba(255, 255, 255, 0.20) inset !important;
transition: all .28s cubic-bezier(.25, .8, .25, 1);
}
.btn-login::after {
@@ -627,7 +719,7 @@ async function submit() {
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.38) 50%,
rgba(255, 255, 255, 0.0) 70%,
transparent 100%);
transition: left .6s cubic-bezier(.25, .8, .25, 1);
@@ -635,18 +727,18 @@ async function submit() {
}
.btn-login:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, var(--mg-primary-hover) 0%, var(--mg-primary-light-3) 100%) !important;
background: linear-gradient(135deg, var(--lg-primary-hover) 0%, var(--lg-accent) 130%) !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;
0 18px 38px rgba(var(--lg-primary-rgb), 0.6),
0 0 42px rgba(var(--lg-accent-rgb), 0.5),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
}
.btn-login:hover::after { left: 120%; }
.bottom-note {
margin-top: 16px;
font-size: 11.5px;
color: rgba(232, 215, 245, 0.55);
color: var(--lg-text-dim);
text-align: center;
line-height: 1.6;
}
@@ -657,8 +749,8 @@ async function submit() {
bottom: 18px; left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 1.5px;
color: rgba(232, 215, 245, 0.4);
letter-spacing: 2px;
color: rgba(45, 32, 78, 0.6);
z-index: 2;
}
@@ -669,9 +761,9 @@ async function submit() {
}
/* 小屏自适应 */
@media (max-width: 720px) {
@media (max-width: 760px) {
.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; }
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding: 28px; }
.panel { padding: 28px; }
.hero-points, .hero-divider { display: none; }
}
@@ -0,0 +1,364 @@
<template>
<div class="wizard-page">
<div class="wz-orb wz-orb-a" aria-hidden="true" />
<div class="wz-orb wz-orb-b" aria-hidden="true" />
<div class="wz-card">
<header class="wz-head">
<div class="wz-brand">
<div class="wz-mark">
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="wz-mark-img" />
</div>
<div class="wz-titles">
<div class="wz-title">平台配置向导</div>
<div class="wz-sub">按需选择导航方式与功能模块系统据此裁剪界面并按需加载内核能力</div>
</div>
</div>
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
</header>
<div v-if="loading" class="wz-loading">正在加载配置选项</div>
<div v-else class="wz-grid">
<div class="wz-main">
<section class="wz-section">
<div class="wz-section-head">
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 </span>
</div>
<div class="chip-grid">
<button
v-for="o in options?.navigationKinds ?? []" :key="o.id" type="button"
class="chip" :class="{ on: sel.navigationKinds.includes(o.id) }"
@click="toggle(sel.navigationKinds, o.id)">
<div class="chip-name">{{ o.name }}</div>
<div class="chip-desc">{{ o.description }}</div>
</button>
</div>
</section>
<section class="wz-section">
<div class="wz-section-head"><el-icon><Box /></el-icon><h3>功能模块</h3></div>
<div class="chip-grid">
<button
v-for="o in options?.modules ?? []" :key="o.id" type="button"
class="chip" :class="{ on: sel.modules.includes(o.id) }"
@click="toggle(sel.modules, o.id)">
<div class="chip-name">{{ o.name }}</div>
<div class="chip-desc">{{ o.description }}</div>
</button>
</div>
</section>
<section v-if="scenarioTemplates.length" class="wz-section">
<div class="wz-section-head"><el-icon><Histogram /></el-icon><h3>业务场景</h3></div>
<div class="chip-grid">
<button
v-for="t in scenarioTemplates" :key="t.id" type="button"
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
@click="toggle(sel.scenarios, t.id)">
<div class="chip-name">{{ t.name }}</div>
<div class="chip-desc">{{ t.category }}</div>
</button>
</div>
</section>
</div>
<aside class="wz-summary">
<div class="sum-title">已选概览</div>
<div class="sum-row"><span>导航方式</span><b>{{ sel.navigationKinds.length }}</b></div>
<div class="sum-row"><span>功能模块</span><b>{{ sel.modules.length }}</b></div>
<div class="sum-row"><span>业务场景</span><b>{{ sel.scenarios.length }}</b></div>
<div class="sum-divider" />
<div class="sum-label">将激活的内核场景插件</div>
<div class="sum-scenes">
<el-tag v-for="s in activeScenes" :key="s" size="small" effect="dark" class="sum-tag">{{ s }}</el-tag>
<span v-if="!activeScenes.length" class="sum-empty">请先选择导航方式</span>
</div>
</aside>
</div>
<footer class="wz-foot">
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
<div class="foot-right">
<span class="foot-hint">保存后写入部署画像并联动 SimpleLite 选择性加载导航场景</span>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>完成并进入平台
</el-button>
</div>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Compass, Box, Histogram, Check } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { getWizardOptions, getWizardProfile, saveWizardProfile } from '@/api/wizard'
import type { WizardOptions, ScenarioTemplateLite } from '@/types/wizard'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const options = ref<WizardOptions | null>(null)
const sel = reactive({
platformType: 'standard',
navigationKinds: [] as string[],
modules: [] as string[],
scenarios: [] as string[]
})
const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.scenarios?.templates ?? [])
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.magnetic',
qrcode: 'scene.qrcode',
laser: 'scene.laser'
}
const activeScenes = computed(() => sel.navigationKinds.map((k) => NAV_SCENE[k] ?? `scene.${k}`))
const canSave = computed(() => sel.navigationKinds.length > 0)
function toggle(list: string[], id: string) {
const i = list.indexOf(id)
if (i >= 0) list.splice(i, 1)
else list.push(id)
}
onMounted(async () => {
try {
const [opt, profile] = await Promise.all([getWizardOptions(), getWizardProfile()])
options.value = opt
sel.platformType = profile.platformType || 'standard'
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
// WMS 为暂定保留的核心仓储模块,首次进入默认勾选,避免用户误漏。
sel.modules = profile.modules?.length ? [...profile.modules] : ['wms']
sel.scenarios = [...(profile.scenarios ?? [])]
} catch (e) {
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
})
async function save() {
if (!canSave.value) {
ElMessage.warning('请至少选择一种导航方式')
return
}
saving.value = true
try {
await saveWizardProfile({
platformType: sel.platformType,
modules: sel.modules,
navigationKinds: sel.navigationKinds,
scenarios: sel.scenarios
})
auth.markWizardDone()
ElMessage.success('部署配置已保存')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
function onLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.wizard-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 24px;
background:
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
}
.wz-orb {
position: absolute; border-radius: 50%; filter: blur(110px); pointer-events: none; opacity: 0.28;
}
.wz-orb-a {
width: 480px; height: 480px; top: -180px; left: -140px;
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary) 55%, transparent 85%);
}
.wz-orb-b {
width: 560px; height: 560px; bottom: -220px; right: -180px;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary-deep) 55%, transparent 88%);
}
.wz-card {
position: relative;
z-index: 2;
width: 1040px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
border-radius: 18px;
overflow: hidden;
/* 固定深紫玻璃底(与登录页一致,不随主题切换) */
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(135deg, rgba(var(--lg-card-rgb), 0.92) 0%, rgba(20, 12, 38, 0.95) 100%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.6),
0 0 80px rgba(var(--lg-primary-rgb), 0.4),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.28) inset;
}
.wz-head {
display: flex; align-items: center; justify-content: space-between;
padding: 22px 28px;
border-bottom: 1px solid rgba(var(--lg-accent-rgb), 0.18);
background: linear-gradient(180deg, rgba(var(--lg-primary-rgb), 0.18) 0%, transparent 100%);
}
.wz-brand { display: flex; align-items: center; gap: 14px; }
.wz-mark {
height: 46px; min-width: 46px;
padding: 0 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.55) 0%, rgba(var(--lg-accent-rgb), 0.30) 100%);
border: 1px solid rgba(255, 255, 255, 0.20);
display: inline-flex; align-items: center; justify-content: center;
box-shadow:
0 10px 26px rgba(var(--lg-primary-rgb), 0.5),
0 0 24px rgba(var(--lg-accent-rgb), 0.28),
0 0 0 1px rgba(255, 255, 255, 0.16) inset;
}
.wz-mark-img {
display: block;
height: 28px;
width: auto;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35));
}
.wz-titles { display: flex; flex-direction: column; gap: 4px; }
.wz-title {
font-size: 20px; font-weight: 700; letter-spacing: 2px; color: #fff;
}
.wz-sub { font-size: 12.5px; color: rgba(232, 215, 245, 0.65); }
.wz-user {
font-size: 13px; color: rgba(255, 255, 255, 0.8);
padding: 5px 12px; border-radius: 999px;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14);
}
.wz-loading {
padding: 80px; text-align: center; color: rgba(255, 255, 255, 0.7); font-size: 14px;
}
.wz-grid {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1fr 280px;
gap: 0;
}
.wz-main {
overflow-y: auto;
padding: 22px 26px;
display: flex; flex-direction: column; gap: 22px;
}
.wz-section-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
color: #fff;
}
.wz-section-head .el-icon { font-size: 18px; color: var(--lg-accent); }
.wz-section-head h3 { margin: 0; font-size: 15px; font-weight: 600; }
.wz-section-head .req {
font-size: 11px; color: var(--lg-accent);
padding: 1px 8px; border-radius: 8px;
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
}
.chip-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
}
.chip {
appearance: none; cursor: pointer; text-align: left;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
padding: 12px 14px;
color: rgba(255, 255, 255, 0.78);
transition: all .18s ease;
}
.chip:hover {
background: rgba(255, 255, 255, 0.1); color: #fff; transform: translateY(-1px);
}
.chip.on {
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.6) 0%, rgba(var(--lg-primary-hover-rgb), 0.4) 100%);
border-color: rgba(var(--lg-accent-rgb), 0.85);
color: #fff;
box-shadow: 0 0 20px rgba(var(--lg-primary-hover-rgb), 0.5), 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset;
}
.chip-name { font-size: 14px; font-weight: 600; }
.chip-desc { font-size: 11.5px; opacity: 0.75; margin-top: 4px; line-height: 1.5; }
.wz-summary {
border-left: 1px solid rgba(var(--lg-accent-rgb), 0.16);
background: rgba(0, 0, 0, 0.18);
padding: 22px 20px;
overflow-y: auto;
}
.sum-title { font-size: 13px; font-weight: 600; color: #fff; margin-bottom: 14px; letter-spacing: 1px; }
.sum-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: rgba(255, 255, 255, 0.75); padding: 7px 0;
}
.sum-row b { color: var(--lg-accent); font-size: 15px; font-variant-numeric: tabular-nums; }
.sum-divider { height: 1px; background: rgba(255, 255, 255, 0.12); margin: 14px 0; }
.sum-label { font-size: 12px; color: rgba(232, 215, 245, 0.65); margin-bottom: 10px; }
.sum-scenes { display: flex; flex-wrap: wrap; gap: 6px; }
.sum-tag {
font-family: var(--mg-font-mono);
/* 锁定固定紫,不随 Element Plus 主题色变化 */
background: rgba(var(--lg-primary-rgb), 0.30) !important;
border-color: rgba(var(--lg-accent-rgb), 0.5) !important;
color: #fff !important;
}
.sum-empty { font-size: 12px; color: rgba(255, 255, 255, 0.4); }
.wz-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 26px;
border-top: 1px solid rgba(var(--lg-accent-rgb), 0.18);
background: rgba(var(--lg-aside-rgb), 0.4);
}
.logout-btn { color: rgba(255, 255, 255, 0.6) !important; }
.foot-right { display: flex; align-items: center; gap: 16px; }
.foot-hint { font-size: 12px; color: rgba(232, 215, 245, 0.55); }
.btn-ic { margin-right: 4px; }
/* 「完成并进入平台」主按钮锁定固定紫色(与登录按钮同款,不随主题切换) */
.wz-foot :deep(.el-button--primary) {
--el-button-bg-color: var(--lg-primary);
--el-button-border-color: var(--lg-primary);
--el-button-hover-bg-color: var(--lg-primary-hover);
--el-button-hover-border-color: var(--lg-primary-hover);
--el-button-active-bg-color: var(--lg-primary-deep);
--el-button-active-border-color: var(--lg-primary-deep);
color: #fff;
box-shadow: 0 8px 20px rgba(var(--lg-primary-rgb), 0.45);
}
@media (max-width: 880px) {
.wz-grid { grid-template-columns: 1fr; }
.wz-summary { border-left: none; border-top: 1px solid rgba(var(--lg-accent-rgb), 0.16); }
}
</style>
@@ -55,6 +55,28 @@
@delete="deleteSelection"
@patch-viewport="onPatchViewport"
/>
<!-- 右侧AI 助手悬浮入口手柄 + 固定停靠聊天面板实底可发送文字需求 -->
<button
class="ai-assistant-fab"
:class="{ 'is-open': aiAssistantOpen }"
:style="aiAssistantOpen ? { right: aiPanelWidth + 'px' } : undefined"
type="button"
:title="aiAssistantOpen ? '收起 AI 助手' : '打开 AI 助手'"
@click="toggleAiAssistant"
>
<span class="fab-glyph"></span>
<span class="fab-text">AI<br>助手</span>
</button>
<AiAssistantPanel
v-model:open="aiAssistantOpen"
v-model:width="aiPanelWidth"
:configured="aiConfigured"
:default-bounds="defaultAiBounds"
:default-layer="defaults.site.layer"
@generated="onAiGenerated"
/>
</div>
<EditStatusBar
@@ -76,13 +98,6 @@
@generated="onAiGenerated"
/>
<ProjectBrowseDialog
v-model="projectBrowseOpen"
:mode="projectBrowseMode"
:initial-path="projectBrowseInitial"
@confirm="onProjectBrowseConfirm"
/>
<!--
图层显示切换对话框列出所有已知图层每行一个 visible/selectable 开关 +
设为当前默认层按钮本面板与右侧 EditPropertyPanel 的图层卡片共享同一份
@@ -138,6 +153,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import Workspace3D from '@/components/Workspace3D.vue'
import EditTopBar, { type ViewFilterState } from '@/components/map-editor/EditTopBar.vue'
@@ -145,7 +161,7 @@ import EditToolRail from '@/components/map-editor/EditToolRail.vue'
import EditPropertyPanel from '@/components/map-editor/EditPropertyPanel.vue'
import EditStatusBar from '@/components/map-editor/EditStatusBar.vue'
import AiGenerateDialog from '@/components/map-editor/AiGenerateDialog.vue'
import ProjectBrowseDialog from '@/components/map-editor/ProjectBrowseDialog.vue'
import AiAssistantPanel from '@/components/map-editor/AiAssistantPanel.vue'
import { useAuthStore } from '@/stores/auth'
import { useEditTool, type EditToolId } from '@/composables/useEditTool'
import { useSelection, type SelectionItem } from '@/composables/useSelection'
@@ -154,7 +170,7 @@ import { useCanvasBridge } from '@/composables/useCanvasBridge'
import { useMapEditStream } from '@/composables/useMapEditStream'
import { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
import { mapEditApi, aiConfigApi } from '@/api/mapEdit'
import { mapEditApi, aiConfigApi, mapsApi } from '@/api/mapEdit'
import {
reflectionApi,
normalizeViewportPayload,
@@ -167,8 +183,14 @@ import {
} from '@/api/reflection'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
const editingMapName = ref<string>('')
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const fileAcceptHint = ref<string>('image/*')
@@ -181,13 +203,15 @@ const history = useHistory({})
const mouseXY = reactive({ x: 0, y: 0 })
const aiDialogOpen = ref(false)
// 右侧固定停靠的「AI 助手」聊天面板开关(与 AI 生图对话框相互独立)。
// 开关状态与面板宽度都持久化到 localStorage,下次进入编辑器自动恢复。
const aiAssistantOpen = ref(false)
const aiPanelWidth = ref(360)
const AI_PANEL_OPEN_KEY = 'mapEditor.aiAssistant.open'
const AI_PANEL_WIDTH_KEY = 'mapEditor.aiAssistant.width'
const aiConfigured = ref(false)
const defaultAiBounds = ref<[number, number, number, number]>([-10000, -10000, 10000, 10000])
const projectBrowseOpen = ref(false)
const projectBrowseMode = ref<'load' | 'save'>('load')
const projectBrowseInitial = ref<string>('')
const saving = ref(false)
const propertyLoading = ref(false)
@@ -377,10 +401,7 @@ async function onTopBarCmd(cmd: string) {
case 'edit.delete': await deleteSelection(); break
case 'edit.selectAll': ElMessage.info('全选:请使用工具栏「按类型选」批量选中'); break
case 'edit.invert': ElMessage.info('反选功能后续实现'); break
case 'project.save': await onProjectSave(false); break
case 'project.open': await onProjectOpen(); break
case 'project.saveAs': await onProjectSave(true); break
case 'project.close': ElMessage.info('关闭项目:使用 SimpleLite 主菜单'); break
case 'project.save': await saveMapAndLeave(); break
case 'ai.open': await openAiDialog(); break
case 'file.upload.image': triggerUpload('image'); break
case 'file.upload.model': triggerUpload('model'); break
@@ -1323,6 +1344,35 @@ async function openAiDialog() {
aiDialogOpen.value = true
}
// 恢复「AI 助手」面板的开关状态与宽度(来自上次会话的 localStorage)。
function restoreAiPanelPrefs() {
try {
aiAssistantOpen.value = localStorage.getItem(AI_PANEL_OPEN_KEY) === '1'
const w = Number(localStorage.getItem(AI_PANEL_WIDTH_KEY))
if (Number.isFinite(w) && w >= 300 && w <= 720) aiPanelWidth.value = w
} catch { /* localStorage 不可用则用默认值 */ }
}
watch(aiAssistantOpen, (v) => {
try { localStorage.setItem(AI_PANEL_OPEN_KEY, v ? '1' : '0') } catch { /* ignore */ }
})
watch(aiPanelWidth, (v) => {
try { localStorage.setItem(AI_PANEL_WIDTH_KEY, String(Math.round(v))) } catch { /* ignore */ }
})
// 右侧「AI 助手」入口:展开前刷新一次 AI 配置状态,保证「未配置」提示与后端一致。
async function toggleAiAssistant() {
if (!aiAssistantOpen.value) {
try {
const cfg = await aiConfigApi.get()
aiConfigured.value = !!cfg.configured
} catch {
aiConfigured.value = false
}
}
aiAssistantOpen.value = !aiAssistantOpen.value
}
async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
refreshAfterMutation()
@@ -1352,60 +1402,49 @@ async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
// 项目保存:调反射 API 触发 SimpleProject.Save (如有 MethodMember 暴露)
// ──────────────────────────────────────────────────────────────────────────
async function onProjectSave(saveAs: boolean) {
const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''
// 非另存为且有缓存:直接覆盖保存到上次路径,无需弹对话框。
if (!saveAs && cached) {
saving.value = true
try {
const r = await mapEditApi.projectSave(cached)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已保存:${r.path}`)
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
return
}
// 另存为或首次保存:弹服务端目录浏览对话框(项目文件保存在 SimpleLite 服务端)。
projectBrowseMode.value = 'save'
projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
projectBrowseOpen.value = true
/** 生成「当前时间」默认地图名(文件名安全,不含冒号等非法字符)。 */
function defaultTimeMapName(): string {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
async function onProjectOpen() {
const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''
projectBrowseMode.value = 'load'
projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
projectBrowseOpen.value = true
}
/**
* 保存当前场景到地图管理统一目录,成功后返回地图管理页。
* - 编辑已有地图:直接覆盖保存(同名冲突时确认替换);
* - 新建地图:用当前时间自动命名。
*/
async function saveMapAndLeave() {
const name = editingMapName.value || defaultTimeMapName()
async function onProjectBrowseConfirm(path: string) {
if (!path) return
if (projectBrowseMode.value === 'save') {
saving.value = true
try {
const r = await mapEditApi.projectSave(path)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已保存:${r.path}`)
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
return
}
saving.value = true
try {
const r = await mapEditApi.projectLoad(path)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已加载:${r.sites} 站点 / ${r.tracks} 路径 / ${r.specials} 装饰 / ${r.missions} 任务`)
selection.clear()
primary.value = null
let outcome = await mapsApi.save(name, false)
if (!outcome.ok && outcome.conflict) {
try {
await ElMessageBox.confirm(
`地图「${name}」已存在,是否替换原地图文件?`,
'替换确认',
{ type: 'warning', confirmButtonText: '替换', cancelButtonText: '取消' }
)
} catch {
return
}
outcome = await mapsApi.save(name, true)
}
if (!outcome.ok) {
ElMessage.error(`保存失败:${outcome.message}`)
return
}
editingMapName.value = outcome.data.name
ElMessage.success(`已保存地图「${outcome.data.name}`)
await router.push({ path: '/admin/maps' })
} catch (err) {
ElMessage.error(`加载失败:${(err as Error).message}`)
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
}
@@ -1583,11 +1622,36 @@ onMounted(async () => {
if (cached) {
try { Object.assign(defaults, JSON.parse(cached)) } catch { /* ignore */ }
}
restoreAiPanelPrefs()
loadViewFilter()
await syncViewFilterFromBackend()
await loadViewportStyle()
await loadFromRouteQuery()
})
/**
* 从地图管理页跳转进来时的初始加载:
* - ?map=<name>:加载该地图到场景供编辑,并记录原名(保存默认沿用 + 替换确认);
* - ?new=1:新建地图,仅清空「正在编辑的地图名」,保存时按时间默认命名。
*/
async function loadFromRouteQuery() {
const mapName = typeof route.query.map === 'string' ? route.query.map : ''
const isNew = route.query.new === '1'
if (mapName) {
try {
const r = await mapsApi.open(mapName)
editingMapName.value = r.name
selection.clear()
primary.value = null
ElMessage.success(`已载入地图「${r.name}」:${r.sites} 站点 / ${r.tracks} 路径`)
} catch (err) {
ElMessage.error(`载入地图失败:${(err as Error).message}`)
}
} else if (isNew) {
editingMapName.value = ''
}
}
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
@@ -1605,7 +1669,51 @@ onUnmounted(() => {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* AI 助手悬浮入口:贴右侧边缘的竖向手柄;面板展开时随面板左移变成其左沿把手。 */
.ai-assistant-fab {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
z-index: 31;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
width: 42px;
padding: 14px 0;
border: none;
border-radius: 12px 0 0 12px;
cursor: pointer;
color: #fff;
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(255, 90, 200, 0.92) 100%);
box-shadow: -4px 0 16px rgba(120, 40, 200, 0.45);
transition: right 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), filter 0.15s ease, box-shadow 0.15s ease;
}
.ai-assistant-fab:hover {
filter: brightness(1.08);
box-shadow: -6px 0 22px rgba(150, 60, 230, 0.6);
}
/* 展开时 right 偏移由内联样式按面板实际宽度设置(默认 360);此处仅作兜底。 */
.ai-assistant-fab.is-open {
right: 360px;
}
.ai-assistant-fab .fab-glyph {
font-size: 18px;
line-height: 1;
text-shadow: 0 0 10px rgba(255, 220, 255, 0.85);
}
.ai-assistant-fab .fab-text {
font-size: 12px;
font-weight: 700;
line-height: 1.18;
letter-spacing: 1px;
text-align: center;
}
.me-center {
flex: 1;
min-width: 0;
@@ -0,0 +1,279 @@
<template>
<div class="map-mgr-page">
<el-tabs v-model="activeTab" type="border-card" class="map-mgr-tabs admin-tabs">
<el-tab-pane label="服务器地图" name="maps">
<div class="mm-section">
<div class="map-mgr-header">
<span class="subtitle">
统一维护后台所有地图资源固定文件夹 / JSON可新增 / 使用 / 重命名 / 编辑 / 删除
</span>
<div class="actions">
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button type="primary" @click="onCreate">新增地图</el-button>
</div>
</div>
<el-alert
v-if="directory"
class="dir-tip"
type="info"
:closable="false"
show-icon
>
<template #title>
地图文件夹<code>{{ directory }}</code>
<span v-if="currentName"> · 当前使用<strong>{{ currentName }}</strong></span>
<span v-else> · 当前未设置默认使用地图</span>
</template>
</el-alert>
<el-table
v-loading="loading"
:data="maps"
class="map-table"
border
:row-class-name="rowClassName"
empty-text="固定文件夹内还没有地图点击右上角新增地图创建"
>
<el-table-column label="地图名称" min-width="220">
<template #default="{ row }">
<span class="map-name">{{ row.name }}</span>
<el-tag v-if="row.isCurrent" size="small" type="success" effect="dark" class="current-tag">
使用中
</el-tag>
</template>
</el-table-column>
<el-table-column label="修改时间" prop="modified" width="190" />
<el-table-column label="操作" width="360" align="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
:disabled="row.isCurrent"
:loading="busyName === row.name"
@click="onUse(row)"
>
{{ row.isCurrent ? '使用中' : '使用' }}
</el-button>
<el-button size="small" @click="onRename(row)">重命名</el-button>
<el-button size="small" @click="onEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="多地图连接管理" name="connections">
<MapConnectionPanel />
</el-tab-pane>
<el-tab-pane label="地图合并" name="merge">
<MapMergePanel @merged="onMerged" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mapsApi, type MapListItem } from '@/api/mapEdit'
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
const router = useRouter()
const activeTab = ref<'maps' | 'connections' | 'merge'>('maps')
function onMerged() {
activeTab.value = 'maps'
refresh()
}
const loading = ref(false)
const maps = ref<MapListItem[]>([])
const directory = ref('')
const currentName = ref('')
const busyName = ref('')
function rowClassName({ row }: { row: MapListItem }) {
return row.isCurrent ? 'current-row' : ''
}
async function refresh() {
loading.value = true
try {
const r = await mapsApi.list()
maps.value = r.maps
directory.value = r.directory
const cur = r.maps.find((m) => m.isCurrent)
currentName.value = cur?.name ?? ''
} catch (err) {
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
function onCreate() {
// 新建地图:跳转地图编辑界面(空场景),保存时按时间默认命名。
router.push({ path: '/admin/map-editor', query: { new: '1' } })
}
function onEdit(row: MapListItem) {
// 编辑地图:跳转编辑界面并加载该地图,保存时默认替换原地图。
router.push({ path: '/admin/map-editor', query: { map: row.name } })
}
async function onUse(row: MapListItem) {
if (row.isCurrent) return
busyName.value = row.name
try {
// 先做一次场景任务校验,给出更友好的明细提示;后端 use 仍会再次校验(防并发竞态)。
const status = await mapsApi.sceneTaskStatus()
if (status.hasTask) {
const detail = status.busyCars
.slice(0, 5)
.map((c) => `${c.name}(#${c.id})${c.reasons.join('、')}`)
.join('\n')
await ElMessageBox.alert(
`场景内仍有 ${status.busyCount} 台车辆存在任务,无法切换当前地图。\n\n${detail}`,
'禁止切换地图',
{ type: 'warning', confirmButtonText: '我知道了' }
)
return
}
await ElMessageBox.confirm(
`确认将「${row.name}」设为当前项目默认使用的地图?切换会重新加载场景。`,
'切换使用地图',
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
)
const r = await mapsApi.use(row.name)
ElMessage.success(`已切换到「${r.name}」:${r.sites} 站点 / ${r.tracks} 路径`)
await refresh()
} catch (err) {
// 取消确认框不提示;其余(含后端 409 任务校验)提示错误文案。
if (err === 'cancel' || err === 'close') return
ElMessage.error(`切换失败:${(err as Error).message}`)
} finally {
busyName.value = ''
}
}
const MAP_NAME_PATTERN = /^[^\\/:*?"<>|]+$/
async function onRename(row: MapListItem) {
try {
const r = await ElMessageBox.prompt('请输入新的地图名称', '重命名地图', {
inputValue: row.name,
inputPattern: MAP_NAME_PATTERN,
inputErrorMessage: '名称不能包含 \\ / : * ? " < > | 等字符',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
const to = (r.value ?? '').trim()
if (!to || to === row.name) return
busyName.value = row.name
await mapsApi.rename(row.name, to)
ElMessage.success(`已重命名为「${to}`)
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`重命名失败:${(err as Error).message}`)
} finally {
busyName.value = ''
}
}
async function onDelete(row: MapListItem) {
try {
await ElMessageBox.confirm(
`确认删除地图「${row.name}」?该操作会从固定文件夹中移除对应 JSON 文件,不可恢复。`,
'删除地图',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
await mapsApi.delete(row.name)
ElMessage.success(`已删除「${row.name}`)
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`删除失败:${(err as Error).message}`)
}
}
onMounted(refresh)
</script>
<style scoped>
.map-mgr-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* Tab 容器撑满主区;玻璃表面 / 选项卡样式由全局 .admin-tabs 提供(与场景管理一致),
* 在星云紫等深色主题下给到不透明深紫底 + 白字,挡住 AppShell 的高亮光球,保证可读。 */
.map-mgr-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 单个 Tab 内容区:纵向铺满,表格自适应高度 */
.mm-section {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.map-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.subtitle {
font-size: 12.5px;
line-height: 1.5;
color: var(--mg-text-muted);
}
.dir-tip {
flex-shrink: 0;
}
.dir-tip code {
font-family: var(--mg-font-mono, monospace);
padding: 1px 6px;
border-radius: 4px;
background: var(--mg-veil-2);
color: var(--mg-text-light);
}
.map-table {
flex: 1;
min-height: 0;
}
.map-name {
font-weight: 500;
color: var(--mg-text-light);
}
.current-tag {
margin-left: 8px;
}
/* 「使用中」行高亮:用主题感知的成功色微透叠加,替换原固定浅绿 --el-color-success-light-9
(在星云紫深底主题下「浅绿底 + 白字」几乎不可读)。深/浅两套主题下均保持清晰对比。 */
.map-table :deep(.current-row) > td.el-table__cell {
background: rgba(var(--mg-status-success-rgb), 0.16) !important;
}
.map-table :deep(.current-row):hover > td.el-table__cell {
background: rgba(var(--mg-status-success-rgb), 0.24) !important;
}
</style>
@@ -92,6 +92,7 @@ import { listDeliveries } from '@/api/delivery'
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
import { reflectionApi } from '@/api/reflection'
import { workspaceToolbarApi } from '@/api/workspaceToolbar'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { DeliveryTask } from '@/types/delivery'
@@ -128,8 +129,7 @@ const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const alarms = ref<AlarmEvent[]>([])
async function onAlarmLocate(a: AlarmEvent) {
// 通过 reflectionApi.setSelection 把 SimpleLite 3D 场景的高亮切到该车,
// CycleGUI 默认会把相机聚焦到 GetPosition 命中目标——视觉上等同于 flyTo。
// 点击浮动报警的「定位」:同步 3D 高亮 + 立即把相机定位到该报警车辆(与车辆监控台选中一致)。
// a.carId 已经是 numberAlarmEvent 类型保证),无须额外字符串处理;如果将来后端
// 改成字符串,这里 Number(...) 会返回 NaN 并由下面的 isFinite 拦下来。
const numId = Number(a.carId)
@@ -137,12 +137,8 @@ async function onAlarmLocate(a: AlarmEvent) {
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}`)
}
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
await selectAndLocateVehicle(numId)
}
function onAlarmAck(_a: AlarmEvent) {
// 当前后端无 ACK 接口,前端本地标记即可(FloatingAlarmStack 已处理)
@@ -342,18 +338,31 @@ async function fallbackSelectionFromBackend() {
}
}
/**
* 选中并定位到指定车辆:① 同步 SimpleLite 3D 场景高亮;② 立即把相机定位(居中 + 2D 俯视)
* 到该车,与原生「双击车辆行 = 选中+定位」一致。两个请求互不阻塞,各自独立提示错误
* (例如 iframe 未连接时相机定位会 503,但高亮仍可成功)。
*/
async function selectAndLocateVehicle(id: number) {
if (!Number.isFinite(id)) return
const [selRes, locRes] = await Promise.allSettled([
reflectionApi.setSelection('car', id),
workspaceToolbarApi.locateCamera(id)
])
if (selRes.status === 'rejected') {
ElMessage.error({ message: `同步 3D 选中失败:${(selRes.reason as Error).message}`, grouping: true })
}
if (locRes.status === 'rejected') {
ElMessage.error({ message: `定位车辆失败:${(locRes.reason as Error).message}`, grouping: true })
}
}
async function onVehicleSelect(ref: SelectedObjectRef) {
selectedDeliveryId.value = null
if (!sameSelection(selection.value, ref)) {
selection.value = ref
}
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}`)
}
await selectAndLocateVehicle(Number(ref.id))
}
async function onDeliverySelect(task: DeliveryTask) {
@@ -364,16 +373,12 @@ async function onDeliverySelect(task: DeliveryTask) {
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
return
}
try {
await reflectionApi.setSelection('car', task.carId)
selection.value = {
kind: 'vehicle',
id: String(task.carId),
name: task.carName ?? `Vehicle ${task.carId}`
}
} catch (err) {
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
selection.value = {
kind: 'vehicle',
id: String(task.carId),
name: task.carName ?? `Vehicle ${task.carId}`
}
await selectAndLocateVehicle(task.carId)
return
}
selection.value = {
@@ -2,12 +2,12 @@
<ConfigPageBase
section="integrations"
title="外部系统对接"
description="MES / WMS / RCS 等标准接口配置(ExternalIntegrations"
description="MES / WMS / RCS / PTL 等标准接口配置(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-tab-pane v-for="kind in (['mes','wms','rcs','ptl'] 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" />
@@ -41,13 +41,13 @@ import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_INTEGRATIONS } from '@/mock/data/configs'
import type { ExternalIntegrations } from '@/types/config'
type Kind = 'mes' | 'wms' | 'rcs'
type Kind = 'mes' | 'wms' | 'rcs' | 'ptl'
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 }]
[kind]: [...(payload[kind] ?? []), { id, name: '新端点', url: 'http://', enabled: false }]
}
update(next)
}
@@ -55,7 +55,7 @@ function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) =>
function remove(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind, idx: number) {
const next: ExternalIntegrations = {
...payload,
[kind]: payload[kind].filter((_, i) => i !== idx)
[kind]: (payload[kind] ?? []).filter((_, i) => i !== idx)
}
update(next)
}
@@ -5,20 +5,14 @@
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-alert
type="info"
:closable="false"
show-icon
title="车队分配已移至「车辆运维 → 运维总览」"
description="在运维总览中可新建车队、设定名称/区域/楼层,并将车辆分配到各车队。"
style="margin-bottom: 12px" />
<el-tabs model-value="ota">
<el-tab-pane name="ota" label="OTA 升级">
<el-descriptions :column="2" border>
<el-descriptions-item label="启用">{{ payload.ota.enabled ? '是' : '否' }}</el-descriptions-item>
@@ -39,8 +39,8 @@
<el-select v-model="filterState" size="small" clearable placeholder="状态" class="filter-select">
<el-option v-for="opt in stateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filterGroup" size="small" clearable placeholder="群组" class="filter-select">
<el-option v-for="g in groupOptions" :key="g" :label="g" :value="g" />
<el-select v-model="filterFleet" size="small" clearable placeholder="车队" class="filter-select fleet-filter">
<el-option v-for="g in fleetFilterOptions" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
<el-dropdown :disabled="!canWrite || !selectedIds.length" @command="onBatchCommand">
<el-button size="small" :disabled="!canWrite || !selectedIds.length">
@@ -74,6 +74,8 @@
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
</div>
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计</p>
</div>
</el-tab-pane>
@@ -94,14 +96,16 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
import type { CarState } from '@/types/car'
import { useAuthStore } from '@/stores/auth'
@@ -140,9 +144,12 @@ const {
refreshAll
} = useVehicleHub()
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
onMounted(() => void reloadFleetGroups())
const search = ref('')
const filterState = ref<CarState | ''>('')
const filterGroup = ref('')
const filterFleet = ref('')
const stateOptions: { value: CarState; label: string }[] = [
{ value: 'idle', label: '空闲' },
@@ -153,22 +160,33 @@ const stateOptions: { value: CarState; label: string }[] = [
{ value: 'offline', label: '离线' }
]
const groupOptions = computed(() => {
const set = new Set<string>()
for (const c of cardModels.value) {
if (c.group) set.add(c.group)
}
return [...set]
})
const fleetFilterOptions = computed(() =>
fleetGroups.value.map((g) => ({
value: g.id,
label: g.region ? `${g.name}${g.region}` : g.name || g.id
}))
)
function onFleetSaved() {
void reloadFleetGroups(true)
}
function matchesFleetFilter(carId: string): boolean {
if (!filterFleet.value) return true
const fleet = fleetGroups.value.find((g) => g.id === filterFleet.value)
return fleet ? fleet.carIds.includes(carId) : false
}
const filteredCards = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return cardModels.value.filter((c) => {
if (filterState.value && c.state !== filterState.value) return false
if (filterGroup.value && c.group !== filterGroup.value) return false
if (!matchesFleetFilter(c.id)) return false
if (!tokens.length) return true
const hay = [c.id, c.name, c.ip, c.group, c.lstatus].filter(Boolean).join(' ').toLowerCase()
const fleetName = fleetNameForCarId(c.id)
const region = regionForCarId(c.id)
const hay = [c.id, c.name, c.ip, c.group, fleetName, region, c.lstatus].filter(Boolean).join(' ').toLowerCase()
return tokens.every((t) => hay.includes(t))
})
})
@@ -295,6 +313,10 @@ async function onBatchCommand(cmd: string) {
width: 120px;
}
.fleet-filter {
width: 160px;
}
.card-grid {
flex: 1;
overflow: auto;