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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-03 09:37:07 +08:00
co-authored by Cursor
parent e2269e430d
commit 7382e85598
109 changed files with 3481 additions and 398 deletions
@@ -0,0 +1,127 @@
/**
* 多地图连接(跨楼层 / 多地图拼接)数据层。
*
* 业务背景:一个站点可作为「切换点站点」与另一张地图的某个切换点站点相连,
* 配上转移代价(cm),即可在多张楼层地图之间做跨图路径规划与地图拼接。
*
* 持久化:后端暂无对应表,先用 localStorage 落地,保证前端功能完整、可演示;
* 待后端补上 `/sl/projection/map-edit/connections` 系列接口后,仅需替换本文件实现,
* 组件层(MapConnectionPanel)无需改动(接口已按异步 Promise 设计)。
*/
export interface MapConnection {
id: number
/** 起始地图名称(与 mapsApi.list 的 name 对齐) */
sourceMap: string
/** 起始地图 ID(地图以名称为主键,这里按名称分配稳定数字 ID,呼应参考图的「地图ID」列) */
sourceMapId: number
/** 起始切换点站点 */
sourceStation: string
/** 目的地图名称 */
targetMap: string
/** 目的地图 ID */
targetMapId: number
/** 目的切换点站点 */
targetStation: string
/** 转移代价(cm */
cost: number
}
export type MapConnectionInput = Omit<MapConnection, 'id' | 'sourceMapId' | 'targetMapId'>
const LS_KEY = 'mapEditor.mapConnections.v1'
const LS_IDREG = 'mapEditor.mapIdRegistry.v1'
function readAll(): MapConnection[] {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return []
const arr = JSON.parse(raw)
return Array.isArray(arr) ? (arr as MapConnection[]) : []
} catch {
return []
}
}
function writeAll(list: MapConnection[]): void {
localStorage.setItem(LS_KEY, JSON.stringify(list))
}
function readReg(): Record<string, number> {
try {
return (JSON.parse(localStorage.getItem(LS_IDREG) ?? '{}') as Record<string, number>) || {}
} catch {
return {}
}
}
function writeReg(reg: Record<string, number>): void {
localStorage.setItem(LS_IDREG, JSON.stringify(reg))
}
/** 名称 → 稳定数字 ID:首次遇到某地图名时分配一个递增 ID,并持久化,保证后续一致。 */
export function mapIdOf(name: string): number {
if (!name) return 0
const reg = readReg()
if (reg[name] != null) return reg[name]
const next = Object.values(reg).reduce((m, v) => Math.max(m, v), 0) + 1
reg[name] = next
writeReg(reg)
return next
}
// 模拟一点点网络延迟,让 loading 态自然,也方便日后替换为真实 http 调用。
function later<T>(value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), 60))
}
export const mapConnectionApi = {
/** 列出全部连接(按 id 倒序,新建的在前)。 */
list(): Promise<MapConnection[]> {
return later(readAll().slice().sort((a, b) => b.id - a.id))
},
create(input: MapConnectionInput): Promise<MapConnection> {
const list = readAll()
const id = list.reduce((m, c) => Math.max(m, c.id), 0) + 1
const rec: MapConnection = {
id,
...input,
sourceMapId: mapIdOf(input.sourceMap),
targetMapId: mapIdOf(input.targetMap)
}
list.push(rec)
writeAll(list)
return later(rec)
},
update(id: number, input: MapConnectionInput): Promise<MapConnection> {
const list = readAll()
const idx = list.findIndex((c) => c.id === id)
if (idx < 0) return Promise.reject(new Error('连接不存在或已被删除'))
const rec: MapConnection = {
id,
...input,
sourceMapId: mapIdOf(input.sourceMap),
targetMapId: mapIdOf(input.targetMap)
}
list[idx] = rec
writeAll(list)
return later(rec)
},
remove(id: number): Promise<{ id: number; deleted: boolean }> {
writeAll(readAll().filter((c) => c.id !== id))
return later({ id, deleted: true })
},
/** 历史用过的切换点站点名,给「切换点站点」下拉做候选(可继续手动输入新名)。 */
stationSuggestions(): string[] {
const set = new Set<string>()
for (const c of readAll()) {
if (c.sourceStation) set.add(c.sourceStation)
if (c.targetStation) set.add(c.targetStation)
}
return Array.from(set)
}
}
@@ -280,3 +280,136 @@ export const aiConfigApi = {
get: () => unwrap<AiConfig>(http.get(`${AI_BASE}/`)),
save: (cfg: AiConfig) => unwrap<{ saved: boolean }>(http.post(`${AI_BASE}/`, cfg))
}
// ──────────────────────────────────────────────────────────────────────────
// 地图管理(固定文件夹 + 按名称列表 / 保存 / 删除 / 使用 / 编辑打开)
// 对应 SimpleLite MapEditApiController 的 /map-edit/maps* 接口。
// ──────────────────────────────────────────────────────────────────────────
export interface MapListItem {
/** 地图名称(文件名去掉 .json 后缀),列表展示与各操作都以它为标识。 */
name: string
fileName: string
sizeBytes: number
modified: string
/** 是否为当前项目默认使用的地图(列表高亮)。 */
isCurrent: boolean
}
export interface MapListResult {
directory: string
currentFileName: string | null
maps: MapListItem[]
}
export interface MapLoadSummary {
name: string
fileName: string
path: string
sites: number
tracks: number
specials: number
missions: number
}
export interface SceneTaskBusyCar {
id: number
name: string
reasons: string[]
}
export interface SceneTaskStatus {
hasTask: boolean
busyCount: number
busyCars: SceneTaskBusyCar[]
}
export interface MapSaveResult {
name: string
fileName: string
path: string
savedAt: string
}
/** 保存结果:success 正常返回 dataconflict=true 表示同名地图已存在,调用方应弹「替换」确认。 */
export type MapSaveOutcome =
| { ok: true; data: MapSaveResult }
| { ok: false; conflict: boolean; message: string }
export interface MapMergeResult {
name: string
fileName: string
path: string
/** 底图 = 合并时的「当前使用地图」名。 */
baseMap: string
/** 合并进来的源地图名(去重、且不含当前地图自身,按叠加顺序)。 */
sources: string[]
sourceCount: number
/** target 与当前地图同名 → 本次合并覆盖了当前地图。 */
overwroteCurrent: boolean
/** 合并后统计(用于结果提示)。 */
sites: number
tracks: number
specials: number
missions: number
savedAt: string
}
/** 合并结果:conflict=true 仅表示目标地图同名已存在,调用方应弹「替换」确认后重试。 */
export type MapMergeOutcome =
| { ok: true; data: MapMergeResult }
| { ok: false; conflict: boolean; message: string }
export const mapsApi = {
list: () => unwrap<MapListResult>(http.get(`${BASE}/maps`)),
sceneTaskStatus: () => unwrap<SceneTaskStatus>(http.get(`${BASE}/maps/scene-task-status`)),
/**
* 保存当前场景为固定文件夹内的地图。overwrite=false 且同名已存在时后端回 409,
* 这里翻译为 { ok:false, conflict:true },让调用方弹「是否替换原地图」确认框。
*/
async save(name: string, overwrite = false): Promise<MapSaveOutcome> {
const { data } = await http.post<MapEditEnvelope<MapSaveResult>>(`${BASE}/maps/save`, { name, overwrite })
if (data?.success) return { ok: true, data: data.data as MapSaveResult }
return { ok: false, conflict: data?.code === 409, message: data?.message ?? '保存失败' }
},
/** 加载指定地图到场景以供编辑(不校验任务、不改当前使用地图)。 */
open: (name: string) => unwrap<MapLoadSummary>(http.post(`${BASE}/maps/open`, { name })),
/**
* 设为当前使用地图并加载。后端切换前会校验场景无车辆任务;存在任务回 409,
* unwrap 会抛出携带后端友好文案的 Error,调用方 try/catch 提示即可。
*/
use: (name: string) => unwrap<MapLoadSummary>(http.post(`${BASE}/maps/use`, { name })),
delete: (name: string) =>
unwrap<{ name: string; fileName: string; deleted: boolean }>(
http.delete(`${BASE}/maps/${encodeURIComponent(name)}`)
),
/** 重命名 maps 目录下的地图文件(from / to 均为不含扩展名的地图名)。 */
rename: (from: string, to: string) =>
unwrap<{ from: string; to: string; fileName: string; path: string }>(
http.post(`${BASE}/maps/rename`, { from, to })
),
/**
* 把选中的地图合并进「当前使用地图」后另存为新地图(语义同桌面端「合并」= SimpleProject.ImportFile)。
* sources 为要合并进来的源地图名数组(≥1,当前地图自身会被后端忽略),以「当前使用地图」为底图依次叠加。
* target 与当前地图同名 + overwrite 即覆盖当前地图。
* 目标同名且 overwrite=false 时后端回 409 → { ok:false, conflict:true },调用方弹「替换」确认;
* 当前未设置使用地图(400)/ 场景内有车辆任务(409)时 message 不含「已存在」,conflict=false,调用方直接提示。
*/
async merge(sources: string[], target: string, overwrite = false): Promise<MapMergeOutcome> {
const { data } = await http.post<MapEditEnvelope<MapMergeResult>>(`${BASE}/maps/merge`, {
sources,
target,
overwrite
})
if (data?.success) return { ok: true, data: data.data as MapMergeResult }
const conflict = data?.code === 409 && (data?.message ?? '').includes('已存在')
return { ok: false, conflict, message: data?.message ?? '合并失败' }
}
}
@@ -0,0 +1,78 @@
import http from './http'
import type {
WizardOptions,
DeploymentProfileDto,
SaveWizardRequest,
EffectivePagesDto
} from '@/types/wizard'
// 与 api/auth.ts 一致:VITE_USE_MOCK==='true' 时走本地假数据,便于无后端联调。
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const MOCK_OPTIONS: WizardOptions = {
navigationKinds: [
{ id: 'magnetic', name: '磁导航', group: 'navigation', description: '磁条循迹 + 地标 / RFID 定位' },
{ id: 'qrcode', name: '二维码导航', group: 'navigation', description: '二维码地标 + 码值地图' },
{ id: 'laser', name: '激光导航', group: 'navigation', description: '反光板 / SLAM + 激光避障' }
],
modules: [
{ id: 'wms', name: 'WMS 仓储管理', group: 'module', description: '库位 / 库存 / 出入库管理' },
{ id: 'ptl', name: 'PTL 拣选系统', group: 'module', description: 'Pick-to-Light 亮灯拣选与播种' }
],
scenarios: {
templates: [
{ id: 'tpl-sps', name: 'SPS 物料配送', category: 'SPS' },
{ id: 'tpl-pack', name: '电池 Pack 自动化产线', category: 'BatteryPack' },
{ id: 'tpl-loop', name: '环线运行', category: 'Loop' },
{ id: 'tpl-p2p', name: '点对点柔性搬运', category: 'P2P' }
]
}
}
let mockProfile: DeploymentProfileDto = {
configured: false,
platformType: 'standard',
modules: [],
navigationKinds: [],
scenarios: [],
updatedBy: 'mock',
activeSceneIds: [],
hiddenPages: []
}
export async function getWizardOptions(): Promise<WizardOptions> {
if (MOCK) return MOCK_OPTIONS
const { data } = await http.get<WizardOptions>('/wizard/options')
return data
}
export async function getWizardProfile(): Promise<DeploymentProfileDto> {
if (MOCK) return mockProfile
const { data } = await http.get<DeploymentProfileDto>('/wizard/profile')
return data
}
export async function saveWizardProfile(req: SaveWizardRequest): Promise<DeploymentProfileDto> {
if (MOCK) {
mockProfile = {
...mockProfile,
platformType: req.platformType ?? mockProfile.platformType,
modules: req.modules ?? [],
navigationKinds: req.navigationKinds ?? [],
scenarios: req.scenarios ?? [],
configured: true,
activeSceneIds: (req.navigationKinds ?? []).map((k) => `scene.${k}`)
}
return mockProfile
}
const { data } = await http.put<DeploymentProfileDto>('/wizard/profile', req)
return data
}
export async function getEffectivePages(): Promise<EffectivePagesDto> {
if (MOCK) {
return { configured: mockProfile.configured, tailorablePages: [], enabledPages: [], hiddenPages: [] }
}
const { data } = await http.get<EffectivePagesDto>('/wizard/effective-pages')
return data
}
@@ -130,5 +130,17 @@ export const workspaceToolbarApi = {
CarId: carId ?? undefined,
Enabled: enabled
})
),
/**
* 一次性把地图相机定位(居中 + 2D 俯视)到指定车辆,不开启持续跟随。
* 对应 SimpleLite `WorkspaceToolbarApiController.LocateCamera`(与原生「双击车辆行 = 选中+定位」一致)。
*/
locateCamera: (carId: number) =>
unwrap<ToolbarState>(
http.post(`${BASE}/camera/locate`, {
carId,
CarId: carId
})
)
}
@@ -0,0 +1,281 @@
<template>
<section class="fleet-alloc">
<div class="fa-header">
<span class="fa-title">车队分配</span>
<el-tag size="small" type="info" effect="plain">区域管理</el-tag>
<span class="fa-sub">将车辆分配到车队并设定车队名称 / 区域 / 楼层</span>
<div class="spacer" />
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
保存
</el-button>
</div>
<div v-loading="loading" class="fa-body">
<el-empty v-if="!fleets.length" description="暂无车队,点击「新建车队」开始分配" />
<div v-for="(fleet, idx) in fleets" :key="fleet.id" class="fleet-card">
<div class="fc-row">
<el-input
v-model="fleet.name"
size="small"
class="fc-name"
placeholder="车队名称"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>名称</template>
</el-input>
<el-input
v-model="fleet.region"
size="small"
class="fc-region"
placeholder="区域"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>区域</template>
</el-input>
<el-input
v-model="fleet.floor"
size="small"
class="fc-floor"
placeholder="楼层"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>楼层</template>
</el-input>
<el-tag size="small" effect="plain">{{ fleet.carIds.length }} </el-tag>
<el-button
size="small"
type="danger"
text
:icon="Delete"
:disabled="!canWrite"
@click="removeFleet(idx)">
删除
</el-button>
</div>
<el-select
v-model="fleet.carIds"
size="small"
multiple
filterable
collapse-tags
collapse-tags-tooltip
class="fc-cars"
placeholder="选择要分配到该车队的车辆"
:disabled="!canWrite"
@change="markDirty">
<el-option
v-for="c in optionsForFleet(fleet)"
:key="c.id"
:label="c.label"
:value="c.id"
:disabled="c.takenByOther" />
</el-select>
</div>
</div>
<p v-if="unknownCarIds.length" class="fa-note">
提示以下已分配的车辆 ID 不在当前在册车辆中{{ unknownCarIds.join('') }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Refresh, Check, Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useConfigStore } from '@/stores/config'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { DEFAULT_FLEET } from '@/mock/data/configs'
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
const props = defineProps<{
cars: { id: string; name?: string }[]
canWrite: boolean
}>()
const emit = defineEmits<{ saved: [] }>()
const store = useConfigStore()
const { reload: reloadShared } = useFleetGroups()
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
// 保留 fleet 配置中除 groups 以外的字段(OTA / 批量 / 诊断),保存时原样回写。
const rest = ref<Omit<FleetLifecycleConfig, 'groups'>>({
ota: DEFAULT_FLEET.ota,
batchOps: DEFAULT_FLEET.batchOps,
networkDiag: DEFAULT_FLEET.networkDiag
})
const fleets = ref<FleetGroup[]>([])
function markDirty() {
dirty.value = true
}
const carIndex = computed(() => {
const m = new Map<string, string>()
for (const c of props.cars) m.set(c.id, c.name ?? c.id)
return m
})
function optionsForFleet(fleet: FleetGroup) {
const assignedElsewhere = new Set<string>()
for (const f of fleets.value) {
if (f === fleet) continue
for (const id of f.carIds) assignedElsewhere.add(id)
}
return props.cars.map((c) => ({
id: c.id,
label: c.name && c.name !== c.id ? `${c.id} · ${c.name}` : c.id,
takenByOther: assignedElsewhere.has(c.id)
}))
}
const unknownCarIds = computed(() => {
const known = carIndex.value
const out: string[] = []
for (const f of fleets.value) {
for (const id of f.carIds) {
if (!known.has(id) && !out.includes(id)) out.push(id)
}
}
return out
})
function newFleetId(): string {
const used = new Set(fleets.value.map((f) => f.id))
let i = fleets.value.length + 1
let id = `G-${i}`
while (used.has(id)) {
i += 1
id = `G-${i}`
}
return id
}
function addFleet() {
fleets.value.push({ id: newFleetId(), name: '新车队', floor: '', region: '', carIds: [] })
markDirty()
}
function removeFleet(idx: number) {
fleets.value.splice(idx, 1)
markDirty()
}
async function reload(force = false) {
loading.value = true
try {
const env = await store.load<FleetLifecycleConfig>('fleet', force)
const payload = env.payload ?? DEFAULT_FLEET
rest.value = {
ota: payload.ota ?? DEFAULT_FLEET.ota,
batchOps: payload.batchOps ?? DEFAULT_FLEET.batchOps,
networkDiag: payload.networkDiag ?? DEFAULT_FLEET.networkDiag
}
fleets.value = JSON.parse(JSON.stringify(payload.groups ?? [])) as FleetGroup[]
dirty.value = false
if (force) ElMessage.success('已重载车队配置')
} catch (e) {
ElMessage.error(`加载车队配置失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const body: FleetLifecycleConfig = {
...rest.value,
groups: JSON.parse(JSON.stringify(fleets.value)) as FleetGroup[]
}
const env = await store.save<FleetLifecycleConfig>('fleet', body)
dirty.value = false
await reloadShared(true)
emit('saved')
ElMessage.success(`车队分配已保存 v${env.version}`)
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
onMounted(() => reload())
</script>
<style scoped>
.fleet-alloc {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 12px;
background: var(--mg-veil-2, rgba(255, 255, 255, 0.03));
}
.fa-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fa-title {
font-weight: 600;
font-size: 14px;
color: var(--mg-text-light, #fff);
}
.fa-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.fa-header .spacer {
flex: 1;
}
.fa-body {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 280px;
overflow: auto;
}
.fleet-card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 10px;
}
.fc-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fc-name { width: 220px; }
.fc-region { width: 160px; }
.fc-floor { width: 150px; }
.fc-cars { width: 100%; }
.fa-note {
margin: 0;
font-size: 11px;
color: var(--el-color-warning);
}
</style>
@@ -0,0 +1,419 @@
<template>
<div
class="ai-assistant-panel"
:class="{ 'is-open': open }"
:style="{ width: panelWidth + 'px' }"
role="complementary"
aria-label="AI 助手"
>
<div
class="aap-resizer"
title="拖拽调整宽度"
@pointerdown="onResizeStart"
@pointermove="onResizeMove"
@pointerup="onResizeEnd"
@pointercancel="onResizeEnd"
></div>
<div class="aap-header">
<div class="aap-title">
<span class="aap-glyph"></span>
<div class="aap-title-text">
<div class="aap-title-main">AI 助手</div>
<div class="aap-title-sub">用自然语言描述地图需求自动生成站点 / 路径</div>
</div>
</div>
<button class="aap-close" type="button" title="收起" @click="close"></button>
</div>
<el-alert v-if="!configured" type="warning" :closable="false" class="aap-alert">
尚未配置 AI 服务apiKey / endpoint请先到
<el-link type="primary" @click="goConfig">系统级配置 AI 服务</el-link>
完成配置
</el-alert>
<div ref="listRef" class="aap-messages">
<div v-if="messages.length === 0" class="aap-empty">
<div class="aap-empty-title">试着这样说</div>
<button
v-for="(ex, i) in examples"
:key="i"
type="button"
class="aap-example"
@click="useExample(ex)"
>{{ ex }}</button>
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="aap-msg"
:class="`aap-msg--${m.role}`"
>
<div class="aap-bubble">
<div class="aap-bubble-text">{{ m.text }}</div>
<div v-if="m.role === 'assistant' && m.meta" class="aap-meta">
落地对象 <b>{{ m.meta.created }}</b> · 工具调用 <b>{{ m.meta.usedTools }}</b>
</div>
</div>
</div>
<div v-if="busy" class="aap-msg aap-msg--assistant">
<div class="aap-bubble aap-bubble--loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>AI 正在生成</span>
</div>
</div>
</div>
<div class="aap-toolbar">
<span class="aap-toolbar-label">生成模式</span>
<el-radio-group v-model="mode" size="small">
<el-radio-button value="sites">站点</el-radio-button>
<el-radio-button value="tracks">路径</el-radio-button>
<el-radio-button value="sites+tracks">站点+路径</el-radio-button>
<el-radio-button value="full">完整</el-radio-button>
</el-radio-group>
</div>
<div class="aap-input">
<el-input
v-model="draft"
type="textarea"
:rows="3"
resize="none"
:disabled="!configured || busy"
placeholder="例如:一条 U 型生产线,含 5 个工站,间距 2m,单向通行…(Enter 发送,Shift+Enter 换行)"
@keydown="onKeydown"
/>
<el-button
type="primary"
class="aap-send"
:loading="busy"
:disabled="!configured || !draft.trim()"
@click="send"
>发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
const props = defineProps<{
/** 面板是否展开(停靠在右侧)。 */
open: boolean
/** AI 服务是否已配置 apiKey / endpoint。 */
configured: boolean
/** 面板宽度(px),由父级持久化;可拖拽左沿调整。 */
width?: number
/** 生成范围默认值 x1,y1,x2,y2(mm),沿用编辑器默认。 */
defaultBounds?: [number, number, number, number]
/** 生成对象默认落点图层。 */
defaultLayer?: string
}>()
const emit = defineEmits<{
(e: 'update:open', v: boolean): void
(e: 'update:width', v: number): void
(e: 'generated', r: AiMapGenerateResult): void
}>()
/** 宽度约束:保证内容(按钮 / 单选组)不被压垮,也不至于把画布挤没。 */
const MIN_WIDTH = 300
const MAX_WIDTH = 720
const panelWidth = computed(() => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, props.width ?? 360)))
const router = useRouter()
interface ChatMessage {
role: 'user' | 'assistant'
text: string
meta?: { created: number; usedTools: number }
}
const messages = ref<ChatMessage[]>([])
const draft = ref('')
const busy = ref(false)
const listRef = ref<HTMLElement | null>(null)
// 记忆上次使用的生成模式:下次打开沿用,免去每次重选。
type GenMode = NonNullable<AiMapGenerateRequest['mode']>
const MODE_KEY = 'mapEditor.aiAssistant.mode'
function loadMode(): GenMode {
const v = localStorage.getItem(MODE_KEY)
if (v === 'sites' || v === 'tracks' || v === 'sites+tracks' || v === 'full') return v
return 'sites+tracks'
}
const mode = ref<GenMode>(loadMode())
watch(mode, (v) => {
try { localStorage.setItem(MODE_KEY, v) } catch { /* localStorage 不可用则忽略 */ }
})
// ── 拖拽调整宽度 ──
// 面板停靠右侧,左沿手柄向左拖 → 变宽。用 setPointerCapture 把后续 pointermove 锁定到
// 手柄元素上,避免指针移到中间的 webVRender iframe 上方时事件被 iframe 吞掉、拖拽中断。
let resizing = false
let resizeStartX = 0
let resizeStartW = 0
function onResizeStart(e: PointerEvent) {
resizing = true
resizeStartX = e.clientX
resizeStartW = panelWidth.value
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
e.preventDefault()
}
function onResizeMove(e: PointerEvent) {
if (!resizing) return
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, resizeStartW + (resizeStartX - e.clientX)))
emit('update:width', next)
}
function onResizeEnd(e: PointerEvent) {
if (!resizing) return
resizing = false
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
}
onBeforeUnmount(() => { resizing = false })
const examples = [
'生成一条横向直线,5 个站点,间距 2000mm',
'画一个 3×3 的站点矩阵,间距 2500mm',
'一条 U 型产线,含 5 个工站,单向通行'
]
function close() {
emit('update:open', false)
}
function goConfig() {
close()
router.push('/admin/config/system')
}
function useExample(ex: string) {
draft.value = ex
}
function onKeydown(e: Event | KeyboardEvent) {
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
if (!(e instanceof KeyboardEvent)) return
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
void send()
}
}
async function scrollToBottom() {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
const text = draft.value.trim()
if (!text || busy.value || !props.configured) return
messages.value.push({ role: 'user', text })
draft.value = ''
void scrollToBottom()
busy.value = true
try {
const req: AiMapGenerateRequest = {
prompt: text,
mode: mode.value,
bounds: props.defaultBounds,
layer: props.defaultLayer
}
const r = await mapEditApi.aiMapGenerate(req)
const created = r.created?.length ?? 0
messages.value.push({
role: 'assistant',
text: r.assistantText?.trim() || `已根据你的描述生成并落地 ${created} 个对象。`,
meta: { created, usedTools: r.usedTools ?? 0 }
})
emit('generated', r)
} catch (err) {
const msg = (err as Error).message
messages.value.push({ role: 'assistant', text: `生成失败:${msg}` })
ElMessage.error(`AI 助手生成失败:${msg}`)
} finally {
busy.value = false
void scrollToBottom()
}
}
</script>
<style scoped>
.ai-assistant-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 30;
display: flex;
flex-direction: column;
/* 实底(高不透明),解决「太透明看不清」 */
background: linear-gradient(180deg, rgba(28, 12, 56, 0.98) 0%, rgba(16, 6, 34, 0.99) 100%);
border-left: 1px solid rgba(190, 140, 240, 0.28);
box-shadow: -10px 0 30px rgba(8, 2, 16, 0.55);
color: rgba(236, 224, 250, 0.95);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
transition: transform 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.26s ease, visibility 0.26s;
}
.ai-assistant-panel.is-open {
transform: translateX(0);
opacity: 1;
visibility: visible;
}
/* 左沿拖拽手柄:覆盖在 border-left 上方,hover 高亮提示可拖拽。 */
.aap-resizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 5;
background: transparent;
transition: background 0.15s ease;
touch-action: none;
}
.aap-resizer:hover { background: rgba(190, 140, 240, 0.45); }
.aap-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.5) 0%, rgba(255, 90, 200, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.aap-glyph {
font-size: 20px;
color: #fff;
text-shadow: 0 0 10px rgba(255, 200, 250, 0.7);
flex: none;
}
.aap-title-text { min-width: 0; }
.aap-title-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; }
.aap-close {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 26px;
height: 26px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
line-height: 1;
flex: none;
transition: background 0.15s ease;
}
.aap-close:hover { background: rgba(255, 255, 255, 0.24); }
.aap-alert { margin: 10px 12px 0; }
.aap-messages {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.aap-empty { padding: 8px 2px; display: flex; flex-direction: column; gap: 8px; }
.aap-empty-title { font-size: 12px; color: rgba(210, 188, 240, 0.7); }
.aap-example {
appearance: none;
text-align: left;
border: 1px dashed rgba(190, 140, 240, 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.9);
border-radius: 8px;
padding: 8px 10px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.aap-example:hover {
background: rgba(150, 90, 230, 0.22);
border-color: rgba(190, 140, 240, 0.7);
color: #fff;
}
.aap-msg { display: flex; }
.aap-msg--user { justify-content: flex-end; }
.aap-msg--assistant { justify-content: flex-start; }
.aap-bubble {
max-width: 86%;
padding: 8px 11px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
}
.aap-msg--user .aap-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.95) 0%, rgba(120, 70, 220, 0.95) 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.aap-msg--assistant .aap-bubble {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.95);
border-bottom-left-radius: 4px;
}
.aap-bubble-text { white-space: pre-wrap; word-break: break-word; }
.aap-meta {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.14);
font-size: 11.5px;
color: rgba(210, 188, 240, 0.8);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(210, 188, 240, 0.85);
}
.aap-toolbar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 0;
flex-wrap: wrap;
}
.aap-toolbar-label { font-size: 11.5px; color: rgba(210, 188, 240, 0.7); flex: none; }
.aap-toolbar :deep(.el-radio-button__inner) {
padding: 5px 9px;
font-size: 12px;
}
.aap-input {
flex: none;
padding: 8px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.aap-send { align-self: flex-end; min-width: 84px; }
</style>
@@ -3,6 +3,7 @@
:model-value="modelValue"
title="AI 生图"
width="640px"
class="ai-generate-dialog"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@close="onClose"
>
@@ -186,3 +187,18 @@ async function onGenerate() {
}
.ai-stat b { color: var(--mg-accent, #c4a4ff); }
</style>
<!--
scopedel-dialog teleport bodyscoped data-v 不一定能命中对话框盒子
深色主题下全局 --el-bg-color 0.55 不透明度导致 AI 生图对话框太透明看不清
这里按主题色把对话框背景设为实底rgb 三元组无 alpha = 完全不透明
同时兼容 class 落在 .el-dialog 盒子或外层 overlay 两种情况
fame-lavender 浅色主题已有 `.el-dialog{background:#fff!important}` 且特异性更高不受影响
-->
<style>
.ai-generate-dialog.el-dialog,
.ai-generate-dialog .el-dialog {
background-color: rgb(var(--mg-bg-card-rgb)) !important;
box-shadow: 0 24px 60px rgba(8, 2, 16, 0.6) !important;
}
</style>
@@ -1,17 +1,13 @@
<template>
<div class="edit-top-bar">
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">项目加载和保存</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="project.open">加载文件</el-dropdown-item>
<el-dropdown-item command="project.save">保存文件</el-dropdown-item>
<el-dropdown-item command="project.saveAs">另存为</el-dropdown-item>
<el-dropdown-item command="project.props" divided>项目属性</el-dropdown-item>
<el-dropdown-item command="project.close">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
type="primary"
class="topbar-save-btn"
:loading="saving"
@click="onCmd('project.save')"
>
保存
</el-button>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">导入</el-button>
@@ -115,9 +111,6 @@
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')"></el-button>
</el-tooltip>
<el-tag v-if="saving" type="warning" effect="dark" class="topbar-saving-tag" size="small">
保存中
</el-tag>
</div>
</template>
@@ -194,8 +187,13 @@ function mark(on: boolean): string {
opacity: 0.4;
}
.topbar-saving-tag {
margin-left: 8px;
.topbar-save-btn {
font-weight: 600;
font-size: 13.5px;
padding: 6px 18px;
height: auto;
border-radius: 6px;
margin-right: 4px;
}
.topbar-filter-menu :deep(.topbar-filter-header) {
@@ -0,0 +1,302 @@
<template>
<div class="mc-panel">
<div class="mc-toolbar">
<el-input
v-model="keyword"
class="mc-search"
placeholder="请输入地图名称搜索"
clearable
:prefix-icon="Search"
/>
<el-button type="primary" :icon="Plus" @click="openCreate">添加地图关系</el-button>
</div>
<el-table
v-loading="loading"
:data="pageRows"
class="mc-table"
border
empty-text="还没有地图连接关系点击右上角添加地图关系创建跨楼层 / 拼接连接"
>
<el-table-column prop="id" label="ID" width="80" sortable />
<el-table-column prop="sourceMap" label="起始地图名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="sourceMapId" label="起始地图ID" width="110" />
<el-table-column prop="sourceStation" label="起始切换点站点" min-width="150" show-overflow-tooltip />
<el-table-column prop="targetMap" label="目的地图名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="targetMapId" label="目的地图ID" width="110" />
<el-table-column prop="targetStation" label="目的切换点站点" min-width="150" show-overflow-tooltip />
<el-table-column label="转移代价(cm" width="130">
<template #default="{ row }">
<span class="mc-cost mg-mono">{{ row.cost }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="right" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mc-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filtered.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
/>
</div>
<!-- 添加 / 编辑 地图关系 -->
<el-dialog
v-model="dialogVisible"
:title="editingId === null ? '添加地图关系' : '编辑地图关系'"
width="520px"
class="map-conn-dialog"
append-to-body
@closed="onDialogClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="right">
<el-form-item label="起始地图" prop="sourceMap">
<el-select v-model="form.sourceMap" placeholder="请选择起始地图" filterable style="width: 100%">
<el-option v-for="m in mapOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="起始切换点站点" prop="sourceStation">
<el-select
v-model="form.sourceStation"
placeholder="请选择起始切换点站点"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option v-for="s in stationOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="目的地图" prop="targetMap">
<el-select v-model="form.targetMap" placeholder="请选择目的地图" filterable style="width: 100%">
<el-option v-for="m in mapOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="目的切换点站点" prop="targetStation">
<el-select
v-model="form.targetStation"
placeholder="请选择目的切换点站点"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option v-for="s in stationOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="转移代价(cm)" prop="cost">
<el-input-number v-model="form.cost" :min="0" :step="100" :controls="false" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { mapsApi } from '@/api/mapEdit'
import { mapConnectionApi, type MapConnection } from '@/api/mapConnection'
const loading = ref(false)
const saving = ref(false)
const rows = ref<MapConnection[]>([])
const mapOptions = ref<string[]>([])
const stationOptions = ref<string[]>([])
const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const form = reactive({
sourceMap: '',
sourceStation: '',
targetMap: '',
targetStation: '',
cost: 1000
})
const rules: FormRules = {
sourceMap: [{ required: true, message: '请选择起始地图', trigger: 'change' }],
sourceStation: [{ required: true, message: '请选择起始切换点站点', trigger: 'change' }],
targetMap: [{ required: true, message: '请选择目的地图', trigger: 'change' }],
targetStation: [{ required: true, message: '请选择目的切换点站点', trigger: 'change' }],
cost: [{ required: true, message: '请输入转移代价', trigger: 'blur' }]
}
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return rows.value
return rows.value.filter(
(r) => r.sourceMap.toLowerCase().includes(kw) || r.targetMap.toLowerCase().includes(kw)
)
})
const pageRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filtered.value.slice(start, start + pageSize.value)
})
// 搜索 / 分页大小变化时回到第一页,避免停留在空白页。
watch([keyword, pageSize], () => {
page.value = 1
})
async function refresh() {
loading.value = true
try {
rows.value = await mapConnectionApi.list()
stationOptions.value = mapConnectionApi.stationSuggestions()
} catch (err) {
ElMessage.error(`加载地图连接失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
async function loadMaps() {
try {
const r = await mapsApi.list()
mapOptions.value = r.maps.map((m) => m.name)
} catch {
// 地图列表拉取失败不阻塞连接管理;下拉为空时仍可手动输入站点。
mapOptions.value = []
}
}
function resetForm() {
form.sourceMap = ''
form.sourceStation = ''
form.targetMap = ''
form.targetStation = ''
form.cost = 1000
}
function openCreate() {
editingId.value = null
resetForm()
stationOptions.value = mapConnectionApi.stationSuggestions()
dialogVisible.value = true
}
function openEdit(row: MapConnection) {
editingId.value = row.id
form.sourceMap = row.sourceMap
form.sourceStation = row.sourceStation
form.targetMap = row.targetMap
form.targetStation = row.targetStation
form.cost = row.cost
stationOptions.value = mapConnectionApi.stationSuggestions()
dialogVisible.value = true
}
function onDialogClosed() {
formRef.value?.clearValidate()
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
const payload = {
sourceMap: form.sourceMap,
sourceStation: form.sourceStation,
targetMap: form.targetMap,
targetStation: form.targetStation,
cost: form.cost
}
if (editingId.value === null) {
await mapConnectionApi.create(payload)
ElMessage.success('已添加地图连接')
} else {
await mapConnectionApi.update(editingId.value, payload)
ElMessage.success('已更新地图连接')
}
dialogVisible.value = false
await refresh()
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
})
}
async function onDelete(row: MapConnection) {
try {
await ElMessageBox.confirm(
`确认删除「${row.sourceMap}${row.targetMap}」这条地图连接?`,
'删除地图连接',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
await mapConnectionApi.remove(row.id)
ElMessage.success('已删除')
// 删除后当前页可能空了,回退一页。
if (pageRows.value.length === 1 && page.value > 1) page.value -= 1
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`删除失败:${(err as Error).message}`)
}
}
onMounted(() => {
refresh()
loadMaps()
})
defineExpose({ refresh })
</script>
<style scoped>
.mc-panel {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.mc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.mc-search {
width: 300px;
max-width: 60%;
}
.mc-table {
flex: 1;
min-height: 0;
}
.mc-cost {
font-variant-numeric: tabular-nums;
}
.mc-pager {
flex-shrink: 0;
display: flex;
justify-content: center;
padding-top: 2px;
}
</style>
@@ -0,0 +1,223 @@
<template>
<div class="mm-merge">
<el-alert class="merge-tip" type="info" :closable="false" show-icon>
<template #title>
与桌面端合并一致<strong>当前使用地图</strong>为底图把选中的地图依次合并进来自动分配独立图层
站点 / 路径 ID 自动避让互不冲突再另存为目标地图可用于多楼层汇总多区域拼接
</template>
</el-alert>
<el-alert
v-if="!loadingMaps && !currentName"
class="merge-tip"
type="warning"
:closable="false"
show-icon
title="当前未设置「使用中」地图。请先到「服务器地图」页对某张地图点「使用」,再来执行合并。"
/>
<div class="merge-body">
<el-form label-width="120px" label-position="right" class="merge-form" @submit.prevent>
<el-form-item label="底图(当前地图)">
<el-tag v-if="currentName" type="success" effect="plain">{{ currentName }}</el-tag>
<span v-else class="merge-hint">未设置</span>
</el-form-item>
<el-form-item label="合并进来的地图" required>
<el-select
v-model="selected"
multiple
filterable
:loading="loadingMaps"
:disabled="!currentName"
placeholder="选择 1 张及以上要合并进当前地图的地图"
style="width: 100%"
>
<el-option v-for="m in sourceOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="另存为" required>
<el-input
v-model="target"
:disabled="!currentName"
placeholder="目标地图名称(与当前地图同名则覆盖当前地图)"
clearable
maxlength="60"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="merging" :disabled="!canMerge" @click="onMerge">开始合并</el-button>
<el-button text :disabled="!currentName" @click="reset">重置</el-button>
<span class="merge-hint">已选 {{ selected.length }} </span>
</el-form-item>
</el-form>
<div v-if="currentName" class="merge-order">
<div class="order-title">叠加顺序</div>
<ol class="order-list">
<li class="order-item">
<el-tag size="small" type="success" effect="plain">底图</el-tag>
<span class="order-name">{{ currentName }}</span>
</li>
<li v-for="(m, i) in selected" :key="m" class="order-item">
<el-tag size="small" type="info" effect="plain">叠加 {{ i + 1 }}</el-tag>
<span class="order-name">{{ m }}</span>
</li>
</ol>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mapsApi } from '@/api/mapEdit'
const emit = defineEmits<{ (e: 'merged', name: string): void }>()
const loadingMaps = ref(false)
const merging = ref(false)
const allMaps = ref<string[]>([])
const currentName = ref('')
const selected = ref<string[]>([])
const target = ref('')
// 可合并的源地图 = 全部地图去掉「当前地图」自身。
const sourceOptions = computed(() => allMaps.value.filter((m) => m !== currentName.value))
const canMerge = computed(
() => !!currentName.value && selected.value.length >= 1 && target.value.trim().length > 0
)
async function loadMaps() {
loadingMaps.value = true
try {
const r = await mapsApi.list()
allMaps.value = r.maps.map((m) => m.name)
currentName.value = r.maps.find((m) => m.isCurrent)?.name ?? ''
// 当前地图变化时,剔除已不可选的项。
selected.value = selected.value.filter((m) => sourceOptions.value.includes(m))
} catch (err) {
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
} finally {
loadingMaps.value = false
}
}
function reset() {
selected.value = []
target.value = ''
}
async function doMerge(overwrite: boolean) {
const sources = [...selected.value]
const name = target.value.trim()
const res = await mapsApi.merge(sources, name, overwrite)
if (res.ok) {
const into = res.data.overwroteCurrent ? '(已覆盖当前地图)' : ''
ElMessage.success(
`已把 ${res.data.sourceCount} 张地图合并进「${res.data.baseMap}」并另存为「${res.data.name}${into}` +
`(站点 ${res.data.sites} · 路径 ${res.data.tracks}`
)
reset()
await loadMaps()
emit('merged', name)
return
}
if (res.conflict) {
const tip =
name === currentName.value
? `目标与当前地图「${name}」同名,将用合并结果覆盖当前地图,是否继续?`
: `地图「${name}」已存在,是否替换原地图?`
try {
await ElMessageBox.confirm(tip, '目标地图已存在', {
type: 'warning',
confirmButtonText: '替换',
cancelButtonText: '取消'
})
} catch {
return
}
await doMerge(true)
return
}
ElMessage.error(res.message)
}
async function onMerge() {
if (!canMerge.value) return
merging.value = true
try {
await doMerge(false)
} finally {
merging.value = false
}
}
onMounted(loadMaps)
defineExpose({ refresh: loadMaps })
</script>
<style scoped>
.mm-merge {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: auto;
}
.merge-tip {
flex-shrink: 0;
}
.merge-body {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.merge-form {
flex: 1 1 440px;
max-width: 580px;
}
.merge-hint {
margin-left: 12px;
font-size: 12.5px;
color: var(--mg-text-muted);
}
.merge-order {
flex: 0 1 280px;
min-width: 220px;
padding: 12px 16px;
border-radius: var(--mg-radius);
background: rgba(var(--mg-accent-rgb), 0.08);
border: 1px solid var(--mg-veil-border);
}
.order-title {
font-size: 13px;
font-weight: 600;
color: var(--mg-text-light);
margin-bottom: 10px;
}
.order-list {
margin: 0;
padding-left: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.order-item {
display: flex;
align-items: center;
gap: 8px;
}
.order-name {
color: var(--mg-text-light);
font-size: 13px;
word-break: break-all;
}
</style>
@@ -0,0 +1,39 @@
import { ref } from 'vue'
import { useConfigStore } from '@/stores/config'
import { DEFAULT_FLEET } from '@/mock/data/configs'
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
const groups = ref<FleetGroup[]>([])
const loading = ref(false)
/** 共享车队分组配置(运维总览分配 + 筛选联动) */
export function useFleetGroups() {
const store = useConfigStore()
async function reload(force = false) {
loading.value = true
try {
const env = await store.load<FleetLifecycleConfig>('fleet', force)
const payload = env.payload ?? DEFAULT_FLEET
groups.value = JSON.parse(JSON.stringify(payload.groups ?? [])) as FleetGroup[]
} finally {
loading.value = false
}
}
function fleetNameForCarId(carId: string): string | undefined {
for (const g of groups.value) {
if (g.carIds.includes(carId)) return g.name || g.id
}
return undefined
}
function regionForCarId(carId: string): string | undefined {
for (const g of groups.value) {
if (g.carIds.includes(carId)) return g.region || undefined
}
return undefined
}
return { groups, loading, reload, fleetNameForCarId, regionForCarId }
}
@@ -80,6 +80,7 @@
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
<el-dropdown-item command="status">服务状态</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
@@ -146,6 +147,7 @@ const ADMIN_MENU: MenuItem[] = [
{
path: '/admin/design', label: '设计与编排', icon: Tools,
children: [
{ path: '/admin/maps', label: '地图管理', key: 'admin-maps' },
{ path: '/admin/map-editor', label: '地图编辑', key: 'admin-map-editor' },
{ path: '/admin/project-properties', label: '项目属性', key: 'admin-project-properties' },
{ path: '/admin/tracks', label: '场景管理', key: 'admin-tracks' },
@@ -223,6 +225,8 @@ function onUserCommand(cmd: string) {
router.push('/login')
} else if (cmd === 'status') {
router.push('/status')
} else if (cmd === 'wizard') {
router.push('/wizard')
}
}
</script>
@@ -25,7 +25,8 @@ export const DEFAULT_SYSTEM: SystemConfig = {
export const DEFAULT_INTEGRATIONS: ExternalIntegrations = {
mes: [{ id: 'mes-1', name: 'MES 主线', url: 'http://mes.lan/api', enabled: true }],
wms: [{ id: 'wms-1', name: 'WMS 仓储', url: 'http://wms.lan/api', enabled: true }],
rcs: []
rcs: [],
ptl: [{ id: 'ptl-1', name: 'PTL 拣选', url: 'http://ptl.lan/api', enabled: true }]
}
export const DEFAULT_ROUTING: RoutingPolicy = {
@@ -13,6 +13,7 @@ const PAGES: PageDef[] = [
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
{ key: 'admin-playback', label: '调度回放', group: '概览', scope: 'Platform' },
{ key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-tracks', label: '场景管理', group: '设计与编排', scope: 'Platform' },
@@ -15,6 +15,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/ServiceStatusView.vue'),
meta: { layout: 'blank', public: true, title: '服务状态' }
},
{
// 部署配置向导:首次部署强制完成平台选型(导航方式 / 模块 / 功能)。需登录,但不属于 admin/monitor scope。
path: '/wizard',
name: 'wizard',
component: () => import('@/views/WizardView.vue'),
meta: { layout: 'blank', title: '配置向导' }
},
{
path: '/admin',
component: () => import('@/layouts/AppShell.vue'),
@@ -23,6 +30,7 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
{ path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } },
{ path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } },
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
{ path: 'cars', name: 'admin-cars', component: () => import('@/views/admin/CarPanelView.vue'), meta: { title: '车辆管理' } },
@@ -109,6 +117,12 @@ router.beforeEach(async (to) => {
}
}
// 部署配置向导:首次部署(deployment.Configured=false)时,强制先完成平台选型再进入业务页。
// 已在 /wizard 则放行,避免自跳死循环;保存成功后 store.markWizardDone() 解除拦截。
if (auth.needsWizard && to.name !== 'wizard') {
return { name: 'wizard' }
}
// 会话 45 AR-6switchScope 改为后端发起 ——
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
@@ -19,6 +19,8 @@ interface AuthState {
* 在用户离开期间重启(JWT secret 重生)的场景下能立刻被发现并跳登录。
*/
validated: boolean
/** 部署配置向导是否待完成(来自登录/me 响应;仅内存态,刷新后由 validate 重新拉取)。 */
needsWizard: boolean
}
const TOKEN_KEY = 'simple.auth.token'
@@ -49,7 +51,9 @@ function loadState(): AuthState {
runMode: safeReadString(RUN_MODE_KEY) as RunMode | null,
effectivePermissions: safeJsonParse<EffectivePermissions>(PERM_KEY),
// 刷新页面后默认未校验:路由守卫会在受保护路由首次进入前 await validate()。
validated: false
validated: false,
// 部署向导状态不持久化:刷新后默认 false,validate() 用后端最新值回填,避免误拦。
needsWizard: false
}
}
@@ -61,6 +65,7 @@ function clearLocalAuth(target: AuthState) {
target.runMode = null
target.effectivePermissions = null
target.validated = false
target.needsWizard = false
try {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
@@ -105,6 +110,7 @@ export const useAuthStore = defineStore('auth', {
this.scope = resp.scope
this.runMode = resp.runMode
this.effectivePermissions = resp.effectivePermissions
this.needsWizard = resp.needsWizard ?? false
// 登录响应本身就是后端的身份背书,等同于一次成功的 /me;省一次往返。
this.validated = true
localStorage.setItem(TOKEN_KEY, resp.token)
@@ -136,6 +142,7 @@ export const useAuthStore = defineStore('auth', {
this.scope = me.scope
this.runMode = me.runMode
this.effectivePermissions = me.effectivePermissions
this.needsWizard = me.needsWizard ?? false
this.validated = true
localStorage.setItem(USER_KEY, JSON.stringify(me.user))
localStorage.setItem(SCOPE_KEY, me.scope)
@@ -161,6 +168,7 @@ export const useAuthStore = defineStore('auth', {
this.scope = resp.scope
this.runMode = resp.runMode
this.effectivePermissions = resp.effectivePermissions
this.needsWizard = resp.needsWizard ?? false
// SwitchScope 后端重发了 token + perm,等同于一次成功的 /me,保持 validated 为 true。
this.validated = true
localStorage.setItem(TOKEN_KEY, resp.token)
@@ -169,6 +177,10 @@ export const useAuthStore = defineStore('auth', {
localStorage.setItem(RUN_MODE_KEY, resp.runMode)
localStorage.setItem(PERM_KEY, JSON.stringify(resp.effectivePermissions))
return resp
},
/** 向导保存成功后调用:清掉 needsWizard,避免守卫再次把用户导回 /wizard。 */
markWizardDone() {
this.needsWizard = false
}
}
})
@@ -35,6 +35,24 @@
--mg-font-sans: 'PingFang SC', 'Microsoft YaHei', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mg-font-mono: 'JetBrains Mono', 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
/* ── 登录页 / 配置向导 固定品牌色(紫蓝混合,永不随主题切换变化) ──
* 登录窗与「平台配置向导」共用同一套定值,保证两处观感统一、且不受用户切换主题影响。
* 色相:靛紫 #7c5cff ↔ 蓝紫(periwinkle) #5e7cff 的「紫蓝」渐变,刻意避开纯蓝 / 青色。 */
--lg-primary: #7d4dff;
--lg-primary-rgb: 125, 77, 255;
--lg-primary-hover: #9c79ff;
--lg-primary-hover-rgb: 156, 121, 255;
--lg-primary-deep: #5a2fc4;
/* accent 与登录按钮同款紫(同色系、略亮),让强调色/光晕/激活态都呈现按钮那种紫 */
--lg-accent: #8b5cff;
--lg-accent-rgb: 139, 92, 255;
--lg-deep-rgb: 14, 9, 28;
--lg-aside-rgb: 30, 20, 58;
--lg-card-rgb: 36, 23, 68;
--lg-text: #eef0ff;
--lg-text-soft: rgba(220, 220, 250, 0.82);
--lg-text-dim: rgba(196, 198, 235, 0.58);
/* ── 品牌主色(默认 = 星云紫,可被 themes.ts 注入覆盖) ── */
--mg-primary: #7c3aed;
--mg-primary-rgb: 124, 58, 237;
@@ -73,6 +73,12 @@ export interface LoginResponse {
* (比如「检测到 SimpleLite 已经在端口上运行,本次选择的启动模式未生效」)。
*/
launchWarning?: string
/**
* 部署配置向导是否待完成(后端 deployment.Configured=false)。
* true 时路由守卫会把用户导向 /wizard 完成平台选型(导航方式 / 模块 / 功能)。
* 字段缺失(旧后端 / mock)按 false 处理。
*/
needsWizard?: boolean
}
/**
@@ -84,4 +90,6 @@ export interface MeResponse {
scope: Scope
runMode: RunMode
effectivePermissions: EffectivePermissions
/** 部署配置向导是否待完成(与 LoginResponse.needsWizard 同义)。 */
needsWizard?: boolean
}
@@ -23,11 +23,13 @@ export interface SystemConfig {
export interface MesEndpoint { id: string; name: string; url: string; enabled: boolean }
export interface WmsEndpoint { id: string; name: string; url: string; enabled: boolean }
export interface RcsEndpoint { id: string; name: string; url: string; enabled: boolean }
export interface PtlEndpoint { id: string; name: string; url: string; enabled: boolean }
export interface ExternalIntegrations {
mes: MesEndpoint[]
wms: WmsEndpoint[]
rcs: RcsEndpoint[]
ptl: PtlEndpoint[]
}
export interface AvoidanceRule { id: string; zoneId: string; rule: string }
@@ -0,0 +1,72 @@
// 配置向导(部署画像)相关类型,对应后端 MiGu.Server 的
// GET /api/wizard/options → WizardOptions
// GET /api/wizard/profile → DeploymentProfileDto
// PUT /api/wizard/profile → DeploymentProfileDto
// GET /api/wizard/effective-pages→ EffectivePagesDto
/** 单个可勾选项(导航方式 / 模块)。 */
export interface WizardOption {
id: string
name: string
group: string
description: string
}
/** 业务场景模板(后端 ScenarioTemplateConfig.templates 透传项,只取展示所需字段)。 */
export interface ScenarioTemplateLite {
id: string
name: string
category?: string
version?: string
}
/** 后端 scenario section 透传负载(只声明向导用到的 templates)。 */
export interface ScenarioPayload {
templates?: ScenarioTemplateLite[]
}
/** 向导可选项目录。 */
export interface WizardOptions {
navigationKinds: WizardOption[]
modules: WizardOption[]
scenarios: ScenarioPayload
}
/** 保存 active-scenes.json 的结果回显。 */
export interface ActiveScenesWriteDto {
ok: boolean
path?: string | null
error?: string | null
}
/** 部署画像(向导结果)。 */
export interface DeploymentProfileDto {
configured: boolean
platformType: string
modules: string[]
navigationKinds: string[]
scenarios: string[]
updatedBy: string
/** 由导航选型推导的 SimpleLite 激活场景 id(如 scene.magnetic)。 */
activeSceneIds: string[]
/** 被部署画像裁剪隐藏的页面 Key。 */
hiddenPages: string[]
/** 仅保存接口返回:写 active-scenes.json 的结果。 */
activeScenesWrite?: ActiveScenesWriteDto | null
}
/** 保存向导请求体。 */
export interface SaveWizardRequest {
platformType?: string
modules?: string[]
navigationKinds?: string[]
scenarios?: string[]
}
/** 当前部署画像对菜单的裁剪结果。 */
export interface EffectivePagesDto {
configured: boolean
tailorablePages: string[]
enabledPages: string[]
hiddenPages: string[]
}
@@ -6,27 +6,36 @@
<div class="bg-orb bg-orb-b" />
<div class="bg-orb bg-orb-c" />
<div class="bg-stars" aria-hidden="true">
<span v-for="i in 18" :key="i" :style="starStyles[i - 1]" />
<span v-for="(s, i) in starStyles" :key="i" :style="s" />
</div>
<div class="login-card mg-glass-lg">
<div class="login-card">
<!-- 左侧品牌 hero -->
<div class="hero">
<div class="hero-stars" aria-hidden="true">
<span v-for="(s, i) in heroStarStyles" :key="i" :style="s" />
</div>
<div class="hero-brand">
<div class="hero-mark"></div>
<div class="hero-name">迷毂</div>
<span class="hero-mark">
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="hero-mark-img" />
</span>
<div class="hero-name"> </div>
</div>
<div class="hero-tagline"> </div>
<div class="hero-tagline-en">INTELLIGENT · DISPATCH · PLATFORM</div>
<div class="hero-divider" />
<ul class="hero-points">
<li><span class="dot" /> 复刻 ARCHITECTURE.md §3.2 启动登录窗</li>
<li><span class="dot" /> Web-Enabled · 管理员 / 运营 双视图</li>
<li><span class="dot" /> 嵌入 webVRender 真实 3D 场景</li>
<li v-for="f in features" :key="f.title">
<span class="dot" />
<div class="feat">
<span class="feat-title">{{ f.title }}</span>
<span class="feat-desc">{{ f.desc }}</span>
</div>
</li>
</ul>
<div class="hero-footnote">
<span class="badge">v1.7</span>
<span>{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
<span>稳定 · 高效 · 安全的智能调度内核</span>
</div>
</div>
@@ -34,7 +43,7 @@
<div class="panel">
<div class="panel-head">
<div class="panel-title"> </div>
<div class="panel-sub">访问 SimpleLite Vue 外壳</div>
<div class="panel-sub">登录以进入智能调度平台</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
@@ -146,7 +155,7 @@
</div>
<div class="footer-stamp">
· {{ year }} · ARCHITECTURE v1.6 · powered by webVRender
· 智能调度平台 · {{ year }}
</div>
</div>
</template>
@@ -158,13 +167,20 @@ import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { User, Lock, Setting, Monitor, Cpu, Connection, QuestionFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import type { LaunchMode, Scope } from '@/types/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const ui = useUiStore()
// 左侧品牌区的产品特性介绍(标签 + 描述),用于强调平台核心能力。
const features = [
{ title: '路径规划与导航', desc: '全局 + 实时双层路径算法,智能寻优路线' },
{ title: '交通管制', desc: '任务队列 + 优先级动态调度,提前规避路口拥堵冲突' },
{ title: '安全可靠性', desc: '约束设备运行规范,实现 AGV 与产线设备协同安全作业' },
{ title: '高级任务调度', desc: '按任务优先级、现场资源动态分配搬运任务' },
{ title: '插件扩展', desc: '插件化架构,支持功能定制、二次开发适配场景' }
]
const formRef = ref<FormInstance>()
const loading = ref(false)
@@ -196,22 +212,24 @@ const rules: FormRules = {
const year = computed(() => new Date().getFullYear())
const starStyles = Array.from({ length: 18 }, (_, i) => {
const top = Math.random() * 100
const left = Math.random() * 100
const size = 2 + Math.random() * 3
const delay = -Math.random() * 6
const opacity = 0.35 + Math.random() * 0.45
void i
return {
top: `${top}%`,
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
opacity: `${opacity}`,
animationDelay: `${delay}s`
} as Record<string, string>
})
// 生成 n 个随机分布、随机大小与闪烁节奏的小白点,营造梦幻星点。
function makeStars(n: number): Record<string, string>[] {
return Array.from({ length: n }, () => {
const size = 1.5 + Math.random() * 2.8
return {
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${size}px`,
height: `${size}px`,
opacity: `${0.35 + Math.random() * 0.5}`,
animationDelay: `${-Math.random() * 6}s`,
animationDuration: `${2.6 + Math.random() * 3.4}s`
} as Record<string, string>
})
}
// 背景层(卡片之外)+ 品牌区(卡片内深色区)两组星点
const starStyles = makeStars(48)
const heroStarStyles = makeStars(22)
async function submit() {
if (!formRef.value) return
@@ -251,104 +269,130 @@ async function submit() {
</script>
<style scoped>
/* ===== 背景层 ===== */
/* ════════════════════════════════════════════════════════════════════════
* 登录页固定配色(不随全局主题切换变化)。
* 统一用一组本地 --lg-* 变量(午夜靛蓝 + 青色霓虹),保证无论用户切到哪个
* 主题,登录界面始终是同一套高级、克制的深色质感。
* ════════════════════════════════════════════════════════════════════════ */
.login-page {
/* 梦幻晨曦渐变:粉紫(左上) → 蓝紫(中) → 淡蓝(右下),高明度柔光,参考产品视觉稿。 */
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
color: var(--lg-text);
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
/* ===== 背景层 ===== */
.bg-image {
position: absolute; inset: 0;
background: url('/login-bg.jpg') center/cover no-repeat;
filter: saturate(0.35) brightness(0.34) hue-rotate(-12deg);
/* 亮调下仅保留极淡纹理,不压暗整体。 */
filter: saturate(0.6) brightness(1.1) contrast(0.9);
opacity: 0.07;
transform: scale(1.08);
z-index: 0;
}
.bg-overlay {
position: absolute; inset: 0;
/* 柔光叠加(提亮,不再压暗):左上奶白高光 + 右下淡蓝 + 中央粉紫。 */
background:
radial-gradient(ellipse at 12% 18%, rgba(var(--mg-primary-hover-rgb), 0.65) 0%, transparent 55%),
radial-gradient(ellipse at 85% 80%, rgba(var(--mg-bg-app-deep-rgb), 0.78) 0%, transparent 60%),
radial-gradient(ellipse at 50% 50%, rgba(var(--mg-primary-rgb), 0.40) 0%, transparent 70%),
linear-gradient(135deg, rgba(var(--mg-bg-aside-rgb), 0.85) 0%, rgba(var(--mg-primary-rgb), 0.48) 50%, rgba(var(--mg-bg-app-deep-rgb), 0.88) 100%);
radial-gradient(ellipse at 14% 16%, rgba(255, 255, 255, 0.38) 0%, transparent 50%),
radial-gradient(ellipse at 86% 84%, rgba(174, 203, 245, 0.45) 0%, transparent 56%),
radial-gradient(ellipse at 50% 50%, rgba(220, 190, 247, 0.22) 0%, transparent 72%);
z-index: 1;
}
/* 登录页:紫色网格层(更明显) */
/* 细网格层(青色,克制) */
.bg-overlay::after {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px);
background-size: 56px 56px, 56px 56px;
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
linear-gradient(rgba(var(--lg-accent-rgb), 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--lg-accent-rgb), 0.06) 1px, transparent 1px);
background-size: 60px 60px, 60px 60px;
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, transparent 72%);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, transparent 72%);
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
filter: blur(100px);
z-index: 1;
pointer-events: none;
animation: drift 18s ease-in-out infinite;
animation: drift 20s ease-in-out infinite;
}
.bg-orb-a {
width: 460px; height: 460px;
top: -140px; left: -120px;
background: radial-gradient(circle, var(--mg-accent) 0%, var(--mg-primary) 50%, transparent 80%);
opacity: 0.6;
top: -150px; left: -130px;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 52%, transparent 80%);
opacity: 0.24;
}
.bg-orb-b {
width: 580px; height: 580px;
bottom: -200px; right: -180px;
background: radial-gradient(circle, var(--mg-primary-hover) 0%, var(--mg-bg-app-3) 60%, transparent 85%);
opacity: 0.55;
animation-delay: -6s;
width: 600px; height: 600px;
bottom: -220px; right: -190px;
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary-deep) 58%, transparent 86%);
opacity: 0.18;
animation-delay: -7s;
}
.bg-orb-c {
width: 320px; height: 320px;
top: 30%; right: 8%;
background: radial-gradient(circle, var(--mg-primary-light-8) 0%, var(--mg-primary-hover) 60%, transparent 90%);
opacity: 0.32;
animation-delay: -12s;
top: 32%; right: 9%;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary) 60%, transparent 90%);
opacity: 0.14;
animation-delay: -13s;
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, -30px) scale(1.05); }
50% { transform: translate(18px, -28px) scale(1.05); }
}
.bg-stars { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.bg-stars span {
position: absolute;
background: #fff;
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
box-shadow:
0 0 6px rgba(255, 255, 255, 0.85),
0 0 12px rgba(150, 130, 255, 0.6);
animation: twinkle 4s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1.0; transform: scale(1.2); }
0%, 100% { opacity: 0.35; transform: scale(0.8); }
50% { opacity: 0.95; transform: scale(1.2); }
}
/* ===== 卡片:双栏 hero ===== */
.login-card {
position: relative;
z-index: 2;
width: 880px;
width: 920px;
max-width: calc(100vw - 32px);
min-height: 560px;
min-height: 588px;
display: grid;
grid-template-columns: 340px 1fr;
grid-template-columns: 360px 1fr;
overflow: hidden;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
/* 登录框本体:紫 → 靛蓝的「紫蓝混合」渐变,梦幻且不发灰;高不透明度保证文字清晰 */
background:
linear-gradient(140deg,
rgba(48, 26, 96, 0.95) 0%,
rgba(33, 26, 92, 0.955) 50%,
rgba(22, 26, 84, 0.96) 100%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.65),
0 0 80px rgba(var(--mg-primary-rgb), 0.42),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.30) inset,
0 1px 0 rgba(255, 255, 255, 0.40) inset !important;
0 44px 110px rgba(0, 0, 0, 0.62),
0 0 70px rgba(var(--lg-primary-rgb), 0.24),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.16) inset,
0 1px 0 rgba(255, 255, 255, 0.18) inset;
}
/* 顶部霓虹高光线 */
.login-card::before {
@@ -358,9 +402,9 @@ async function submit() {
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--mg-accent-rgb), 0.6) 30%,
rgba(255, 255, 255, 0.95) 50%,
rgba(var(--mg-accent-rgb), 0.6) 70%,
rgba(var(--lg-accent-rgb), 0.55) 30%,
rgba(255, 255, 255, 0.92) 50%,
rgba(var(--lg-accent-rgb), 0.55) 70%,
transparent 100%);
pointer-events: none;
z-index: 3;
@@ -369,88 +413,134 @@ async function submit() {
/* ===== 左侧 hero ===== */
.hero {
position: relative;
padding: 40px 32px;
padding: 44px 34px 34px;
color: #fff;
/* 品牌区紫蓝混合:左上紫光球 + 右下蓝光球,叠在深紫底上 */
background:
linear-gradient(180deg, rgba(var(--mg-primary-hover-rgb), 0.42) 0%, rgba(var(--mg-bg-aside-rgb), 0.35) 100%);
border-right: 1px solid rgba(255, 255, 255, 0.12);
radial-gradient(ellipse at 16% 18%, rgba(123, 77, 255, 0.34) 0%, transparent 56%),
radial-gradient(ellipse at 88% 84%, rgba(94, 124, 255, 0.32) 0%, transparent 58%),
linear-gradient(170deg, rgba(var(--lg-primary-rgb), 0.26) 0%, rgba(var(--lg-aside-rgb), 0.42) 60%, rgba(var(--lg-deep-rgb), 0.55) 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
}
.hero::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse at 80% 20%, rgba(var(--mg-accent-rgb), 0.35), transparent 60%);
background: radial-gradient(ellipse at 78% 16%, rgba(var(--lg-accent-rgb), 0.24), transparent 58%);
pointer-events: none;
}
.hero > * { position: relative; z-index: 1; }
/* 品牌区梦幻星点:浮在 hero 底色之上、文字之下,闪烁 */
.hero .hero-stars {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.hero-stars span {
position: absolute;
background: rgba(255, 255, 255, 0.92);
border-radius: 50%;
box-shadow:
0 0 6px rgba(255, 255, 255, 0.8),
0 0 12px rgba(150, 130, 255, 0.6);
animation: twinkle 4s ease-in-out infinite;
}
.hero-brand { display: flex; align-items: center; gap: 14px; }
.hero-mark {
width: 56px; height: 56px; border-radius: 14px;
background: linear-gradient(135deg, var(--mg-accent) 0%, var(--mg-primary-hover) 50%, var(--mg-primary-active) 100%);
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 700; color: #fff;
display: inline-flex; align-items: center; justify-content: center;
height: 56px;
padding: 0 14px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.55) 0%, rgba(var(--lg-accent-rgb), 0.30) 100%);
border: 1px solid rgba(255, 255, 255, 0.20);
box-shadow:
0 14px 32px rgba(var(--mg-primary-rgb), 0.65),
0 0 36px rgba(var(--mg-primary-hover-rgb), 0.55),
0 0 0 1px rgba(255, 255, 255, 0.30) inset;
letter-spacing: 1px;
animation: mg-pulse-glow 3.6s ease-in-out infinite;
0 12px 30px rgba(var(--lg-primary-rgb), 0.45),
0 0 30px rgba(var(--lg-accent-rgb), 0.30),
0 0 0 1px rgba(255, 255, 255, 0.16) inset;
animation: mg-mark-glow 4s ease-in-out infinite;
}
@keyframes mg-mark-glow {
0%, 100% { box-shadow: 0 12px 30px rgba(var(--lg-primary-rgb), 0.45), 0 0 24px rgba(var(--lg-accent-rgb), 0.24), 0 0 0 1px rgba(255, 255, 255, 0.16) inset; }
50% { box-shadow: 0 14px 34px rgba(var(--lg-primary-rgb), 0.55), 0 0 40px rgba(var(--lg-accent-rgb), 0.42), 0 0 0 1px rgba(255, 255, 255, 0.22) inset; }
}
.hero-mark-img {
display: block;
height: 34px;
width: auto;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35));
}
.hero-name {
font-size: 34px; font-weight: 700;
letter-spacing: 4px;
background: linear-gradient(90deg, #ffffff 0%, var(--mg-accent) 100%);
letter-spacing: 6px;
/* 紫蓝混合渐变字:白 → 浅紫 → 浅蓝 */
background: linear-gradient(95deg, #ffffff 0%, #c3b6ff 52%, #93a7ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 24px rgba(var(--mg-accent-rgb), 0.45);
text-shadow: 0 0 24px rgba(120, 130, 255, 0.4);
}
.hero-tagline {
margin-top: 32px;
font-size: 18px;
margin-top: 30px;
font-size: 17px;
font-weight: 500;
letter-spacing: 8px;
color: rgba(255, 255, 255, 0.94);
color: rgba(255, 255, 255, 0.95);
}
.hero-tagline-en {
margin-top: 4px;
font-size: 11px;
margin-top: 5px;
font-size: 10.5px;
letter-spacing: 3px;
color: rgba(232, 215, 245, 0.6);
color: var(--lg-text-dim);
text-transform: uppercase;
}
.hero-divider {
margin: 22px 0;
margin: 22px 0 18px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--lg-accent-rgb), 0.5), transparent);
}
.hero-points {
list-style: none;
padding: 0; margin: 0;
font-size: 12.5px;
color: rgba(236, 225, 245, 0.85);
line-height: 1.9;
display: flex;
flex-direction: column;
gap: 13px;
flex: 1;
}
.hero-points li { display: flex; align-items: center; gap: 8px; }
.hero-points li { display: flex; align-items: flex-start; gap: 10px; }
.hero-points .dot {
margin-top: 7px;
width: 6px; height: 6px; border-radius: 50%;
background: var(--mg-accent);
box-shadow: 0 0 10px rgba(var(--mg-accent-rgb), 0.85);
background: var(--lg-accent);
box-shadow: 0 0 10px rgba(var(--lg-accent-rgb), 0.85);
flex-shrink: 0;
}
.feat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.feat-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0.5px;
color: #fff;
}
.feat-desc {
font-size: 11.5px;
line-height: 1.55;
color: var(--lg-text-soft);
}
.hero-footnote {
margin-top: auto;
margin-top: 22px;
display: flex; align-items: center; gap: 10px;
font-size: 11px;
color: rgba(232, 215, 245, 0.55);
letter-spacing: 1px;
color: var(--lg-text-dim);
letter-spacing: 0.6px;
}
.hero-footnote .badge {
padding: 2px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.20);
color: #fff;
font-weight: 600;
}
@@ -460,23 +550,24 @@ async function submit() {
padding: 44px 44px 32px;
display: flex;
flex-direction: column;
/* 表单侧:紫 → 蓝的洗光,与品牌区一致的「紫蓝混合」基调 */
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
linear-gradient(135deg, rgba(123, 77, 255, 0.14) 0%, rgba(94, 124, 255, 0.10) 100%);
}
.panel-head { margin-bottom: 28px; }
.panel-head { margin-bottom: 26px; }
.panel-title {
font-size: 30px; font-weight: 700;
letter-spacing: 12px;
background: linear-gradient(135deg, #ffffff 0%, var(--mg-accent) 100%);
background: linear-gradient(135deg, #ffffff 0%, var(--lg-accent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 22px rgba(var(--mg-primary-hover-rgb), 0.6);
text-shadow: 0 0 22px rgba(var(--lg-primary-hover-rgb), 0.5);
}
.panel-sub {
margin-top: 6px;
font-size: 12.5px;
color: rgba(232, 215, 245, 0.65);
color: var(--lg-text-dim);
letter-spacing: 1px;
}
@@ -485,20 +576,20 @@ async function submit() {
/* 输入框 透明玻璃 */
.glass-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset !important;
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset !important;
border-radius: 10px !important;
transition: all .25s;
}
.glass-form :deep(.el-input__wrapper:hover) {
background: rgba(255, 255, 255, 0.13) !important;
box-shadow: 0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
background: rgba(255, 255, 255, 0.11) !important;
box-shadow: 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
}
.glass-form :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.15) !important;
background: rgba(255, 255, 255, 0.13) !important;
box-shadow:
0 0 0 1px rgba(var(--mg-accent-rgb), 0.85) inset,
0 0 20px rgba(var(--mg-primary-hover-rgb), 0.55) !important;
0 0 0 1px rgba(var(--lg-accent-rgb), 0.85) inset,
0 0 20px rgba(var(--lg-primary-hover-rgb), 0.45) !important;
}
.glass-form :deep(.el-input__inner) {
color: #fff !important;
@@ -506,9 +597,9 @@ async function submit() {
font-size: 14px;
height: 42px;
}
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.45); }
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.42); }
.glass-form :deep(.el-input__prefix-inner > :first-child),
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.6); }
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.55); }
/* scope 双卡 */
.scope-item :deep(.el-form-item__content) { width: 100%; }
@@ -520,23 +611,23 @@ async function submit() {
display: flex; align-items: center; gap: 6px;
margin-bottom: 8px;
font-size: 12px;
color: rgba(232, 215, 245, 0.72);
color: var(--lg-text-soft);
letter-spacing: 1px;
}
.launch-mode-title { font-weight: 500; }
.launch-mode-help {
color: rgba(232, 215, 245, 0.55);
color: var(--lg-text-dim);
cursor: help;
font-size: 14px;
}
.launch-mode-help:hover { color: var(--mg-accent); }
.launch-wrap { /* 与 scope 一致,留位置给 hover 阴影 */ margin-bottom: 4px; }
.launch-mode-help:hover { color: var(--lg-accent); }
.launch-wrap { margin-bottom: 4px; }
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
.launch-tip > div + div { margin-top: 6px; }
.scope-tab {
appearance: none;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
padding: 14px 14px;
display: flex; align-items: center; gap: 12px;
@@ -546,19 +637,19 @@ async function submit() {
text-align: left;
}
.scope-tab:hover {
background: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.11);
color: #fff;
transform: translateY(-1px);
}
.scope-tab.active {
background: linear-gradient(135deg,
rgba(var(--mg-primary-rgb), 0.62) 0%,
rgba(var(--mg-primary-hover-rgb), 0.42) 100%);
border-color: rgba(var(--mg-accent-rgb), 0.85);
rgba(var(--lg-primary-rgb), 0.55) 0%,
rgba(var(--lg-accent-rgb), 0.28) 100%);
border-color: rgba(var(--lg-accent-rgb), 0.75);
color: #fff;
box-shadow:
0 0 24px rgba(var(--mg-primary-hover-rgb), 0.55),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset;
0 0 22px rgba(var(--lg-primary-hover-rgb), 0.45),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.45) inset;
}
.scope-tab .el-icon { font-size: 20px; }
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
@@ -569,19 +660,19 @@ async function submit() {
.row { display: flex; align-items: center; }
.row-between { justify-content: space-between; margin: -6px 0 10px; }
.remember :deep(.el-checkbox__label) {
color: rgba(255, 255, 255, 0.92) !important;
color: rgba(255, 255, 255, 0.9) !important;
font-size: 12.5px;
text-shadow: 0 1px 2px rgba(22, 4, 31, 0.4);
text-shadow: 0 1px 2px rgba(6, 9, 18, 0.4);
}
.remember :deep(.el-checkbox__inner) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
border-color: rgba(255, 255, 255, 0.32);
}
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
background: var(--mg-primary);
border-color: var(--mg-primary);
background: var(--lg-primary);
border-color: var(--lg-primary);
}
.adv-link { color: rgba(232, 215, 245, 0.82) !important; font-size: 12.5px; }
.adv-link { color: var(--lg-text-soft) !important; font-size: 12.5px; }
.adv-link:hover { color: #fff !important; }
/* 高级折叠 */
@@ -589,19 +680,19 @@ async function submit() {
.adv-collapse :deep(.el-collapse-item__wrap),
.adv-collapse :deep(.el-collapse-item__header) {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(232, 215, 245, 0.82) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--lg-text-soft) !important;
}
.adv-title { font-size: 12px; letter-spacing: 1px; }
.adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; padding-top: 6px; }
.adv-grid :deep(.el-form-item__label) {
color: rgba(232, 215, 245, 0.7);
color: var(--lg-text-soft);
font-size: 12px;
}
.adv-grid :deep(.el-input-number) { width: 100%; }
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
/* 登录按钮:紫色霓虹 · 扫光动效 */
/* 登录按钮:靛蓝→青 霓虹 · 扫光动效 */
.btn-login {
position: relative;
overflow: hidden;
@@ -611,12 +702,13 @@ async function submit() {
font-size: 15px;
letter-spacing: 6px;
font-weight: 600;
background: linear-gradient(135deg, var(--mg-primary) 0%, var(--mg-primary-hover) 100%) !important;
color: #fff !important;
background: linear-gradient(135deg, var(--lg-primary) 0%, var(--lg-primary-hover) 100%) !important;
border: none !important;
box-shadow:
0 12px 28px rgba(var(--mg-primary-rgb), 0.55),
0 0 28px rgba(var(--mg-primary-hover-rgb), 0.40),
0 0 0 1px rgba(255, 255, 255, 0.22) inset !important;
0 12px 28px rgba(var(--lg-primary-rgb), 0.5),
0 0 26px rgba(var(--lg-primary-hover-rgb), 0.36),
0 0 0 1px rgba(255, 255, 255, 0.20) inset !important;
transition: all .28s cubic-bezier(.25, .8, .25, 1);
}
.btn-login::after {
@@ -627,7 +719,7 @@ async function submit() {
background: linear-gradient(110deg,
transparent 0%,
rgba(255, 255, 255, 0.0) 30%,
rgba(255, 255, 255, 0.40) 50%,
rgba(255, 255, 255, 0.38) 50%,
rgba(255, 255, 255, 0.0) 70%,
transparent 100%);
transition: left .6s cubic-bezier(.25, .8, .25, 1);
@@ -635,18 +727,18 @@ async function submit() {
}
.btn-login:hover {
transform: translateY(-2px);
background: linear-gradient(135deg, var(--mg-primary-hover) 0%, var(--mg-primary-light-3) 100%) !important;
background: linear-gradient(135deg, var(--lg-primary-hover) 0%, var(--lg-accent) 130%) !important;
box-shadow:
0 18px 38px rgba(var(--mg-primary-rgb), 0.65),
0 0 42px rgba(var(--mg-primary-hover-rgb), 0.65),
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
0 18px 38px rgba(var(--lg-primary-rgb), 0.6),
0 0 42px rgba(var(--lg-accent-rgb), 0.5),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset !important;
}
.btn-login:hover::after { left: 120%; }
.bottom-note {
margin-top: 16px;
font-size: 11.5px;
color: rgba(232, 215, 245, 0.55);
color: var(--lg-text-dim);
text-align: center;
line-height: 1.6;
}
@@ -657,8 +749,8 @@ async function submit() {
bottom: 18px; left: 50%;
transform: translateX(-50%);
font-size: 11px;
letter-spacing: 1.5px;
color: rgba(232, 215, 245, 0.4);
letter-spacing: 2px;
color: rgba(45, 32, 78, 0.6);
z-index: 2;
}
@@ -669,9 +761,9 @@ async function submit() {
}
/* 小屏自适应 */
@media (max-width: 720px) {
@media (max-width: 760px) {
.login-card { grid-template-columns: 1fr; min-height: auto; }
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.12); padding: 28px; }
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding: 28px; }
.panel { padding: 28px; }
.hero-points, .hero-divider { display: none; }
}
@@ -0,0 +1,364 @@
<template>
<div class="wizard-page">
<div class="wz-orb wz-orb-a" aria-hidden="true" />
<div class="wz-orb wz-orb-b" aria-hidden="true" />
<div class="wz-card">
<header class="wz-head">
<div class="wz-brand">
<div class="wz-mark">
<img src="/FRLD-logo-white-no_title.png" alt="迷毂" class="wz-mark-img" />
</div>
<div class="wz-titles">
<div class="wz-title">平台配置向导</div>
<div class="wz-sub">按需选择导航方式与功能模块系统据此裁剪界面并按需加载内核能力</div>
</div>
</div>
<div class="wz-user">{{ auth.user?.displayName ?? auth.user?.username ?? '' }}</div>
</header>
<div v-if="loading" class="wz-loading">正在加载配置选项</div>
<div v-else class="wz-grid">
<div class="wz-main">
<section class="wz-section">
<div class="wz-section-head">
<el-icon><Compass /></el-icon><h3>导航方式</h3><span class="req">至少选 1 </span>
</div>
<div class="chip-grid">
<button
v-for="o in options?.navigationKinds ?? []" :key="o.id" type="button"
class="chip" :class="{ on: sel.navigationKinds.includes(o.id) }"
@click="toggle(sel.navigationKinds, o.id)">
<div class="chip-name">{{ o.name }}</div>
<div class="chip-desc">{{ o.description }}</div>
</button>
</div>
</section>
<section class="wz-section">
<div class="wz-section-head"><el-icon><Box /></el-icon><h3>功能模块</h3></div>
<div class="chip-grid">
<button
v-for="o in options?.modules ?? []" :key="o.id" type="button"
class="chip" :class="{ on: sel.modules.includes(o.id) }"
@click="toggle(sel.modules, o.id)">
<div class="chip-name">{{ o.name }}</div>
<div class="chip-desc">{{ o.description }}</div>
</button>
</div>
</section>
<section v-if="scenarioTemplates.length" class="wz-section">
<div class="wz-section-head"><el-icon><Histogram /></el-icon><h3>业务场景</h3></div>
<div class="chip-grid">
<button
v-for="t in scenarioTemplates" :key="t.id" type="button"
class="chip" :class="{ on: sel.scenarios.includes(t.id) }"
@click="toggle(sel.scenarios, t.id)">
<div class="chip-name">{{ t.name }}</div>
<div class="chip-desc">{{ t.category }}</div>
</button>
</div>
</section>
</div>
<aside class="wz-summary">
<div class="sum-title">已选概览</div>
<div class="sum-row"><span>导航方式</span><b>{{ sel.navigationKinds.length }}</b></div>
<div class="sum-row"><span>功能模块</span><b>{{ sel.modules.length }}</b></div>
<div class="sum-row"><span>业务场景</span><b>{{ sel.scenarios.length }}</b></div>
<div class="sum-divider" />
<div class="sum-label">将激活的内核场景插件</div>
<div class="sum-scenes">
<el-tag v-for="s in activeScenes" :key="s" size="small" effect="dark" class="sum-tag">{{ s }}</el-tag>
<span v-if="!activeScenes.length" class="sum-empty">请先选择导航方式</span>
</div>
</aside>
</div>
<footer class="wz-foot">
<el-button text class="logout-btn" @click="onLogout">退出登录</el-button>
<div class="foot-right">
<span class="foot-hint">保存后写入部署画像并联动 SimpleLite 选择性加载导航场景</span>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<el-icon v-if="!saving" class="btn-ic"><Check /></el-icon>完成并进入平台
</el-button>
</div>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Compass, Box, Histogram, Check } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { getWizardOptions, getWizardProfile, saveWizardProfile } from '@/api/wizard'
import type { WizardOptions, ScenarioTemplateLite } from '@/types/wizard'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const options = ref<WizardOptions | null>(null)
const sel = reactive({
platformType: 'standard',
navigationKinds: [] as string[],
modules: [] as string[],
scenarios: [] as string[]
})
const scenarioTemplates = computed<ScenarioTemplateLite[]>(() => options.value?.scenarios?.templates ?? [])
// 导航方式 → 内核场景 id 预览(与后端 DeploymentProfile.NavKindToSceneId 对齐)。
const NAV_SCENE: Record<string, string> = {
magnetic: 'scene.magnetic',
qrcode: 'scene.qrcode',
laser: 'scene.laser'
}
const activeScenes = computed(() => sel.navigationKinds.map((k) => NAV_SCENE[k] ?? `scene.${k}`))
const canSave = computed(() => sel.navigationKinds.length > 0)
function toggle(list: string[], id: string) {
const i = list.indexOf(id)
if (i >= 0) list.splice(i, 1)
else list.push(id)
}
onMounted(async () => {
try {
const [opt, profile] = await Promise.all([getWizardOptions(), getWizardProfile()])
options.value = opt
sel.platformType = profile.platformType || 'standard'
sel.navigationKinds = [...(profile.navigationKinds ?? [])]
// WMS 为暂定保留的核心仓储模块,首次进入默认勾选,避免用户误漏。
sel.modules = profile.modules?.length ? [...profile.modules] : ['wms']
sel.scenarios = [...(profile.scenarios ?? [])]
} catch (e) {
ElMessage.error(`加载向导失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
})
async function save() {
if (!canSave.value) {
ElMessage.warning('请至少选择一种导航方式')
return
}
saving.value = true
try {
await saveWizardProfile({
platformType: sel.platformType,
modules: sel.modules,
navigationKinds: sel.navigationKinds,
scenarios: sel.scenarios
})
auth.markWizardDone()
ElMessage.success('部署配置已保存')
router.push(auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
function onLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.wizard-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 24px;
background:
radial-gradient(ellipse 75% 55% at 16% 14%, #dcbef7 0%, transparent 55%),
radial-gradient(ellipse 80% 65% at 86% 90%, #abc9f5 0%, transparent 58%),
linear-gradient(135deg, #cbb2f0 0%, #b7adec 46%, #a7c6f2 100%);
}
.wz-orb {
position: absolute; border-radius: 50%; filter: blur(110px); pointer-events: none; opacity: 0.28;
}
.wz-orb-a {
width: 480px; height: 480px; top: -180px; left: -140px;
background: radial-gradient(circle, var(--lg-accent) 0%, var(--lg-primary) 55%, transparent 85%);
}
.wz-orb-b {
width: 560px; height: 560px; bottom: -220px; right: -180px;
background: radial-gradient(circle, var(--lg-primary-hover) 0%, var(--lg-primary-deep) 55%, transparent 88%);
}
.wz-card {
position: relative;
z-index: 2;
width: 1040px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
border-radius: 18px;
overflow: hidden;
/* 固定深紫玻璃底(与登录页一致,不随主题切换) */
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(135deg, rgba(var(--lg-card-rgb), 0.92) 0%, rgba(20, 12, 38, 0.95) 100%);
backdrop-filter: blur(26px) saturate(150%);
-webkit-backdrop-filter: blur(26px) saturate(150%);
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.6),
0 0 80px rgba(var(--lg-primary-rgb), 0.4),
0 0 0 1px rgba(var(--lg-accent-rgb), 0.28) inset;
}
.wz-head {
display: flex; align-items: center; justify-content: space-between;
padding: 22px 28px;
border-bottom: 1px solid rgba(var(--lg-accent-rgb), 0.18);
background: linear-gradient(180deg, rgba(var(--lg-primary-rgb), 0.18) 0%, transparent 100%);
}
.wz-brand { display: flex; align-items: center; gap: 14px; }
.wz-mark {
height: 46px; min-width: 46px;
padding: 0 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.55) 0%, rgba(var(--lg-accent-rgb), 0.30) 100%);
border: 1px solid rgba(255, 255, 255, 0.20);
display: inline-flex; align-items: center; justify-content: center;
box-shadow:
0 10px 26px rgba(var(--lg-primary-rgb), 0.5),
0 0 24px rgba(var(--lg-accent-rgb), 0.28),
0 0 0 1px rgba(255, 255, 255, 0.16) inset;
}
.wz-mark-img {
display: block;
height: 28px;
width: auto;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.35));
}
.wz-titles { display: flex; flex-direction: column; gap: 4px; }
.wz-title {
font-size: 20px; font-weight: 700; letter-spacing: 2px; color: #fff;
}
.wz-sub { font-size: 12.5px; color: rgba(232, 215, 245, 0.65); }
.wz-user {
font-size: 13px; color: rgba(255, 255, 255, 0.8);
padding: 5px 12px; border-radius: 999px;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14);
}
.wz-loading {
padding: 80px; text-align: center; color: rgba(255, 255, 255, 0.7); font-size: 14px;
}
.wz-grid {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1fr 280px;
gap: 0;
}
.wz-main {
overflow-y: auto;
padding: 22px 26px;
display: flex; flex-direction: column; gap: 22px;
}
.wz-section-head {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
color: #fff;
}
.wz-section-head .el-icon { font-size: 18px; color: var(--lg-accent); }
.wz-section-head h3 { margin: 0; font-size: 15px; font-weight: 600; }
.wz-section-head .req {
font-size: 11px; color: var(--lg-accent);
padding: 1px 8px; border-radius: 8px;
border: 1px solid rgba(var(--lg-accent-rgb), 0.4);
}
.chip-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
}
.chip {
appearance: none; cursor: pointer; text-align: left;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
padding: 12px 14px;
color: rgba(255, 255, 255, 0.78);
transition: all .18s ease;
}
.chip:hover {
background: rgba(255, 255, 255, 0.1); color: #fff; transform: translateY(-1px);
}
.chip.on {
background: linear-gradient(135deg, rgba(var(--lg-primary-rgb), 0.6) 0%, rgba(var(--lg-primary-hover-rgb), 0.4) 100%);
border-color: rgba(var(--lg-accent-rgb), 0.85);
color: #fff;
box-shadow: 0 0 20px rgba(var(--lg-primary-hover-rgb), 0.5), 0 0 0 1px rgba(var(--lg-accent-rgb), 0.5) inset;
}
.chip-name { font-size: 14px; font-weight: 600; }
.chip-desc { font-size: 11.5px; opacity: 0.75; margin-top: 4px; line-height: 1.5; }
.wz-summary {
border-left: 1px solid rgba(var(--lg-accent-rgb), 0.16);
background: rgba(0, 0, 0, 0.18);
padding: 22px 20px;
overflow-y: auto;
}
.sum-title { font-size: 13px; font-weight: 600; color: #fff; margin-bottom: 14px; letter-spacing: 1px; }
.sum-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: rgba(255, 255, 255, 0.75); padding: 7px 0;
}
.sum-row b { color: var(--lg-accent); font-size: 15px; font-variant-numeric: tabular-nums; }
.sum-divider { height: 1px; background: rgba(255, 255, 255, 0.12); margin: 14px 0; }
.sum-label { font-size: 12px; color: rgba(232, 215, 245, 0.65); margin-bottom: 10px; }
.sum-scenes { display: flex; flex-wrap: wrap; gap: 6px; }
.sum-tag {
font-family: var(--mg-font-mono);
/* 锁定固定紫,不随 Element Plus 主题色变化 */
background: rgba(var(--lg-primary-rgb), 0.30) !important;
border-color: rgba(var(--lg-accent-rgb), 0.5) !important;
color: #fff !important;
}
.sum-empty { font-size: 12px; color: rgba(255, 255, 255, 0.4); }
.wz-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 26px;
border-top: 1px solid rgba(var(--lg-accent-rgb), 0.18);
background: rgba(var(--lg-aside-rgb), 0.4);
}
.logout-btn { color: rgba(255, 255, 255, 0.6) !important; }
.foot-right { display: flex; align-items: center; gap: 16px; }
.foot-hint { font-size: 12px; color: rgba(232, 215, 245, 0.55); }
.btn-ic { margin-right: 4px; }
/* 「完成并进入平台」主按钮锁定固定紫色(与登录按钮同款,不随主题切换) */
.wz-foot :deep(.el-button--primary) {
--el-button-bg-color: var(--lg-primary);
--el-button-border-color: var(--lg-primary);
--el-button-hover-bg-color: var(--lg-primary-hover);
--el-button-hover-border-color: var(--lg-primary-hover);
--el-button-active-bg-color: var(--lg-primary-deep);
--el-button-active-border-color: var(--lg-primary-deep);
color: #fff;
box-shadow: 0 8px 20px rgba(var(--lg-primary-rgb), 0.45);
}
@media (max-width: 880px) {
.wz-grid { grid-template-columns: 1fr; }
.wz-summary { border-left: none; border-top: 1px solid rgba(var(--lg-accent-rgb), 0.16); }
}
</style>
@@ -55,6 +55,28 @@
@delete="deleteSelection"
@patch-viewport="onPatchViewport"
/>
<!-- 右侧AI 助手悬浮入口手柄 + 固定停靠聊天面板实底可发送文字需求 -->
<button
class="ai-assistant-fab"
:class="{ 'is-open': aiAssistantOpen }"
:style="aiAssistantOpen ? { right: aiPanelWidth + 'px' } : undefined"
type="button"
:title="aiAssistantOpen ? '收起 AI 助手' : '打开 AI 助手'"
@click="toggleAiAssistant"
>
<span class="fab-glyph"></span>
<span class="fab-text">AI<br>助手</span>
</button>
<AiAssistantPanel
v-model:open="aiAssistantOpen"
v-model:width="aiPanelWidth"
:configured="aiConfigured"
:default-bounds="defaultAiBounds"
:default-layer="defaults.site.layer"
@generated="onAiGenerated"
/>
</div>
<EditStatusBar
@@ -76,13 +98,6 @@
@generated="onAiGenerated"
/>
<ProjectBrowseDialog
v-model="projectBrowseOpen"
:mode="projectBrowseMode"
:initial-path="projectBrowseInitial"
@confirm="onProjectBrowseConfirm"
/>
<!--
图层显示切换对话框列出所有已知图层每行一个 visible/selectable 开关 +
设为当前默认层按钮本面板与右侧 EditPropertyPanel 的图层卡片共享同一份
@@ -138,6 +153,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import Workspace3D from '@/components/Workspace3D.vue'
import EditTopBar, { type ViewFilterState } from '@/components/map-editor/EditTopBar.vue'
@@ -145,7 +161,7 @@ import EditToolRail from '@/components/map-editor/EditToolRail.vue'
import EditPropertyPanel from '@/components/map-editor/EditPropertyPanel.vue'
import EditStatusBar from '@/components/map-editor/EditStatusBar.vue'
import AiGenerateDialog from '@/components/map-editor/AiGenerateDialog.vue'
import ProjectBrowseDialog from '@/components/map-editor/ProjectBrowseDialog.vue'
import AiAssistantPanel from '@/components/map-editor/AiAssistantPanel.vue'
import { useAuthStore } from '@/stores/auth'
import { useEditTool, type EditToolId } from '@/composables/useEditTool'
import { useSelection, type SelectionItem } from '@/composables/useSelection'
@@ -154,7 +170,7 @@ import { useCanvasBridge } from '@/composables/useCanvasBridge'
import { useMapEditStream } from '@/composables/useMapEditStream'
import { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
import { mapEditApi, aiConfigApi } from '@/api/mapEdit'
import { mapEditApi, aiConfigApi, mapsApi } from '@/api/mapEdit'
import {
reflectionApi,
normalizeViewportPayload,
@@ -167,8 +183,14 @@ import {
} from '@/api/reflection'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
const editingMapName = ref<string>('')
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const fileAcceptHint = ref<string>('image/*')
@@ -181,13 +203,15 @@ const history = useHistory({})
const mouseXY = reactive({ x: 0, y: 0 })
const aiDialogOpen = ref(false)
// 右侧固定停靠的「AI 助手」聊天面板开关(与 AI 生图对话框相互独立)。
// 开关状态与面板宽度都持久化到 localStorage,下次进入编辑器自动恢复。
const aiAssistantOpen = ref(false)
const aiPanelWidth = ref(360)
const AI_PANEL_OPEN_KEY = 'mapEditor.aiAssistant.open'
const AI_PANEL_WIDTH_KEY = 'mapEditor.aiAssistant.width'
const aiConfigured = ref(false)
const defaultAiBounds = ref<[number, number, number, number]>([-10000, -10000, 10000, 10000])
const projectBrowseOpen = ref(false)
const projectBrowseMode = ref<'load' | 'save'>('load')
const projectBrowseInitial = ref<string>('')
const saving = ref(false)
const propertyLoading = ref(false)
@@ -377,10 +401,7 @@ async function onTopBarCmd(cmd: string) {
case 'edit.delete': await deleteSelection(); break
case 'edit.selectAll': ElMessage.info('全选:请使用工具栏「按类型选」批量选中'); break
case 'edit.invert': ElMessage.info('反选功能后续实现'); break
case 'project.save': await onProjectSave(false); break
case 'project.open': await onProjectOpen(); break
case 'project.saveAs': await onProjectSave(true); break
case 'project.close': ElMessage.info('关闭项目:使用 SimpleLite 主菜单'); break
case 'project.save': await saveMapAndLeave(); break
case 'ai.open': await openAiDialog(); break
case 'file.upload.image': triggerUpload('image'); break
case 'file.upload.model': triggerUpload('model'); break
@@ -1323,6 +1344,35 @@ async function openAiDialog() {
aiDialogOpen.value = true
}
// 恢复「AI 助手」面板的开关状态与宽度(来自上次会话的 localStorage)。
function restoreAiPanelPrefs() {
try {
aiAssistantOpen.value = localStorage.getItem(AI_PANEL_OPEN_KEY) === '1'
const w = Number(localStorage.getItem(AI_PANEL_WIDTH_KEY))
if (Number.isFinite(w) && w >= 300 && w <= 720) aiPanelWidth.value = w
} catch { /* localStorage 不可用则用默认值 */ }
}
watch(aiAssistantOpen, (v) => {
try { localStorage.setItem(AI_PANEL_OPEN_KEY, v ? '1' : '0') } catch { /* ignore */ }
})
watch(aiPanelWidth, (v) => {
try { localStorage.setItem(AI_PANEL_WIDTH_KEY, String(Math.round(v))) } catch { /* ignore */ }
})
// 右侧「AI 助手」入口:展开前刷新一次 AI 配置状态,保证「未配置」提示与后端一致。
async function toggleAiAssistant() {
if (!aiAssistantOpen.value) {
try {
const cfg = await aiConfigApi.get()
aiConfigured.value = !!cfg.configured
} catch {
aiConfigured.value = false
}
}
aiAssistantOpen.value = !aiAssistantOpen.value
}
async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
refreshAfterMutation()
@@ -1352,60 +1402,49 @@ async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
// 项目保存:调反射 API 触发 SimpleProject.Save (如有 MethodMember 暴露)
// ──────────────────────────────────────────────────────────────────────────
async function onProjectSave(saveAs: boolean) {
const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''
// 非另存为且有缓存:直接覆盖保存到上次路径,无需弹对话框。
if (!saveAs && cached) {
saving.value = true
try {
const r = await mapEditApi.projectSave(cached)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已保存:${r.path}`)
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
return
}
// 另存为或首次保存:弹服务端目录浏览对话框(项目文件保存在 SimpleLite 服务端)。
projectBrowseMode.value = 'save'
projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
projectBrowseOpen.value = true
/** 生成「当前时间」默认地图名(文件名安全,不含冒号等非法字符)。 */
function defaultTimeMapName(): string {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
async function onProjectOpen() {
const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''
projectBrowseMode.value = 'load'
projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
projectBrowseOpen.value = true
}
/**
* 保存当前场景到地图管理统一目录,成功后返回地图管理页。
* - 编辑已有地图:直接覆盖保存(同名冲突时确认替换);
* - 新建地图:用当前时间自动命名。
*/
async function saveMapAndLeave() {
const name = editingMapName.value || defaultTimeMapName()
async function onProjectBrowseConfirm(path: string) {
if (!path) return
if (projectBrowseMode.value === 'save') {
saving.value = true
try {
const r = await mapEditApi.projectSave(path)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已保存:${r.path}`)
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
return
}
saving.value = true
try {
const r = await mapEditApi.projectLoad(path)
localStorage.setItem('mapEditor.lastProjectPath', r.path)
ElMessage.success(`已加载:${r.sites} 站点 / ${r.tracks} 路径 / ${r.specials} 装饰 / ${r.missions} 任务`)
selection.clear()
primary.value = null
let outcome = await mapsApi.save(name, false)
if (!outcome.ok && outcome.conflict) {
try {
await ElMessageBox.confirm(
`地图「${name}」已存在,是否替换原地图文件?`,
'替换确认',
{ type: 'warning', confirmButtonText: '替换', cancelButtonText: '取消' }
)
} catch {
return
}
outcome = await mapsApi.save(name, true)
}
if (!outcome.ok) {
ElMessage.error(`保存失败:${outcome.message}`)
return
}
editingMapName.value = outcome.data.name
ElMessage.success(`已保存地图「${outcome.data.name}`)
await router.push({ path: '/admin/maps' })
} catch (err) {
ElMessage.error(`加载失败:${(err as Error).message}`)
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
}
@@ -1583,11 +1622,36 @@ onMounted(async () => {
if (cached) {
try { Object.assign(defaults, JSON.parse(cached)) } catch { /* ignore */ }
}
restoreAiPanelPrefs()
loadViewFilter()
await syncViewFilterFromBackend()
await loadViewportStyle()
await loadFromRouteQuery()
})
/**
* 从地图管理页跳转进来时的初始加载:
* - ?map=<name>:加载该地图到场景供编辑,并记录原名(保存默认沿用 + 替换确认);
* - ?new=1:新建地图,仅清空「正在编辑的地图名」,保存时按时间默认命名。
*/
async function loadFromRouteQuery() {
const mapName = typeof route.query.map === 'string' ? route.query.map : ''
const isNew = route.query.new === '1'
if (mapName) {
try {
const r = await mapsApi.open(mapName)
editingMapName.value = r.name
selection.clear()
primary.value = null
ElMessage.success(`已载入地图「${r.name}」:${r.sites} 站点 / ${r.tracks} 路径`)
} catch (err) {
ElMessage.error(`载入地图失败:${(err as Error).message}`)
}
} else if (isNew) {
editingMapName.value = ''
}
}
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
@@ -1605,7 +1669,51 @@ onUnmounted(() => {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* AI 助手悬浮入口:贴右侧边缘的竖向手柄;面板展开时随面板左移变成其左沿把手。 */
.ai-assistant-fab {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
z-index: 31;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
width: 42px;
padding: 14px 0;
border: none;
border-radius: 12px 0 0 12px;
cursor: pointer;
color: #fff;
background: linear-gradient(135deg, rgba(150, 90, 240, 0.96) 0%, rgba(255, 90, 200, 0.92) 100%);
box-shadow: -4px 0 16px rgba(120, 40, 200, 0.45);
transition: right 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), filter 0.15s ease, box-shadow 0.15s ease;
}
.ai-assistant-fab:hover {
filter: brightness(1.08);
box-shadow: -6px 0 22px rgba(150, 60, 230, 0.6);
}
/* 展开时 right 偏移由内联样式按面板实际宽度设置(默认 360);此处仅作兜底。 */
.ai-assistant-fab.is-open {
right: 360px;
}
.ai-assistant-fab .fab-glyph {
font-size: 18px;
line-height: 1;
text-shadow: 0 0 10px rgba(255, 220, 255, 0.85);
}
.ai-assistant-fab .fab-text {
font-size: 12px;
font-weight: 700;
line-height: 1.18;
letter-spacing: 1px;
text-align: center;
}
.me-center {
flex: 1;
min-width: 0;
@@ -0,0 +1,279 @@
<template>
<div class="map-mgr-page">
<el-tabs v-model="activeTab" type="border-card" class="map-mgr-tabs admin-tabs">
<el-tab-pane label="服务器地图" name="maps">
<div class="mm-section">
<div class="map-mgr-header">
<span class="subtitle">
统一维护后台所有地图资源固定文件夹 / JSON可新增 / 使用 / 重命名 / 编辑 / 删除
</span>
<div class="actions">
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button type="primary" @click="onCreate">新增地图</el-button>
</div>
</div>
<el-alert
v-if="directory"
class="dir-tip"
type="info"
:closable="false"
show-icon
>
<template #title>
地图文件夹<code>{{ directory }}</code>
<span v-if="currentName"> · 当前使用<strong>{{ currentName }}</strong></span>
<span v-else> · 当前未设置默认使用地图</span>
</template>
</el-alert>
<el-table
v-loading="loading"
:data="maps"
class="map-table"
border
:row-class-name="rowClassName"
empty-text="固定文件夹内还没有地图点击右上角新增地图创建"
>
<el-table-column label="地图名称" min-width="220">
<template #default="{ row }">
<span class="map-name">{{ row.name }}</span>
<el-tag v-if="row.isCurrent" size="small" type="success" effect="dark" class="current-tag">
使用中
</el-tag>
</template>
</el-table-column>
<el-table-column label="修改时间" prop="modified" width="190" />
<el-table-column label="操作" width="360" align="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
:disabled="row.isCurrent"
:loading="busyName === row.name"
@click="onUse(row)"
>
{{ row.isCurrent ? '使用中' : '使用' }}
</el-button>
<el-button size="small" @click="onRename(row)">重命名</el-button>
<el-button size="small" @click="onEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="多地图连接管理" name="connections">
<MapConnectionPanel />
</el-tab-pane>
<el-tab-pane label="地图合并" name="merge">
<MapMergePanel @merged="onMerged" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mapsApi, type MapListItem } from '@/api/mapEdit'
import MapConnectionPanel from '@/components/map-manage/MapConnectionPanel.vue'
import MapMergePanel from '@/components/map-manage/MapMergePanel.vue'
const router = useRouter()
const activeTab = ref<'maps' | 'connections' | 'merge'>('maps')
function onMerged() {
activeTab.value = 'maps'
refresh()
}
const loading = ref(false)
const maps = ref<MapListItem[]>([])
const directory = ref('')
const currentName = ref('')
const busyName = ref('')
function rowClassName({ row }: { row: MapListItem }) {
return row.isCurrent ? 'current-row' : ''
}
async function refresh() {
loading.value = true
try {
const r = await mapsApi.list()
maps.value = r.maps
directory.value = r.directory
const cur = r.maps.find((m) => m.isCurrent)
currentName.value = cur?.name ?? ''
} catch (err) {
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
function onCreate() {
// 新建地图:跳转地图编辑界面(空场景),保存时按时间默认命名。
router.push({ path: '/admin/map-editor', query: { new: '1' } })
}
function onEdit(row: MapListItem) {
// 编辑地图:跳转编辑界面并加载该地图,保存时默认替换原地图。
router.push({ path: '/admin/map-editor', query: { map: row.name } })
}
async function onUse(row: MapListItem) {
if (row.isCurrent) return
busyName.value = row.name
try {
// 先做一次场景任务校验,给出更友好的明细提示;后端 use 仍会再次校验(防并发竞态)。
const status = await mapsApi.sceneTaskStatus()
if (status.hasTask) {
const detail = status.busyCars
.slice(0, 5)
.map((c) => `${c.name}(#${c.id})${c.reasons.join('、')}`)
.join('\n')
await ElMessageBox.alert(
`场景内仍有 ${status.busyCount} 台车辆存在任务,无法切换当前地图。\n\n${detail}`,
'禁止切换地图',
{ type: 'warning', confirmButtonText: '我知道了' }
)
return
}
await ElMessageBox.confirm(
`确认将「${row.name}」设为当前项目默认使用的地图?切换会重新加载场景。`,
'切换使用地图',
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
)
const r = await mapsApi.use(row.name)
ElMessage.success(`已切换到「${r.name}」:${r.sites} 站点 / ${r.tracks} 路径`)
await refresh()
} catch (err) {
// 取消确认框不提示;其余(含后端 409 任务校验)提示错误文案。
if (err === 'cancel' || err === 'close') return
ElMessage.error(`切换失败:${(err as Error).message}`)
} finally {
busyName.value = ''
}
}
const MAP_NAME_PATTERN = /^[^\\/:*?"<>|]+$/
async function onRename(row: MapListItem) {
try {
const r = await ElMessageBox.prompt('请输入新的地图名称', '重命名地图', {
inputValue: row.name,
inputPattern: MAP_NAME_PATTERN,
inputErrorMessage: '名称不能包含 \\ / : * ? " < > | 等字符',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
const to = (r.value ?? '').trim()
if (!to || to === row.name) return
busyName.value = row.name
await mapsApi.rename(row.name, to)
ElMessage.success(`已重命名为「${to}`)
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`重命名失败:${(err as Error).message}`)
} finally {
busyName.value = ''
}
}
async function onDelete(row: MapListItem) {
try {
await ElMessageBox.confirm(
`确认删除地图「${row.name}」?该操作会从固定文件夹中移除对应 JSON 文件,不可恢复。`,
'删除地图',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
await mapsApi.delete(row.name)
ElMessage.success(`已删除「${row.name}`)
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`删除失败:${(err as Error).message}`)
}
}
onMounted(refresh)
</script>
<style scoped>
.map-mgr-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* Tab 容器撑满主区;玻璃表面 / 选项卡样式由全局 .admin-tabs 提供(与场景管理一致),
* 在星云紫等深色主题下给到不透明深紫底 + 白字,挡住 AppShell 的高亮光球,保证可读。 */
.map-mgr-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 单个 Tab 内容区:纵向铺满,表格自适应高度 */
.mm-section {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.map-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.subtitle {
font-size: 12.5px;
line-height: 1.5;
color: var(--mg-text-muted);
}
.dir-tip {
flex-shrink: 0;
}
.dir-tip code {
font-family: var(--mg-font-mono, monospace);
padding: 1px 6px;
border-radius: 4px;
background: var(--mg-veil-2);
color: var(--mg-text-light);
}
.map-table {
flex: 1;
min-height: 0;
}
.map-name {
font-weight: 500;
color: var(--mg-text-light);
}
.current-tag {
margin-left: 8px;
}
/* 「使用中」行高亮:用主题感知的成功色微透叠加,替换原固定浅绿 --el-color-success-light-9
(在星云紫深底主题下「浅绿底 + 白字」几乎不可读)。深/浅两套主题下均保持清晰对比。 */
.map-table :deep(.current-row) > td.el-table__cell {
background: rgba(var(--mg-status-success-rgb), 0.16) !important;
}
.map-table :deep(.current-row):hover > td.el-table__cell {
background: rgba(var(--mg-status-success-rgb), 0.24) !important;
}
</style>
@@ -92,6 +92,7 @@ import { listDeliveries } from '@/api/delivery'
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
import { reflectionApi } from '@/api/reflection'
import { workspaceToolbarApi } from '@/api/workspaceToolbar'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { DeliveryTask } from '@/types/delivery'
@@ -128,8 +129,7 @@ const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const alarms = ref<AlarmEvent[]>([])
async function onAlarmLocate(a: AlarmEvent) {
// 通过 reflectionApi.setSelection 把 SimpleLite 3D 场景的高亮切到该车,
// CycleGUI 默认会把相机聚焦到 GetPosition 命中目标——视觉上等同于 flyTo。
// 点击浮动报警的「定位」:同步 3D 高亮 + 立即把相机定位到该报警车辆(与车辆监控台选中一致)。
// a.carId 已经是 numberAlarmEvent 类型保证),无须额外字符串处理;如果将来后端
// 改成字符串,这里 Number(...) 会返回 NaN 并由下面的 isFinite 拦下来。
const numId = Number(a.carId)
@@ -137,12 +137,8 @@ async function onAlarmLocate(a: AlarmEvent) {
ElMessage.warning(`报警车辆 id 无法解析:${a.carId}`)
return
}
try {
await reflectionApi.setSelection('car', numId)
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
} catch (err) {
ElMessage.error(`定位失败:${(err as Error).message}`)
}
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
await selectAndLocateVehicle(numId)
}
function onAlarmAck(_a: AlarmEvent) {
// 当前后端无 ACK 接口,前端本地标记即可(FloatingAlarmStack 已处理)
@@ -342,18 +338,31 @@ async function fallbackSelectionFromBackend() {
}
}
/**
* 选中并定位到指定车辆:① 同步 SimpleLite 3D 场景高亮;② 立即把相机定位(居中 + 2D 俯视)
* 到该车,与原生「双击车辆行 = 选中+定位」一致。两个请求互不阻塞,各自独立提示错误
* (例如 iframe 未连接时相机定位会 503,但高亮仍可成功)。
*/
async function selectAndLocateVehicle(id: number) {
if (!Number.isFinite(id)) return
const [selRes, locRes] = await Promise.allSettled([
reflectionApi.setSelection('car', id),
workspaceToolbarApi.locateCamera(id)
])
if (selRes.status === 'rejected') {
ElMessage.error({ message: `同步 3D 选中失败:${(selRes.reason as Error).message}`, grouping: true })
}
if (locRes.status === 'rejected') {
ElMessage.error({ message: `定位车辆失败:${(locRes.reason as Error).message}`, grouping: true })
}
}
async function onVehicleSelect(ref: SelectedObjectRef) {
selectedDeliveryId.value = null
if (!sameSelection(selection.value, ref)) {
selection.value = ref
}
const id = Number(ref.id)
if (!Number.isFinite(id)) return
try {
await reflectionApi.setSelection('car', id)
} catch (err) {
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
}
await selectAndLocateVehicle(Number(ref.id))
}
async function onDeliverySelect(task: DeliveryTask) {
@@ -364,16 +373,12 @@ async function onDeliverySelect(task: DeliveryTask) {
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
return
}
try {
await reflectionApi.setSelection('car', task.carId)
selection.value = {
kind: 'vehicle',
id: String(task.carId),
name: task.carName ?? `Vehicle ${task.carId}`
}
} catch (err) {
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
selection.value = {
kind: 'vehicle',
id: String(task.carId),
name: task.carName ?? `Vehicle ${task.carId}`
}
await selectAndLocateVehicle(task.carId)
return
}
selection.value = {
@@ -2,12 +2,12 @@
<ConfigPageBase
section="integrations"
title="外部系统对接"
description="MES / WMS / RCS 等标准接口配置(ExternalIntegrations"
description="MES / WMS / RCS / PTL 等标准接口配置(ExternalIntegrations"
:defaults="DEFAULT_INTEGRATIONS">
<template #default="{ payload, update }">
<el-tabs model-value="mes">
<el-tab-pane v-for="kind in (['mes','wms','rcs'] as const)" :key="kind" :name="kind" :label="kind.toUpperCase()">
<el-table :data="payload[kind]" size="small" border>
<el-tab-pane v-for="kind in (['mes','wms','rcs','ptl'] as const)" :key="kind" :name="kind" :label="kind.toUpperCase()">
<el-table :data="payload[kind] ?? []" size="small" border>
<el-table-column label="ID" width="120">
<template #default="s">
<el-input v-model="s.row.id" />
@@ -41,13 +41,13 @@ import ConfigPageBase from '@/components/ConfigPageBase.vue'
import { DEFAULT_INTEGRATIONS } from '@/mock/data/configs'
import type { ExternalIntegrations } from '@/types/config'
type Kind = 'mes' | 'wms' | 'rcs'
type Kind = 'mes' | 'wms' | 'rcs' | 'ptl'
function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind) {
const id = `${kind}-${Date.now()}`
const next: ExternalIntegrations = {
...payload,
[kind]: [...payload[kind], { id, name: '新端点', url: 'http://', enabled: false }]
[kind]: [...(payload[kind] ?? []), { id, name: '新端点', url: 'http://', enabled: false }]
}
update(next)
}
@@ -55,7 +55,7 @@ function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) =>
function remove(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind, idx: number) {
const next: ExternalIntegrations = {
...payload,
[kind]: payload[kind].filter((_, i) => i !== idx)
[kind]: (payload[kind] ?? []).filter((_, i) => i !== idx)
}
update(next)
}
@@ -5,20 +5,14 @@
description="分区域/跨楼层/多车协作;OTA、批量操作、网络诊断(FleetLifecycleConfig"
:defaults="DEFAULT_FLEET">
<template #default="{ payload }">
<el-tabs model-value="groups">
<el-tab-pane name="groups" label="车队分组">
<el-table :data="payload.groups" size="small" border>
<el-table-column label="ID" prop="id" width="100" />
<el-table-column label="名称" prop="name" width="140" />
<el-table-column label="楼层" prop="floor" width="80" />
<el-table-column label="区域" prop="region" width="80" />
<el-table-column label="车辆">
<template #default="s">
<el-tag v-for="c in s.row.carIds" :key="c" size="small" style="margin: 2px">{{ c }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-alert
type="info"
:closable="false"
show-icon
title="车队分配已移至「车辆运维 → 运维总览」"
description="在运维总览中可新建车队、设定名称/区域/楼层,并将车辆分配到各车队。"
style="margin-bottom: 12px" />
<el-tabs model-value="ota">
<el-tab-pane name="ota" label="OTA 升级">
<el-descriptions :column="2" border>
<el-descriptions-item label="启用">{{ payload.ota.enabled ? '是' : '否' }}</el-descriptions-item>
@@ -39,8 +39,8 @@
<el-select v-model="filterState" size="small" clearable placeholder="状态" class="filter-select">
<el-option v-for="opt in stateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="filterGroup" size="small" clearable placeholder="群组" class="filter-select">
<el-option v-for="g in groupOptions" :key="g" :label="g" :value="g" />
<el-select v-model="filterFleet" size="small" clearable placeholder="车队" class="filter-select fleet-filter">
<el-option v-for="g in fleetFilterOptions" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
<el-dropdown :disabled="!canWrite || !selectedIds.length" @command="onBatchCommand">
<el-button size="small" :disabled="!canWrite || !selectedIds.length">
@@ -74,6 +74,8 @@
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
</div>
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计</p>
</div>
</el-tab-pane>
@@ -94,14 +96,16 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
import type { CarState } from '@/types/car'
import { useAuthStore } from '@/stores/auth'
@@ -140,9 +144,12 @@ const {
refreshAll
} = useVehicleHub()
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
onMounted(() => void reloadFleetGroups())
const search = ref('')
const filterState = ref<CarState | ''>('')
const filterGroup = ref('')
const filterFleet = ref('')
const stateOptions: { value: CarState; label: string }[] = [
{ value: 'idle', label: '空闲' },
@@ -153,22 +160,33 @@ const stateOptions: { value: CarState; label: string }[] = [
{ value: 'offline', label: '离线' }
]
const groupOptions = computed(() => {
const set = new Set<string>()
for (const c of cardModels.value) {
if (c.group) set.add(c.group)
}
return [...set]
})
const fleetFilterOptions = computed(() =>
fleetGroups.value.map((g) => ({
value: g.id,
label: g.region ? `${g.name}${g.region}` : g.name || g.id
}))
)
function onFleetSaved() {
void reloadFleetGroups(true)
}
function matchesFleetFilter(carId: string): boolean {
if (!filterFleet.value) return true
const fleet = fleetGroups.value.find((g) => g.id === filterFleet.value)
return fleet ? fleet.carIds.includes(carId) : false
}
const filteredCards = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
return cardModels.value.filter((c) => {
if (filterState.value && c.state !== filterState.value) return false
if (filterGroup.value && c.group !== filterGroup.value) return false
if (!matchesFleetFilter(c.id)) return false
if (!tokens.length) return true
const hay = [c.id, c.name, c.ip, c.group, c.lstatus].filter(Boolean).join(' ').toLowerCase()
const fleetName = fleetNameForCarId(c.id)
const region = regionForCarId(c.id)
const hay = [c.id, c.name, c.ip, c.group, fleetName, region, c.lstatus].filter(Boolean).join(' ').toLowerCase()
return tokens.every((t) => hay.includes(t))
})
})
@@ -295,6 +313,10 @@ async function onBatchCommand(cmd: string) {
width: 120px;
}
.fleet-filter {
width: 160px;
}
.card-grid {
flex: 1;
overflow: auto;