登录固定 WebOnly,并增强部署向导流程。

移除启动模式切换与 ScopeSwitcher;向导拆分子组件并补充 setup 工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:48:51 +08:00
co-authored by Cursor
parent 79c9e36d89
commit 59ccd7934a
6 changed files with 868 additions and 346 deletions
-1
View File
@@ -80,7 +80,6 @@ declare module 'vue' {
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScopeSwitcher: typeof import('./src/components/ScopeSwitcher.vue')['default']
SelectionDetailPanel: typeof import('./src/components/workbench/SelectionDetailPanel.vue')['default']
SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default']
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
@@ -1,42 +0,0 @@
<template>
<el-radio-group :model-value="current" size="small" @change="onChange">
<el-radio-button value="Platform">管理员</el-radio-button>
<el-radio-button value="RCSMonitor">运营</el-radio-button>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { Scope } from '@/types/auth'
const auth = useAuthStore()
const router = useRouter()
// 单向受控 + 仅 @change 触发:避免「v-model set 与 @change 各触发一次」导致的
// 双重 switchScope 请求 / 双重导航竞态(旧实现会让一次点击发起两次切换,
// 偶发造成 scope 与 effectivePermissions 不一致 → 配置页 PermissionGuard 误判隐藏)。
const current = computed<Scope>(() => auth.scope ?? 'Platform')
let switching = false
async function onChange(v: string | number | boolean | undefined) {
if (switching) return
switching = true
try {
await switchAndNavigate(v as Scope)
} finally {
switching = false
}
}
async function switchAndNavigate(scope: Scope) {
try {
await auth.switchScope(scope)
router.push(scope === 'Platform' ? '/admin/map-monitor' : '/monitor/map')
} catch (err) {
ElMessage.error((err as Error)?.message ?? `切换到 ${scope} 失败`)
}
}
</script>
@@ -0,0 +1,115 @@
<template>
<div v-if="visible" class="wz-setup-bar" role="status">
<div class="bar-left">
<span class="bar-kicker">配置向导</span>
<span class="bar-step">{{ current.index }}/{{ total }} · {{ current.title }}</span>
<span class="bar-hint">{{ current.hint }}</span>
</div>
<div class="bar-right">
<button type="button" class="bar-btn" @click="backToWizard">返回向导</button>
<button v-if="nextId" type="button" class="bar-btn primary" @click="goNext">下一步</button>
<button v-else type="button" class="bar-btn primary" @click="backToWizard">完成向导</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
isWizardSetupActive,
loadWizardDraft,
nextStep,
saveWizardDraft,
stepDef,
WIZARD_STEPS
} from '@/utils/wizardSetup'
const route = useRoute()
const router = useRouter()
const visible = ref(false)
const draft = ref(loadWizardDraft())
const total = WIZARD_STEPS.length
const current = computed(() => stepDef(draft.value.step))
const nextId = computed(() => nextStep(draft.value.step))
function refresh() {
visible.value = isWizardSetupActive()
draft.value = loadWizardDraft()
}
function backToWizard() {
router.push({ name: 'wizard' })
}
function goNext() {
const n = nextId.value
if (!n) {
backToWizard()
return
}
const next = { ...draft.value, step: n, inSetup: true }
saveWizardDraft(next)
draft.value = next
router.push({ name: 'wizard' })
}
onMounted(refresh)
watch(() => route.fullPath, refresh)
</script>
<style scoped>
.wz-setup-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 14px;
margin: 0 8px 8px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(117, 67, 232, 0.16), rgba(155, 124, 255, 0.1));
border: 1px solid rgba(117, 67, 232, 0.28);
color: #28213a;
}
.bar-left {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.bar-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: #7543e8;
text-transform: uppercase;
}
.bar-step { font-size: 13px; font-weight: 650; }
.bar-hint {
font-size: 12px;
color: #756d85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-right { display: flex; gap: 8px; flex: none; }
.bar-btn {
appearance: none;
height: 28px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(40, 33, 58, 0.12);
background: #fff;
color: #28213a;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.bar-btn.primary {
background: #7543e8;
border-color: transparent;
color: #fff;
}
</style>
@@ -0,0 +1,340 @@
/** 首次部署向导:步骤、草稿、配置页放行。 */
export const WIZARD_DRAFT_KEY = 'migu.wizard.draft'
export const WIZARD_SETUP_KEY = 'migu.wizard.inSetup'
export type WizardStepId =
| 'nav'
| 'scenario'
| 'modules'
| 'vehicles'
| 'map'
| 'business'
export interface WizardDraft {
platformType: string
navigationKinds: string[]
modules: string[]
scenarios: string[]
step: WizardStepId
inSetup: boolean
/** 配置清单勾选(key 见 WizardChecklistItem.key */
checked: string[]
}
export interface WizardChecklistItem {
key: string
title: string
detail: string
route?: string
query?: Record<string, string>
cta?: string
}
export interface WizardStepDef {
id: WizardStepId
index: number
title: string
hint: string
/** 选型步 / 配置引导步 */
kind: 'select' | 'setup'
}
export const WIZARD_STEPS: WizardStepDef[] = [
{ id: 'nav', index: 1, title: '导航方式', hint: '选择现场实际使用的定位方式', kind: 'select' },
{ id: 'scenario', index: 2, title: '业务场景', hint: '决定后续设备与互锁怎么配', kind: 'select' },
{ id: 'modules', index: 3, title: '功能模块', hint: '按需裁剪仓储 / 拣选等能力', kind: 'select' },
{ id: 'vehicles', index: 4, title: '车辆配置', hint: '添加车辆并填写运行参数', kind: 'setup' },
{ id: 'map', index: 5, title: '地图配置', hint: '站点、路径与功能参数', kind: 'setup' },
{ id: 'business', index: 6, title: '业务配置', hint: '按场景接入设备与互锁', kind: 'setup' }
]
/** 向导进行中允许进入的业务页(需同时 inSetup=true)。 */
export const WIZARD_SETUP_ROUTES = new Set([
'admin-cars',
'admin-map-editor',
'admin-maps',
'admin-tracks',
'admin-config-facility',
'admin-task-templates',
'admin-project-properties'
])
export const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.mag',
qrcode: 'scene.qrlidar',
laser: 'scene.qrlidar'
}
const VEHICLE_CHECKLIST: WizardChecklistItem[] = [
{
key: 'veh-add',
title: '添加车辆',
detail: '打开车辆管理 → 右上角「新建车辆」,从已加载车型实例化至少一台车。没有车则地图监控与任务无法绑定实车。',
route: 'admin-cars',
cta: '去添加车辆'
},
{
key: 'veh-params',
title: '填写车辆参数',
detail: '编号、名称、车型、IP / 通讯地址必须填写;电池、速度、尺寸等运行参数按车型补齐后保存。',
route: 'admin-cars',
cta: '去填参数'
},
{
key: 'veh-list',
title: '确认列表可见',
detail: '保存后车辆列表中能看到该车,状态不是空列表。后续站点占用、任务下发都依赖这台车。',
route: 'admin-cars',
cta: '查看车辆列表'
}
]
const MAP_CHECKLIST: WizardChecklistItem[] = [
{
key: 'map-sites',
title: '配置站点',
detail: '在地图编辑器布置工位、充电、待命、互锁等站点,并核对接驳点 / 站点属性。',
route: 'admin-map-editor',
cta: '去布站点'
},
{
key: 'map-paths',
title: '绘制路径',
detail: '用直线 / 曲线把站点连起来,保证车辆从待命到工位、充电点均可到达。',
route: 'admin-map-editor',
cta: '去画路径'
},
{
key: 'map-params',
title: '功能参数',
detail: '按导航方式补站点功能参数:锁点、限速、交管、允许停车等。可在场景/路径表里核对。',
route: 'admin-tracks',
cta: '去核路径表'
}
]
const SCENARIO_CHECKLIST: Record<string, WizardChecklistItem[]> = {
'tpl-sps': [
{
key: 'sps-device',
title: '添加上下线机构',
detail: '进入设备管理 → 设备实例,添加上线机构、下线机构(或对应 SPS 台架设备)。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去添加机构'
},
{
key: 'sps-interlock-site',
title: '配置互锁站点',
detail: '把机构对应的互锁站点绑到地图上的上线/下线工位,避免车未到位就放行机构。',
route: 'admin-map-editor',
cta: '去绑互锁站点'
},
{
key: 'sps-comm',
title: '设备通讯参数',
detail: '填写协议(如 Modbus / PLC)、IP、端口,确认设备实例已启用、能通讯。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去配通讯'
},
{
key: 'sps-addr',
title: '互锁点地址',
detail: '配置互锁点(锁点)地址参数,与现场 PLC / 机构到位、放行信号对齐。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去配互锁地址'
}
],
'tpl-pack': [
{
key: 'pack-device',
title: '工位对接设备',
detail: '在设备管理中添加产线工位对接设备。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去添加设备'
},
{
key: 'pack-interlock',
title: '互锁站点',
detail: '绑定工位互锁站点,避免车与设备抢位。',
route: 'admin-map-editor',
cta: '去绑站点'
},
{
key: 'pack-comm',
title: '通讯与地址',
detail: '配置设备通讯参数及互锁点地址。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去配参数'
}
],
'tpl-loop': [
{
key: 'loop-sites',
title: '环线站点',
detail: '确认环线进出站、循环路径已在地图中闭合,避免堵车。',
route: 'admin-map-editor',
cta: '去核地图'
}
],
'tpl-p2p': [
{
key: 'p2p-sites',
title: '取放货站点',
detail: '明确取货点与放货点,并在地图中标好。',
route: 'admin-map-editor',
cta: '去标站点'
},
{
key: 'p2p-tpl',
title: '任务模板',
detail: '可在任务编排中配置点对点搬运模板。',
route: 'admin-task-templates',
cta: '去配任务模板'
}
]
}
const GENERIC_BUSINESS: WizardChecklistItem[] = [
{
key: 'biz-device',
title: '设备接入',
detail: '如现场有门、充电桩、机构等,在设备管理中添加并配置通讯。',
route: 'admin-config-facility',
query: { tab: 'devices' },
cta: '去设备管理'
}
]
export function emptyDraft(): WizardDraft {
return {
platformType: 'standard',
navigationKinds: [],
modules: ['wms'],
scenarios: [],
step: 'nav',
inSetup: false,
checked: []
}
}
export function loadWizardDraft(): WizardDraft {
try {
const raw = sessionStorage.getItem(WIZARD_DRAFT_KEY)
if (!raw) return emptyDraft()
const parsed = JSON.parse(raw) as Partial<WizardDraft>
const base = emptyDraft()
return {
...base,
...parsed,
navigationKinds: Array.isArray(parsed.navigationKinds) ? parsed.navigationKinds : [],
modules: Array.isArray(parsed.modules) ? parsed.modules : base.modules,
scenarios: Array.isArray(parsed.scenarios) ? parsed.scenarios : [],
step: WIZARD_STEPS.some((s) => s.id === parsed.step) ? (parsed.step as WizardStepId) : 'nav',
inSetup: !!parsed.inSetup,
checked: Array.isArray(parsed.checked) ? parsed.checked : []
}
} catch {
return emptyDraft()
}
}
export function saveWizardDraft(draft: WizardDraft) {
try {
sessionStorage.setItem(WIZARD_DRAFT_KEY, JSON.stringify(draft))
if (draft.inSetup) sessionStorage.setItem(WIZARD_SETUP_KEY, '1')
else sessionStorage.removeItem(WIZARD_SETUP_KEY)
} catch { /* ignore */ }
}
export function clearWizardDraft() {
try {
sessionStorage.removeItem(WIZARD_DRAFT_KEY)
sessionStorage.removeItem(WIZARD_SETUP_KEY)
} catch { /* ignore */ }
}
export function isWizardSetupActive(): boolean {
try {
return sessionStorage.getItem(WIZARD_SETUP_KEY) === '1'
} catch {
return false
}
}
export function stepDef(id: WizardStepId): WizardStepDef {
return WIZARD_STEPS.find((s) => s.id === id) ?? WIZARD_STEPS[0]!
}
export function stepIndex(id: WizardStepId): number {
return WIZARD_STEPS.findIndex((s) => s.id === id)
}
export function nextStep(id: WizardStepId): WizardStepId | null {
const i = stepIndex(id)
return i >= 0 && i < WIZARD_STEPS.length - 1 ? WIZARD_STEPS[i + 1]!.id : null
}
export function prevStep(id: WizardStepId): WizardStepId | null {
const i = stepIndex(id)
return i > 0 ? WIZARD_STEPS[i - 1]!.id : null
}
const SIGNAL_SCENARIOS = ['tpl-sps', 'tpl-pack']
export function activeSceneIds(navKinds: string[], scenarios: string[] = []): string[] {
const out: string[] = []
for (const k of navKinds) {
const id = NAV_SCENE[k] ?? `scene.${k}`
if (!out.includes(id)) out.push(id)
}
if (scenarios.some((s) => SIGNAL_SCENARIOS.includes(s)) && !out.includes('scene.signal')) {
out.push('scene.signal')
}
if (out.length > 0 && !out.includes('scene.device')) out.push('scene.device')
return out
}
export function vehicleChecklist(): WizardChecklistItem[] {
return VEHICLE_CHECKLIST
}
export function mapChecklist(): WizardChecklistItem[] {
return MAP_CHECKLIST
}
export function businessChecklist(scenarios: string[]): WizardChecklistItem[] {
const items: WizardChecklistItem[] = []
const seen = new Set<string>()
for (const id of scenarios) {
const list = SCENARIO_CHECKLIST[id]
if (!list) continue
for (const it of list) {
if (seen.has(it.key)) continue
seen.add(it.key)
items.push(it)
}
}
return items.length ? items : GENERIC_BUSINESS
}
export function scenarioSetupHint(scenarios: string[]): string {
if (scenarios.includes('tpl-sps')) {
return '已选 SPS:进入设备管理,添加上下线机构,再配互锁站点、通讯参数和互锁点地址。'
}
if (scenarios.includes('tpl-pack')) {
return '已选 Pack 产线:在设备管理中对接工位设备,并配置互锁站点与通讯。'
}
if (scenarios.includes('tpl-loop')) {
return '已选环线:确认环线路径闭合,并按需配置交管参数。'
}
if (scenarios.includes('tpl-p2p')) {
return '已选点对点:明确取放货站点,必要时配置任务模板。'
}
return '未选具体场景时,可按现场需要接入设备,或直接完成向导。'
}
@@ -36,7 +36,7 @@
<div class="panel">
<div class="panel-head">
<div class="panel-title"> </div>
<div class="panel-sub">登录以进入智能调度平台</div>
<div class="panel-sub">使用已分配权限的账号进入平台</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk @submit.prevent="submit">
@@ -46,73 +46,13 @@
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" size="large" show-password placeholder="密码Mock,任意非空)" autocomplete="current-password">
<el-input v-model="form.password" type="password" size="large" show-password placeholder="密码" autocomplete="current-password">
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="scope" class="scope-item">
<div class="scope-wrap">
<button type="button" class="scope-tab" :class="{ active: form.scope === 'Platform' }" @click="form.scope = 'Platform'">
<el-icon><Setting /></el-icon>
<div class="scope-meta">
<div class="scope-name">管理员</div>
<div class="scope-desc">platform-vue · 全功能</div>
</div>
</button>
<button type="button" class="scope-tab" :class="{ active: form.scope === 'RCSMonitor' }" @click="form.scope = 'RCSMonitor'">
<el-icon><Monitor /></el-icon>
<div class="scope-meta">
<div class="scope-name">运营</div>
<div class="scope-desc">rcsmonitor-vue · 受限</div>
</div>
</button>
</div>
</el-form-item>
<!-- 会话 N+1启动反转Platform.Server 作为主入口登录时由用户选择 SimpleLite 的启动模式
"本地+Web" SimpleLite 同时启 LocalTerminal + WebTerminal "仅Web" 只起 WebTerminal-->
<el-form-item prop="launchMode" class="scope-item launch-mode-item">
<div class="launch-mode-head">
<span class="launch-mode-title">SimpleLite 启动模式</span>
<el-tooltip placement="top">
<template #content>
<div class="launch-tip">
<div><b>本地+Web</b>登录后由 Platform 后端拉起 SimpleLite同时打开桌面端 ImGui 窗口与浏览器端 webVRender</div>
<div><b> Web</b>只起 WebTerminal不弹本地桌面窗口适合远程办公 / 服务器部署</div>
</div>
</template>
<el-icon class="launch-mode-help"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="scope-wrap launch-wrap">
<button
type="button"
class="scope-tab"
:class="{ active: form.launchMode === 'DesktopAndWeb' }"
@click="form.launchMode = 'DesktopAndWeb'">
<el-icon><Cpu /></el-icon>
<div class="scope-meta">
<div class="scope-name">本地 + Web</div>
<div class="scope-desc">桌面窗口 & 浏览器同时启动</div>
</div>
</button>
<button
type="button"
class="scope-tab"
:class="{ active: form.launchMode === 'WebOnly' }"
@click="form.launchMode = 'WebOnly'">
<el-icon><Connection /></el-icon>
<div class="scope-meta">
<div class="scope-name"> Web</div>
<div class="scope-desc">不启桌面窗口 · 浏览器使用</div>
</div>
</button>
</div>
</el-form-item>
<div class="row row-between">
<el-checkbox v-model="form.remember" size="default" class="remember">下次自动按当前 scope 进入</el-checkbox>
<el-checkbox v-model="form.remember" size="default" class="remember">记住用户名</el-checkbox>
<el-link underline="never" class="adv-link" @click="adv = adv.length ? [] : ['adv']">高级 </el-link>
</div>
@@ -141,7 +81,7 @@
</el-button>
<div class="bottom-note">
任意非空用户名 + 任意密码即可登录scope 决定进入 admin / monitor 视图权限按 Mock 表生成
入口由账号权限决定管理账号进入配置与调度运营账号进入已授权的监控页
</div>
</el-form>
</div>
@@ -158,9 +98,8 @@ import { reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { User, Lock, Setting, Monitor, Cpu, Connection, QuestionFilled } from '@element-plus/icons-vue'
import { User, Lock } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import type { LaunchMode, Scope } from '@/types/auth'
const route = useRoute()
const router = useRouter()
@@ -182,10 +121,6 @@ const adv = ref<string[]>([])
const form = reactive({
username: 'admin',
password: 'admin',
scope: 'Platform' as Scope,
// 会话 N+1:默认「本地+Web」与改造前 Configuration.displayMode="web+local" 一致,
// 保证「我啥也不选」时的行为与改造前完全相同。
launchMode: 'DesktopAndWeb' as LaunchMode,
remember: true
})
@@ -198,14 +133,12 @@ const adv2 = reactive({
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
scope: [{ required: true, message: '请选择 scope', trigger: 'change' }],
launchMode: [{ required: true, message: '请选择启动模式', trigger: 'change' }]
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const year = computed(() => new Date().getFullYear())
function postLoginTarget(needsWizard?: boolean): string {
function postLoginTarget(needsWizard?: boolean, scope?: string): string {
if (needsWizard) return '/wizard'
const raw = route.query.redirect
const redirect = Array.isArray(raw) ? raw[0] : raw
@@ -217,7 +150,7 @@ function postLoginTarget(needsWizard?: boolean): string {
) {
return redirect
}
return form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map'
return scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard'
}
async function submit() {
@@ -232,23 +165,21 @@ async function submit() {
const resp = await auth.login({
username: form.username,
password: form.password,
scope: form.scope,
launchMode: form.launchMode
// Simple3 仅 Web 宿主,固定 WebOnly--display-mode=web
launchMode: 'WebOnly'
})
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
if (resp.launchWarning) {
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
} else if (resp.runMode === 'Detached') {
ElMessage({
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
message: 'Simple3 未启动,/api/sl/* 相关功能将不可用。',
type: 'warning',
duration: 6000,
showClose: true
})
}
await router.push(postLoginTarget(resp.needsWizard))
await router.push(postLoginTarget(resp.needsWizard, resp.scope))
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(`登录失败:${msg}`)
@@ -557,58 +488,6 @@ async function submit() {
.glass-form :deep(.el-input__prefix-inner > :first-child),
.glass-form :deep(.el-input__suffix-inner) { color: #756d85; }
/* scope 双卡 */
.scope-item :deep(.el-form-item__content) { width: 100%; }
.scope-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: 100%; }
/* 启动模式(会话 N+1):与 scope 双卡共用 .scope-tab 样式,仅在外层做一些标题/留白调整 */
.launch-mode-item :deep(.el-form-item__content) { display: flex; flex-direction: column; align-items: stretch; }
.launch-mode-head {
display: flex; align-items: center; gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: #756d85;
letter-spacing: 1px;
}
.launch-mode-title { font-weight: 500; }
.launch-mode-help {
color: #a8a0c0;
cursor: help;
font-size: 14px;
}
.launch-mode-help:hover { color: #7543e8; }
.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: #f6f3fb;
border: 1px solid rgba(40, 33, 58, 0.1);
border-radius: 12px;
padding: 14px 14px;
display: flex; align-items: center; gap: 12px;
color: #28213a;
cursor: pointer;
transition: all .2s;
text-align: left;
}
.scope-tab:hover {
background: #fbf9fd;
border-color: rgba(117, 67, 232, 0.3);
color: #28213a;
transform: translateY(-1px);
}
.scope-tab.active {
background: linear-gradient(135deg, #7543e8 0%, #9b7cff 100%);
border-color: transparent;
color: #fff;
box-shadow: 0 8px 20px rgba(117, 67, 232, 0.28);
}
.scope-tab .el-icon { font-size: 20px; }
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.scope-name { font-size: 14px; font-weight: 600; }
.scope-desc { font-size: 11px; opacity: 0.75; margin-top: 2px; }
/* 行 - 记住选择 & 高级 */
.row { display: flex; align-items: center; }
.row-between { justify-content: space-between; margin: -6px 0 10px; }
@@ -11,77 +11,174 @@
</div>
<div class="wz-titles">
<div class="wz-title">平台配置向导</div>
<div class="wz-sub">先选导航方式再选业务场景与功能模块保存后进入车辆与地图配置</div>
<div class="wz-sub">{{ current.hint }}</div>
</div>
</div>
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
</header>
<ol class="wz-steps" aria-label="向导步骤">
<li
v-for="s in steps"
:key="s.id"
class="wz-step"
:class="{
'is-current': s.id === draft.step,
'is-done': stepIndex(s.id) < stepIndex(draft.step)
}"
>
<span class="wz-step-n">{{ s.index }}</span>
<span class="wz-step-t">{{ s.title }}</span>
</li>
</ol>
<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">
<span class="step-no">1</span>
<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>
<div v-else class="wz-body">
<!-- 1 导航 -->
<section v-show="draft.step === 'nav'" class="wz-section">
<div class="wz-section-head">
<el-icon><Compass /></el-icon><h3>选择导航方式</h3><span class="req">至少选 1 </span>
</div>
<p class="wz-lead">按现场实际定位方式勾选系统会据此加载对应内核场景插件</p>
<div class="chip-grid">
<button
v-for="o in options?.navigationKinds ?? []" :key="o.id" type="button"
class="chip" :class="{ on: draft.navigationKinds.includes(o.id) }"
@click="toggle(draft.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">
<span class="step-no">2</span>
<el-icon><Histogram /></el-icon><h3>业务场景</h3><span class="opt">可多选可暂不选</span>
</div>
<div class="section-hint">SPS 物料配送电池 Pack 自动化产线才会加载 signal 插件</div>
<div v-if="scenarioTemplates.length" 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>
<div v-else class="chip-empty">暂无场景模板可跳过这一步</div>
</section>
<!-- 2 场景 -->
<section v-show="draft.step === 'scenario'" class="wz-section">
<div class="wz-section-head">
<el-icon><Histogram /></el-icon><h3>选择业务场景</h3><span class="opt">可多选可暂不选</span>
</div>
<p class="wz-lead">场景决定后面业务配置要做什么例如 SPS 会引导你去设备管理添加上下线机构并配置互锁</p>
<div class="chip-grid">
<button
v-for="t in scenarioTemplates" :key="t.id" type="button"
class="chip" :class="{ on: draft.scenarios.includes(t.id) }"
@click="toggle(draft.scenarios, t.id)">
<div class="chip-name">{{ t.name }}</div>
<div class="chip-desc">{{ t.category }}</div>
</button>
</div>
</section>
<section class="wz-section">
<div class="wz-section-head">
<span class="step-no">3</span>
<el-icon><Box /></el-icon><h3>功能模块</h3><span class="opt">可多选可暂不选</span>
</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>
<!-- 3 模块 -->
<section v-show="draft.step === 'modules'" class="wz-section">
<div class="wz-section-head">
<el-icon><Box /></el-icon><h3>选择功能模块</h3>
</div>
<p class="wz-lead">按项目需要裁剪平台菜单未选中的模块对应配置页会隐藏</p>
<div class="chip-grid">
<button
v-for="o in options?.modules ?? []" :key="o.id" type="button"
class="chip" :class="{ on: draft.modules.includes(o.id) }"
@click="toggle(draft.modules, o.id)">
<div class="chip-name">{{ o.name }}</div>
<div class="chip-desc">{{ o.description }}</div>
</button>
</div>
</section>
<!-- 4 车辆 -->
<section v-show="draft.step === 'vehicles'" class="wz-section">
<div class="wz-section-head">
<el-icon><Van /></el-icon><h3>车辆配置</h3>
</div>
<p class="wz-lead warn">必须添加车辆并补齐参数否则地图监控与任务无法绑定实车</p>
<ol class="check-list">
<li
v-for="(it, i) in vehicleItems"
:key="it.key"
:class="{ done: isChecked(it.key) }"
>
<button type="button" class="check-mark" :aria-pressed="isChecked(it.key)" @click="toggleChecked(it.key)">
{{ isChecked(it.key) ? '' : i + 1 }}
</button>
</div>
</section>
</div>
<div class="check-body">
<div class="check-title">{{ it.title }}</div>
<div class="check-detail">{{ it.detail }}</div>
</div>
<button v-if="it.route" type="button" class="check-cta" @click="openItem(it)">{{ it.cta || '去配置' }}</button>
</li>
</ol>
<div class="cta-row">
<el-button type="primary" @click="openSetup('admin-cars')">前往车辆管理</el-button>
<span class="cta-note">必须添加车辆并补齐参数配完后点顶栏返回向导或勾选清单后点下一步</span>
</div>
</section>
<!-- 5 地图 -->
<section v-show="draft.step === 'map'" class="wz-section">
<div class="wz-section-head">
<el-icon><MapLocation /></el-icon><h3>地图配置</h3>
</div>
<p class="wz-lead">在画布上布置站点绘制路径并按导航方式填写站点功能参数</p>
<ol class="check-list">
<li
v-for="(it, i) in mapItems"
:key="it.key"
:class="{ done: isChecked(it.key) }"
>
<button type="button" class="check-mark" :aria-pressed="isChecked(it.key)" @click="toggleChecked(it.key)">
{{ isChecked(it.key) ? '' : i + 1 }}
</button>
<div class="check-body">
<div class="check-title">{{ it.title }}</div>
<div class="check-detail">{{ it.detail }}</div>
</div>
<button v-if="it.route" type="button" class="check-cta" @click="openItem(it)">{{ it.cta || '去配置' }}</button>
</li>
</ol>
<div class="cta-row">
<el-button type="primary" @click="openSetup('admin-map-editor')">前往地图编辑</el-button>
<el-button @click="openSetup('admin-tracks')">场景 / 路径表</el-button>
</div>
</section>
<!-- 6 业务 -->
<section v-show="draft.step === 'business'" class="wz-section">
<div class="wz-section-head">
<el-icon><SetUp /></el-icon><h3>配置业务场景</h3>
</div>
<p class="wz-lead">{{ businessHint }}</p>
<ol class="check-list">
<li
v-for="(it, i) in businessItems"
:key="it.key"
:class="{ done: isChecked(it.key) }"
>
<button type="button" class="check-mark" :aria-pressed="isChecked(it.key)" @click="toggleChecked(it.key)">
{{ isChecked(it.key) ? '' : i + 1 }}
</button>
<div class="check-body">
<div class="check-title">{{ it.title }}</div>
<div class="check-detail">{{ it.detail }}</div>
</div>
<button v-if="it.route" type="button" class="check-cta" @click="openItem(it)">{{ it.cta || '去配置' }}</button>
</li>
</ol>
<div class="cta-row">
<el-button type="primary" @click="openSetup('admin-config-facility', { tab: 'devices' })">前往设备管理</el-button>
</div>
</section>
<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-row"><span>导航方式</span><b>{{ navLabels }}</b></div>
<div class="sum-row"><span>业务场景</span><b>{{ scenarioLabels }}</b></div>
<div class="sum-row"><span>功能模块</span><b>{{ moduleLabels }}</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>
<el-tag v-for="s in scenes" :key="s" size="small" effect="dark" class="sum-tag">{{ s }}</el-tag>
<span v-if="!scenes.length" class="sum-empty">请先选择导航方式</span>
</div>
</aside>
</div>
@@ -89,9 +186,21 @@
<footer class="wz-foot">
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
<div class="foot-right">
<span class="foot-hint">保存后进入车辆与地图配置不选导航方式无法继续</span>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>下一步进入配置
<el-button v-if="prevId" @click="goPrev">上一步</el-button>
<el-button
v-if="nextId"
type="primary"
:disabled="!canAdvance"
@click="goNext"
>{{ nextCta }}</el-button>
<el-button
v-else
type="primary"
:loading="saving"
:disabled="!canAdvance"
@click="finish"
>
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>完成并进入平台
</el-button>
</div>
</footer>
@@ -100,13 +209,31 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Compass, Box, Histogram, Check } from '@element-plus/icons-vue'
import { Compass, Box, Histogram, Check, Van, MapLocation, SetUp } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { getWizardOptions, getWizardProfile, saveWizardProfile } from '@/api/wizard'
import type { WizardOptions, ScenarioTemplateLite } from '@/types/wizard'
import {
WIZARD_STEPS,
activeSceneIds,
businessChecklist,
clearWizardDraft,
loadWizardDraft,
mapChecklist,
nextStep,
prevStep,
saveWizardDraft,
scenarioSetupHint,
stepDef,
stepIndex,
vehicleChecklist,
type WizardChecklistItem,
type WizardDraft,
type WizardStepId
} from '@/utils/wizardSetup'
const router = useRouter()
const auth = useAuthStore()
@@ -114,37 +241,44 @@ const auth = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const options = ref<WizardOptions | null>(null)
const steps = WIZARD_STEPS
const sel = reactive({
platformType: 'standard',
navigationKinds: [] as string[],
modules: [] as string[],
scenarios: [] as string[]
})
const draft = reactive<WizardDraft>(loadWizardDraft())
const current = computed(() => stepDef(draft.step))
const nextId = computed(() => nextStep(draft.step))
const prevId = computed(() => prevStep(draft.step))
const scenes = computed(() => activeSceneIds(draft.navigationKinds, draft.scenarios))
const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.scenarios?.templates ?? [])
const vehicleItems = computed(() => vehicleChecklist())
const mapItems = computed(() => mapChecklist())
const businessItems = computed(() => businessChecklist(draft.scenarios))
const businessHint = computed(() => scenarioSetupHint(draft.scenarios))
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.mag',
qrcode: 'scene.qrlidar',
laser: 'scene.qrlidar'
}
const SIGNAL_SCENARIOS = ['tpl-sps', 'tpl-pack']
const activeScenes = computed(() => {
const scenes: string[] = []
for (const k of sel.navigationKinds) {
const id = NAV_SCENE[k] ?? `scene.${k}`
if (!scenes.includes(id)) scenes.push(id)
}
if (sel.scenarios.some((s) => SIGNAL_SCENARIOS.includes(s)) && !scenes.includes('scene.signal')) {
scenes.push('scene.signal')
}
if (scenes.length > 0 && !scenes.includes('scene.device')) scenes.push('scene.device')
return scenes
const canAdvance = computed(() => {
if (draft.step === 'nav') return draft.navigationKinds.length > 0
return true
})
const canSave = computed(() => sel.navigationKinds.length > 0)
const nextCta = computed(() => {
if (draft.step === 'modules') return '进入配置'
return '下一步'
})
function namesOf(ids: string[], pool: { id: string; name: string }[]): string {
if (!ids.length) return '未选'
return ids.map((id) => pool.find((o) => o.id === id)?.name ?? id).join('、')
}
const navLabels = computed(() => namesOf(draft.navigationKinds, options.value?.navigationKinds ?? []))
const moduleLabels = computed(() => namesOf(draft.modules, options.value?.modules ?? []))
const scenarioLabels = computed(() => namesOf(draft.scenarios, scenarioTemplates.value))
function persist() {
saveWizardDraft({ ...draft })
}
watch(draft, persist, { deep: true })
function toggle(list: string[], id: string) {
const i = list.indexOf(id)
@@ -152,49 +286,93 @@ function toggle(list: string[], id: string) {
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 ?? [])]
sel.modules = [...(profile.modules ?? [])]
sel.scenarios = [...(profile.scenarios ?? [])]
} catch (e) {
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
})
function isChecked(key: string) {
return draft.checked.includes(key)
}
async function save() {
if (!canSave.value) {
function toggleChecked(key: string) {
toggle(draft.checked, key)
}
function goPrev() {
const p = prevId.value
if (p) draft.step = p
if (stepDef(draft.step).kind === 'select') draft.inSetup = false
}
function goNext() {
if (!canAdvance.value) {
ElMessage.warning('请至少选择一种导航方式')
return
}
const n = nextId.value
if (!n) return
if (n === 'vehicles') draft.inSetup = true
draft.step = n
}
async function openSetup(name: string, query?: Record<string, string>) {
draft.inSetup = true
persist()
await router.push({ name, query })
}
async function openItem(it: WizardChecklistItem) {
if (!it.route) return
await openSetup(it.route, it.query)
}
async function finish() {
if (draft.navigationKinds.length === 0) {
ElMessage.warning('请至少选择一种导航方式')
draft.step = 'nav'
return
}
saving.value = true
try {
await saveWizardProfile({
platformType: sel.platformType,
modules: sel.modules,
navigationKinds: sel.navigationKinds,
scenarios: sel.scenarios
platformType: draft.platformType,
modules: draft.modules,
navigationKinds: draft.navigationKinds,
scenarios: draft.scenarios
})
clearWizardDraft()
auth.markWizardDone()
await auth.refreshPermissions()
ElMessage.success('选型已保存,请继续配置车辆与地图')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/setup')
ElMessage.success('部署配置已保存')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
ElMessage.error(`保存失败:${(e as Error).message}`)
} finally {
saving.value = false
}
}
function onLogout() {
clearWizardDraft()
auth.logout()
router.push('/login')
}
onMounted(async () => {
try {
const [opt, profile] = await Promise.all([getWizardOptions(), getWizardProfile()])
options.value = opt
if (!draft.navigationKinds.length && profile.navigationKinds?.length) {
draft.navigationKinds = [...profile.navigationKinds]
}
if (!draft.modules.length) {
draft.modules = profile.modules?.length ? [...profile.modules] : ['wms']
}
if (!draft.scenarios.length && profile.scenarios?.length) {
draft.scenarios = [...profile.scenarios]
}
draft.platformType = draft.platformType || profile.platformType || 'standard'
} catch (e) {
ElMessage.error(`加载向导失败:${(e as Error).message}`)
} finally {
loading.value = false
}
})
</script>
<style scoped>
@@ -226,14 +404,13 @@ function onLogout() {
.wz-card {
position: relative;
z-index: 2;
width: 1040px;
width: 1080px;
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%);
@@ -247,9 +424,7 @@ function onLogout() {
.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%);
padding: 18px 28px 12px;
}
.wz-brand { display: flex; align-items: center; gap: 14px; }
.wz-mark {
@@ -259,21 +434,10 @@ function onLogout() {
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-mark-img { display: block; height: 28px; width: auto; }
.wz-titles { display: flex; flex-direction: column; gap: 4px; }
.wz-title {
font-size: 20px; font-weight: 700; letter-spacing: 2px; color: #fff;
}
.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);
@@ -281,48 +445,75 @@ function onLogout() {
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-steps {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
list-style: none;
margin: 0;
padding: 0 22px 12px;
}
.wz-step {
display: flex; align-items: center; gap: 6px;
min-width: 0;
padding: 7px 8px;
border-radius: 10px;
color: rgba(255, 255, 255, 0.42);
font-size: 12px;
}
.wz-step.is-done { color: rgba(232, 215, 245, 0.78); }
.wz-step.is-current {
color: #fff;
background: rgba(var(--lg-primary-rgb), 0.28);
}
.wz-step-n {
flex: none;
width: 20px; height: 20px;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
border: 1px solid currentColor;
}
.wz-step-t {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650;
}
.wz-grid {
.wz-loading { padding: 80px; text-align: center; color: rgba(255, 255, 255, 0.7); }
.wz-body {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1fr 280px;
gap: 0;
display: grid;
grid-template-columns: 1fr 260px;
overflow: hidden;
}
.wz-main {
.wz-section {
overflow-y: auto;
padding: 22px 26px;
display: flex; flex-direction: column; gap: 22px;
padding: 8px 26px 22px;
}
.wz-section-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
color: #fff;
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; 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 .step-no {
width: 20px; height: 20px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: #fff;
background: var(--lg-primary);
.wz-section-head .req,
.wz-section-head .opt {
font-size: 11px; padding: 1px 8px; border-radius: 8px;
}
.wz-section-head .req {
font-size: 11px; color: var(--lg-accent);
padding: 1px 8px; border-radius: 8px;
color: var(--lg-accent);
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
}
.wz-section-head .opt {
font-size: 11px; color: rgba(232, 215, 245, 0.7);
padding: 1px 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(232, 215, 245, 0.7);
border: 1px solid rgba(255, 255, 255, 0.16);
}
.chip-empty {
font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 8px 2px;
}
.section-hint {
font-size: 12px; color: rgba(232, 215, 245, 0.6); margin: -4px 0 10px; line-height: 1.5;
.wz-lead {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.55;
color: rgba(232, 215, 245, 0.72);
}
.wz-lead.warn { color: #f3c77a; }
.chip-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
@@ -336,36 +527,81 @@ function onLogout() {
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:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.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; }
.check-list {
list-style: none; margin: 0 0 16px; padding: 0;
display: flex; flex-direction: column; gap: 10px;
}
.check-list li {
display: flex; gap: 12px; align-items: flex-start;
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.check-list li.done {
border-color: rgba(34, 197, 94, 0.45);
background: rgba(34, 197, 94, 0.08);
}
.check-mark {
appearance: none;
flex: none;
width: 22px; height: 22px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: #fff;
background: rgba(var(--lg-primary-rgb), 0.7);
border: 0;
cursor: pointer;
}
.check-list li.done .check-mark { background: #16a34a; }
.check-body { flex: 1; min-width: 0; }
.check-title { font-size: 13.5px; font-weight: 650; color: #fff; }
.check-detail { font-size: 12px; color: rgba(232, 215, 245, 0.68); margin-top: 3px; line-height: 1.5; }
.check-cta {
appearance: none;
flex: none;
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid rgba(var(--lg-accent-rgb), 0.45);
background: rgba(var(--lg-primary-rgb), 0.28);
color: #fff;
font-size: 12px;
font-weight: 650;
cursor: pointer;
white-space: nowrap;
}
.check-cta:hover { background: rgba(var(--lg-primary-rgb), 0.45); }
.cta-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cta-note { font-size: 12px; color: rgba(232, 215, 245, 0.55); }
.wz-summary {
border-left: 1px solid rgba(var(--lg-accent-rgb), 0.16);
background: rgba(0, 0, 0, 0.18);
padding: 22px 20px;
padding: 18px 18px;
overflow-y: auto;
}
.sum-title { font-size: 13px; font-weight: 600; color: #fff; margin-bottom: 14px; letter-spacing: 1px; }
.sum-title { font-size: 13px; font-weight: 600; color: #fff; margin-bottom: 14px; }
.sum-row {
display: flex; align-items: center; justify-content: space-between;
display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
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-row b {
color: var(--lg-accent); font-size: 12.5px; font-weight: 650; text-align: right; max-width: 58%;
}
.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;
@@ -374,29 +610,24 @@ function onLogout() {
.wz-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 26px;
padding: 14px 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); }
.foot-right { display: flex; align-items: center; gap: 10px; }
.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-body { grid-template-columns: 1fr; }
.wz-summary { border-left: none; border-top: 1px solid rgba(var(--lg-accent-rgb), 0.16); }
.wz-steps { grid-template-columns: repeat(3, 1fr); }
}
</style>