登录固定 WebOnly,并增强部署向导流程。
移除启动模式切换与 ScopeSwitcher;向导拆分子组件并补充 setup 工具。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 v-else class="wz-body">
|
||||
<!-- 1 导航 -->
|
||||
<section v-show="draft.step === 'nav'" 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>
|
||||
<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: sel.navigationKinds.includes(o.id) }"
|
||||
@click="toggle(sel.navigationKinds, o.id)">
|
||||
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">
|
||||
<!-- 2 场景 -->
|
||||
<section v-show="draft.step === 'scenario'" 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>
|
||||
<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">
|
||||
<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: sel.scenarios.includes(t.id) }"
|
||||
@click="toggle(sel.scenarios, t.id)">
|
||||
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>
|
||||
<div v-else class="chip-empty">暂无场景模板,可跳过这一步</div>
|
||||
</section>
|
||||
|
||||
<section class="wz-section">
|
||||
<!-- 3 模块 -->
|
||||
<section v-show="draft.step === 'modules'" 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>
|
||||
<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: sel.modules.includes(o.id) }"
|
||||
@click="toggle(sel.modules, o.id)">
|
||||
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 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>
|
||||
|
||||
Reference in New Issue
Block a user