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:
@@ -7,6 +7,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AiAssistantPanel: typeof import('./src/components/map-editor/AiAssistantPanel.vue')['default']
|
||||
AiGenerateDialog: typeof import('./src/components/map-editor/AiGenerateDialog.vue')['default']
|
||||
ConfigPageBase: typeof import('./src/components/ConfigPageBase.vue')['default']
|
||||
DataTablePro: typeof import('./src/components/DataTablePro.vue')['default']
|
||||
@@ -51,6 +52,7 @@ declare module 'vue' {
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
@@ -71,8 +73,11 @@ declare module 'vue' {
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default']
|
||||
FleetAllocationPanel: typeof import('./src/components/fleet/FleetAllocationPanel.vue')['default']
|
||||
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
|
||||
FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default']
|
||||
MapConnectionPanel: typeof import('./src/components/map-manage/MapConnectionPanel.vue')['default']
|
||||
MapMergePanel: typeof import('./src/components/map-manage/MapMergePanel.vue')['default']
|
||||
MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default']
|
||||
MissionListPanel: typeof import('./src/components/workbench/MissionListPanel.vue')['default']
|
||||
MonitorSelectionPanel: typeof import('./src/components/workbench/MonitorSelectionPanel.vue')['default']
|
||||
|
||||
@@ -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 正常返回 data;conflict=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>
|
||||
|
||||
<!--
|
||||
非 scoped:el-dialog 会 teleport 到 body,scoped 的 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-6:switchScope 改为后端发起 ——
|
||||
// 必须 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 已经是 number(AlarmEvent 类型保证),无须额外字符串处理;如果将来后端
|
||||
// 改成字符串,这里 Number(...) 会返回 NaN 并由下面的 isFinite 拦下来。
|
||||
const numId = Number(a.carId)
|
||||
@@ -137,12 +137,8 @@ async function onAlarmLocate(a: AlarmEvent) {
|
||||
ElMessage.warning(`报警车辆 id 无法解析:${a.carId}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.setSelection('car', numId)
|
||||
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
|
||||
} catch (err) {
|
||||
ElMessage.error(`定位失败:${(err as Error).message}`)
|
||||
}
|
||||
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
|
||||
await selectAndLocateVehicle(numId)
|
||||
}
|
||||
function onAlarmAck(_a: AlarmEvent) {
|
||||
// 当前后端无 ACK 接口,前端本地标记即可(FloatingAlarmStack 已处理)
|
||||
@@ -342,18 +338,31 @@ async function fallbackSelectionFromBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中并定位到指定车辆:① 同步 SimpleLite 3D 场景高亮;② 立即把相机定位(居中 + 2D 俯视)
|
||||
* 到该车,与原生「双击车辆行 = 选中+定位」一致。两个请求互不阻塞,各自独立提示错误
|
||||
* (例如 iframe 未连接时相机定位会 503,但高亮仍可成功)。
|
||||
*/
|
||||
async function selectAndLocateVehicle(id: number) {
|
||||
if (!Number.isFinite(id)) return
|
||||
const [selRes, locRes] = await Promise.allSettled([
|
||||
reflectionApi.setSelection('car', id),
|
||||
workspaceToolbarApi.locateCamera(id)
|
||||
])
|
||||
if (selRes.status === 'rejected') {
|
||||
ElMessage.error({ message: `同步 3D 选中失败:${(selRes.reason as Error).message}`, grouping: true })
|
||||
}
|
||||
if (locRes.status === 'rejected') {
|
||||
ElMessage.error({ message: `定位车辆失败:${(locRes.reason as Error).message}`, grouping: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
selectedDeliveryId.value = null
|
||||
if (!sameSelection(selection.value, ref)) {
|
||||
selection.value = ref
|
||||
}
|
||||
const id = Number(ref.id)
|
||||
if (!Number.isFinite(id)) return
|
||||
try {
|
||||
await reflectionApi.setSelection('car', id)
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||
}
|
||||
await selectAndLocateVehicle(Number(ref.id))
|
||||
}
|
||||
|
||||
async function onDeliverySelect(task: DeliveryTask) {
|
||||
@@ -364,16 +373,12 @@ async function onDeliverySelect(task: DeliveryTask) {
|
||||
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.setSelection('car', task.carId)
|
||||
selection.value = {
|
||||
kind: 'vehicle',
|
||||
id: String(task.carId),
|
||||
name: task.carName ?? `Vehicle ${task.carId}`
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||
selection.value = {
|
||||
kind: 'vehicle',
|
||||
id: String(task.carId),
|
||||
name: task.carName ?? `Vehicle ${task.carId}`
|
||||
}
|
||||
await selectAndLocateVehicle(task.carId)
|
||||
return
|
||||
}
|
||||
selection.value = {
|
||||
|
||||
+6
-6
@@ -2,12 +2,12 @@
|
||||
<ConfigPageBase
|
||||
section="integrations"
|
||||
title="外部系统对接"
|
||||
description="MES / WMS / RCS 等标准接口配置(ExternalIntegrations)"
|
||||
description="MES / WMS / RCS / PTL 等标准接口配置(ExternalIntegrations)"
|
||||
:defaults="DEFAULT_INTEGRATIONS">
|
||||
<template #default="{ payload, update }">
|
||||
<el-tabs model-value="mes">
|
||||
<el-tab-pane v-for="kind in (['mes','wms','rcs'] as const)" :key="kind" :name="kind" :label="kind.toUpperCase()">
|
||||
<el-table :data="payload[kind]" size="small" border>
|
||||
<el-tab-pane v-for="kind in (['mes','wms','rcs','ptl'] as const)" :key="kind" :name="kind" :label="kind.toUpperCase()">
|
||||
<el-table :data="payload[kind] ?? []" size="small" border>
|
||||
<el-table-column label="ID" width="120">
|
||||
<template #default="s">
|
||||
<el-input v-model="s.row.id" />
|
||||
@@ -41,13 +41,13 @@ import ConfigPageBase from '@/components/ConfigPageBase.vue'
|
||||
import { DEFAULT_INTEGRATIONS } from '@/mock/data/configs'
|
||||
import type { ExternalIntegrations } from '@/types/config'
|
||||
|
||||
type Kind = 'mes' | 'wms' | 'rcs'
|
||||
type Kind = 'mes' | 'wms' | 'rcs' | 'ptl'
|
||||
|
||||
function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind) {
|
||||
const id = `${kind}-${Date.now()}`
|
||||
const next: ExternalIntegrations = {
|
||||
...payload,
|
||||
[kind]: [...payload[kind], { id, name: '新端点', url: 'http://', enabled: false }]
|
||||
[kind]: [...(payload[kind] ?? []), { id, name: '新端点', url: 'http://', enabled: false }]
|
||||
}
|
||||
update(next)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function add(payload: ExternalIntegrations, update: (n: ExternalIntegrations) =>
|
||||
function remove(payload: ExternalIntegrations, update: (n: ExternalIntegrations) => void, kind: Kind, idx: number) {
|
||||
const next: ExternalIntegrations = {
|
||||
...payload,
|
||||
[kind]: payload[kind].filter((_, i) => i !== idx)
|
||||
[kind]: (payload[kind] ?? []).filter((_, i) => i !== idx)
|
||||
}
|
||||
update(next)
|
||||
}
|
||||
|
||||
@@ -5,20 +5,14 @@
|
||||
description="分区域/跨楼层/多车协作;OTA、批量操作、网络诊断(FleetLifecycleConfig)"
|
||||
:defaults="DEFAULT_FLEET">
|
||||
<template #default="{ payload }">
|
||||
<el-tabs model-value="groups">
|
||||
<el-tab-pane name="groups" label="车队分组">
|
||||
<el-table :data="payload.groups" size="small" border>
|
||||
<el-table-column label="ID" prop="id" width="100" />
|
||||
<el-table-column label="名称" prop="name" width="140" />
|
||||
<el-table-column label="楼层" prop="floor" width="80" />
|
||||
<el-table-column label="区域" prop="region" width="80" />
|
||||
<el-table-column label="车辆">
|
||||
<template #default="s">
|
||||
<el-tag v-for="c in s.row.carIds" :key="c" size="small" style="margin: 2px">{{ c }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="车队分配已移至「车辆运维 → 运维总览」"
|
||||
description="在运维总览中可新建车队、设定名称/区域/楼层,并将车辆分配到各车队。"
|
||||
style="margin-bottom: 12px" />
|
||||
<el-tabs model-value="ota">
|
||||
<el-tab-pane name="ota" label="OTA 升级">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="启用">{{ payload.ota.enabled ? '是' : '否' }}</el-descriptions-item>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<el-select v-model="filterState" size="small" clearable placeholder="状态" class="filter-select">
|
||||
<el-option v-for="opt in stateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filterGroup" size="small" clearable placeholder="群组" class="filter-select">
|
||||
<el-option v-for="g in groupOptions" :key="g" :label="g" :value="g" />
|
||||
<el-select v-model="filterFleet" size="small" clearable placeholder="车队" class="filter-select fleet-filter">
|
||||
<el-option v-for="g in fleetFilterOptions" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
<el-dropdown :disabled="!canWrite || !selectedIds.length" @command="onBatchCommand">
|
||||
<el-button size="small" :disabled="!canWrite || !selectedIds.length">
|
||||
@@ -74,6 +74,8 @@
|
||||
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
|
||||
</div>
|
||||
|
||||
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
|
||||
|
||||
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计)</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
@@ -94,14 +96,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
|
||||
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
|
||||
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
|
||||
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
|
||||
import { useVehicleHub } from '@/composables/useVehicleHub'
|
||||
import { useFleetGroups } from '@/composables/useFleetGroups'
|
||||
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||
import type { CarState } from '@/types/car'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -140,9 +144,12 @@ const {
|
||||
refreshAll
|
||||
} = useVehicleHub()
|
||||
|
||||
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
|
||||
onMounted(() => void reloadFleetGroups())
|
||||
|
||||
const search = ref('')
|
||||
const filterState = ref<CarState | ''>('')
|
||||
const filterGroup = ref('')
|
||||
const filterFleet = ref('')
|
||||
|
||||
const stateOptions: { value: CarState; label: string }[] = [
|
||||
{ value: 'idle', label: '空闲' },
|
||||
@@ -153,22 +160,33 @@ const stateOptions: { value: CarState; label: string }[] = [
|
||||
{ value: 'offline', label: '离线' }
|
||||
]
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const c of cardModels.value) {
|
||||
if (c.group) set.add(c.group)
|
||||
}
|
||||
return [...set]
|
||||
})
|
||||
const fleetFilterOptions = computed(() =>
|
||||
fleetGroups.value.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.region ? `${g.name}(${g.region})` : g.name || g.id
|
||||
}))
|
||||
)
|
||||
|
||||
function onFleetSaved() {
|
||||
void reloadFleetGroups(true)
|
||||
}
|
||||
|
||||
function matchesFleetFilter(carId: string): boolean {
|
||||
if (!filterFleet.value) return true
|
||||
const fleet = fleetGroups.value.find((g) => g.id === filterFleet.value)
|
||||
return fleet ? fleet.carIds.includes(carId) : false
|
||||
}
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
||||
return cardModels.value.filter((c) => {
|
||||
if (filterState.value && c.state !== filterState.value) return false
|
||||
if (filterGroup.value && c.group !== filterGroup.value) return false
|
||||
if (!matchesFleetFilter(c.id)) return false
|
||||
if (!tokens.length) return true
|
||||
const hay = [c.id, c.name, c.ip, c.group, c.lstatus].filter(Boolean).join(' ').toLowerCase()
|
||||
const fleetName = fleetNameForCarId(c.id)
|
||||
const region = regionForCarId(c.id)
|
||||
const hay = [c.id, c.name, c.ip, c.group, fleetName, region, c.lstatus].filter(Boolean).join(' ').toLowerCase()
|
||||
return tokens.every((t) => hay.includes(t))
|
||||
})
|
||||
})
|
||||
@@ -295,6 +313,10 @@ async function onBatchCommand(cmd: string) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.fleet-filter {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
Reference in New Issue
Block a user