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:
@@ -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 已经是 number(AlarmEvent 类型保证),无须额外字符串处理;如果将来后端
|
||||
// 改成字符串,这里 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 = {
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user