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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-06-03 09:37:07 +08:00
co-authored by Cursor
parent e2269e430d
commit 7382e85598
109 changed files with 3481 additions and 398 deletions
@@ -0,0 +1,281 @@
<template>
<section class="fleet-alloc">
<div class="fa-header">
<span class="fa-title">车队分配</span>
<el-tag size="small" type="info" effect="plain">区域管理</el-tag>
<span class="fa-sub">将车辆分配到车队并设定车队名称 / 区域 / 楼层</span>
<div class="spacer" />
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
保存
</el-button>
</div>
<div v-loading="loading" class="fa-body">
<el-empty v-if="!fleets.length" description="暂无车队,点击「新建车队」开始分配" />
<div v-for="(fleet, idx) in fleets" :key="fleet.id" class="fleet-card">
<div class="fc-row">
<el-input
v-model="fleet.name"
size="small"
class="fc-name"
placeholder="车队名称"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>名称</template>
</el-input>
<el-input
v-model="fleet.region"
size="small"
class="fc-region"
placeholder="区域"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>区域</template>
</el-input>
<el-input
v-model="fleet.floor"
size="small"
class="fc-floor"
placeholder="楼层"
:disabled="!canWrite"
@input="markDirty">
<template #prepend>楼层</template>
</el-input>
<el-tag size="small" effect="plain">{{ fleet.carIds.length }} </el-tag>
<el-button
size="small"
type="danger"
text
:icon="Delete"
:disabled="!canWrite"
@click="removeFleet(idx)">
删除
</el-button>
</div>
<el-select
v-model="fleet.carIds"
size="small"
multiple
filterable
collapse-tags
collapse-tags-tooltip
class="fc-cars"
placeholder="选择要分配到该车队的车辆"
:disabled="!canWrite"
@change="markDirty">
<el-option
v-for="c in optionsForFleet(fleet)"
:key="c.id"
:label="c.label"
:value="c.id"
:disabled="c.takenByOther" />
</el-select>
</div>
</div>
<p v-if="unknownCarIds.length" class="fa-note">
提示以下已分配的车辆 ID 不在当前在册车辆中{{ unknownCarIds.join('') }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Refresh, Check, Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useConfigStore } from '@/stores/config'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { DEFAULT_FLEET } from '@/mock/data/configs'
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
const props = defineProps<{
cars: { id: string; name?: string }[]
canWrite: boolean
}>()
const emit = defineEmits<{ saved: [] }>()
const store = useConfigStore()
const { reload: reloadShared } = useFleetGroups()
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
// 保留 fleet 配置中除 groups 以外的字段(OTA / 批量 / 诊断),保存时原样回写。
const rest = ref<Omit<FleetLifecycleConfig, 'groups'>>({
ota: DEFAULT_FLEET.ota,
batchOps: DEFAULT_FLEET.batchOps,
networkDiag: DEFAULT_FLEET.networkDiag
})
const fleets = ref<FleetGroup[]>([])
function markDirty() {
dirty.value = true
}
const carIndex = computed(() => {
const m = new Map<string, string>()
for (const c of props.cars) m.set(c.id, c.name ?? c.id)
return m
})
function optionsForFleet(fleet: FleetGroup) {
const assignedElsewhere = new Set<string>()
for (const f of fleets.value) {
if (f === fleet) continue
for (const id of f.carIds) assignedElsewhere.add(id)
}
return props.cars.map((c) => ({
id: c.id,
label: c.name && c.name !== c.id ? `${c.id} · ${c.name}` : c.id,
takenByOther: assignedElsewhere.has(c.id)
}))
}
const unknownCarIds = computed(() => {
const known = carIndex.value
const out: string[] = []
for (const f of fleets.value) {
for (const id of f.carIds) {
if (!known.has(id) && !out.includes(id)) out.push(id)
}
}
return out
})
function newFleetId(): string {
const used = new Set(fleets.value.map((f) => f.id))
let i = fleets.value.length + 1
let id = `G-${i}`
while (used.has(id)) {
i += 1
id = `G-${i}`
}
return id
}
function addFleet() {
fleets.value.push({ id: newFleetId(), name: '新车队', floor: '', region: '', carIds: [] })
markDirty()
}
function removeFleet(idx: number) {
fleets.value.splice(idx, 1)
markDirty()
}
async function reload(force = false) {
loading.value = true
try {
const env = await store.load<FleetLifecycleConfig>('fleet', force)
const payload = env.payload ?? DEFAULT_FLEET
rest.value = {
ota: payload.ota ?? DEFAULT_FLEET.ota,
batchOps: payload.batchOps ?? DEFAULT_FLEET.batchOps,
networkDiag: payload.networkDiag ?? DEFAULT_FLEET.networkDiag
}
fleets.value = JSON.parse(JSON.stringify(payload.groups ?? [])) as FleetGroup[]
dirty.value = false
if (force) ElMessage.success('已重载车队配置')
} catch (e) {
ElMessage.error(`加载车队配置失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const body: FleetLifecycleConfig = {
...rest.value,
groups: JSON.parse(JSON.stringify(fleets.value)) as FleetGroup[]
}
const env = await store.save<FleetLifecycleConfig>('fleet', body)
dirty.value = false
await reloadShared(true)
emit('saved')
ElMessage.success(`车队分配已保存 v${env.version}`)
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
saving.value = false
}
}
onMounted(() => reload())
</script>
<style scoped>
.fleet-alloc {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 12px;
background: var(--mg-veil-2, rgba(255, 255, 255, 0.03));
}
.fa-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fa-title {
font-weight: 600;
font-size: 14px;
color: var(--mg-text-light, #fff);
}
.fa-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.fa-header .spacer {
flex: 1;
}
.fa-body {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 280px;
overflow: auto;
}
.fleet-card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 10px;
}
.fc-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fc-name { width: 220px; }
.fc-region { width: 160px; }
.fc-floor { width: 150px; }
.fc-cars { width: 100%; }
.fa-note {
margin: 0;
font-size: 11px;
color: var(--el-color-warning);
}
</style>
@@ -0,0 +1,419 @@
<template>
<div
class="ai-assistant-panel"
:class="{ 'is-open': open }"
:style="{ width: panelWidth + 'px' }"
role="complementary"
aria-label="AI 助手"
>
<div
class="aap-resizer"
title="拖拽调整宽度"
@pointerdown="onResizeStart"
@pointermove="onResizeMove"
@pointerup="onResizeEnd"
@pointercancel="onResizeEnd"
></div>
<div class="aap-header">
<div class="aap-title">
<span class="aap-glyph"></span>
<div class="aap-title-text">
<div class="aap-title-main">AI 助手</div>
<div class="aap-title-sub">用自然语言描述地图需求自动生成站点 / 路径</div>
</div>
</div>
<button class="aap-close" type="button" title="收起" @click="close"></button>
</div>
<el-alert v-if="!configured" type="warning" :closable="false" class="aap-alert">
尚未配置 AI 服务apiKey / endpoint请先到
<el-link type="primary" @click="goConfig">系统级配置 AI 服务</el-link>
完成配置
</el-alert>
<div ref="listRef" class="aap-messages">
<div v-if="messages.length === 0" class="aap-empty">
<div class="aap-empty-title">试着这样说</div>
<button
v-for="(ex, i) in examples"
:key="i"
type="button"
class="aap-example"
@click="useExample(ex)"
>{{ ex }}</button>
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="aap-msg"
:class="`aap-msg--${m.role}`"
>
<div class="aap-bubble">
<div class="aap-bubble-text">{{ m.text }}</div>
<div v-if="m.role === 'assistant' && m.meta" class="aap-meta">
落地对象 <b>{{ m.meta.created }}</b> · 工具调用 <b>{{ m.meta.usedTools }}</b>
</div>
</div>
</div>
<div v-if="busy" class="aap-msg aap-msg--assistant">
<div class="aap-bubble aap-bubble--loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>AI 正在生成</span>
</div>
</div>
</div>
<div class="aap-toolbar">
<span class="aap-toolbar-label">生成模式</span>
<el-radio-group v-model="mode" size="small">
<el-radio-button value="sites">站点</el-radio-button>
<el-radio-button value="tracks">路径</el-radio-button>
<el-radio-button value="sites+tracks">站点+路径</el-radio-button>
<el-radio-button value="full">完整</el-radio-button>
</el-radio-group>
</div>
<div class="aap-input">
<el-input
v-model="draft"
type="textarea"
:rows="3"
resize="none"
:disabled="!configured || busy"
placeholder="例如:一条 U 型生产线,含 5 个工站,间距 2m,单向通行…(Enter 发送,Shift+Enter 换行)"
@keydown="onKeydown"
/>
<el-button
type="primary"
class="aap-send"
:loading="busy"
:disabled="!configured || !draft.trim()"
@click="send"
>发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
const props = defineProps<{
/** 面板是否展开(停靠在右侧)。 */
open: boolean
/** AI 服务是否已配置 apiKey / endpoint。 */
configured: boolean
/** 面板宽度(px),由父级持久化;可拖拽左沿调整。 */
width?: number
/** 生成范围默认值 x1,y1,x2,y2(mm),沿用编辑器默认。 */
defaultBounds?: [number, number, number, number]
/** 生成对象默认落点图层。 */
defaultLayer?: string
}>()
const emit = defineEmits<{
(e: 'update:open', v: boolean): void
(e: 'update:width', v: number): void
(e: 'generated', r: AiMapGenerateResult): void
}>()
/** 宽度约束:保证内容(按钮 / 单选组)不被压垮,也不至于把画布挤没。 */
const MIN_WIDTH = 300
const MAX_WIDTH = 720
const panelWidth = computed(() => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, props.width ?? 360)))
const router = useRouter()
interface ChatMessage {
role: 'user' | 'assistant'
text: string
meta?: { created: number; usedTools: number }
}
const messages = ref<ChatMessage[]>([])
const draft = ref('')
const busy = ref(false)
const listRef = ref<HTMLElement | null>(null)
// 记忆上次使用的生成模式:下次打开沿用,免去每次重选。
type GenMode = NonNullable<AiMapGenerateRequest['mode']>
const MODE_KEY = 'mapEditor.aiAssistant.mode'
function loadMode(): GenMode {
const v = localStorage.getItem(MODE_KEY)
if (v === 'sites' || v === 'tracks' || v === 'sites+tracks' || v === 'full') return v
return 'sites+tracks'
}
const mode = ref<GenMode>(loadMode())
watch(mode, (v) => {
try { localStorage.setItem(MODE_KEY, v) } catch { /* localStorage 不可用则忽略 */ }
})
// ── 拖拽调整宽度 ──
// 面板停靠右侧,左沿手柄向左拖 → 变宽。用 setPointerCapture 把后续 pointermove 锁定到
// 手柄元素上,避免指针移到中间的 webVRender iframe 上方时事件被 iframe 吞掉、拖拽中断。
let resizing = false
let resizeStartX = 0
let resizeStartW = 0
function onResizeStart(e: PointerEvent) {
resizing = true
resizeStartX = e.clientX
resizeStartW = panelWidth.value
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
e.preventDefault()
}
function onResizeMove(e: PointerEvent) {
if (!resizing) return
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, resizeStartW + (resizeStartX - e.clientX)))
emit('update:width', next)
}
function onResizeEnd(e: PointerEvent) {
if (!resizing) return
resizing = false
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
}
onBeforeUnmount(() => { resizing = false })
const examples = [
'生成一条横向直线,5 个站点,间距 2000mm',
'画一个 3×3 的站点矩阵,间距 2500mm',
'一条 U 型产线,含 5 个工站,单向通行'
]
function close() {
emit('update:open', false)
}
function goConfig() {
close()
router.push('/admin/config/system')
}
function useExample(ex: string) {
draft.value = ex
}
function onKeydown(e: Event | KeyboardEvent) {
// el-input 的 keydown 事件签名是 Event | KeyboardEvent,这里收窄到键盘事件。
// Enter 发送、Shift+Enter 换行;中文输入法组合期间(isComposing)不触发发送。
if (!(e instanceof KeyboardEvent)) return
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
void send()
}
}
async function scrollToBottom() {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
const text = draft.value.trim()
if (!text || busy.value || !props.configured) return
messages.value.push({ role: 'user', text })
draft.value = ''
void scrollToBottom()
busy.value = true
try {
const req: AiMapGenerateRequest = {
prompt: text,
mode: mode.value,
bounds: props.defaultBounds,
layer: props.defaultLayer
}
const r = await mapEditApi.aiMapGenerate(req)
const created = r.created?.length ?? 0
messages.value.push({
role: 'assistant',
text: r.assistantText?.trim() || `已根据你的描述生成并落地 ${created} 个对象。`,
meta: { created, usedTools: r.usedTools ?? 0 }
})
emit('generated', r)
} catch (err) {
const msg = (err as Error).message
messages.value.push({ role: 'assistant', text: `生成失败:${msg}` })
ElMessage.error(`AI 助手生成失败:${msg}`)
} finally {
busy.value = false
void scrollToBottom()
}
}
</script>
<style scoped>
.ai-assistant-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 30;
display: flex;
flex-direction: column;
/* 实底(高不透明),解决「太透明看不清」 */
background: linear-gradient(180deg, rgba(28, 12, 56, 0.98) 0%, rgba(16, 6, 34, 0.99) 100%);
border-left: 1px solid rgba(190, 140, 240, 0.28);
box-shadow: -10px 0 30px rgba(8, 2, 16, 0.55);
color: rgba(236, 224, 250, 0.95);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
transition: transform 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.26s ease, visibility 0.26s;
}
.ai-assistant-panel.is-open {
transform: translateX(0);
opacity: 1;
visibility: visible;
}
/* 左沿拖拽手柄:覆盖在 border-left 上方,hover 高亮提示可拖拽。 */
.aap-resizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 5;
background: transparent;
transition: background 0.15s ease;
touch-action: none;
}
.aap-resizer:hover { background: rgba(190, 140, 240, 0.45); }
.aap-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.5) 0%, rgba(255, 90, 200, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.aap-glyph {
font-size: 20px;
color: #fff;
text-shadow: 0 0 10px rgba(255, 200, 250, 0.7);
flex: none;
}
.aap-title-text { min-width: 0; }
.aap-title-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; }
.aap-close {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 26px;
height: 26px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
line-height: 1;
flex: none;
transition: background 0.15s ease;
}
.aap-close:hover { background: rgba(255, 255, 255, 0.24); }
.aap-alert { margin: 10px 12px 0; }
.aap-messages {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.aap-empty { padding: 8px 2px; display: flex; flex-direction: column; gap: 8px; }
.aap-empty-title { font-size: 12px; color: rgba(210, 188, 240, 0.7); }
.aap-example {
appearance: none;
text-align: left;
border: 1px dashed rgba(190, 140, 240, 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.9);
border-radius: 8px;
padding: 8px 10px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.aap-example:hover {
background: rgba(150, 90, 230, 0.22);
border-color: rgba(190, 140, 240, 0.7);
color: #fff;
}
.aap-msg { display: flex; }
.aap-msg--user { justify-content: flex-end; }
.aap-msg--assistant { justify-content: flex-start; }
.aap-bubble {
max-width: 86%;
padding: 8px 11px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
}
.aap-msg--user .aap-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.95) 0%, rgba(120, 70, 220, 0.95) 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.aap-msg--assistant .aap-bubble {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.95);
border-bottom-left-radius: 4px;
}
.aap-bubble-text { white-space: pre-wrap; word-break: break-word; }
.aap-meta {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.14);
font-size: 11.5px;
color: rgba(210, 188, 240, 0.8);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(210, 188, 240, 0.85);
}
.aap-toolbar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 0;
flex-wrap: wrap;
}
.aap-toolbar-label { font-size: 11.5px; color: rgba(210, 188, 240, 0.7); flex: none; }
.aap-toolbar :deep(.el-radio-button__inner) {
padding: 5px 9px;
font-size: 12px;
}
.aap-input {
flex: none;
padding: 8px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.aap-send { align-self: flex-end; min-width: 84px; }
</style>
@@ -3,6 +3,7 @@
:model-value="modelValue"
title="AI 生图"
width="640px"
class="ai-generate-dialog"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@close="onClose"
>
@@ -186,3 +187,18 @@ async function onGenerate() {
}
.ai-stat b { color: var(--mg-accent, #c4a4ff); }
</style>
<!--
scopedel-dialog teleport bodyscoped data-v 不一定能命中对话框盒子
深色主题下全局 --el-bg-color 0.55 不透明度导致 AI 生图对话框太透明看不清
这里按主题色把对话框背景设为实底rgb 三元组无 alpha = 完全不透明
同时兼容 class 落在 .el-dialog 盒子或外层 overlay 两种情况
fame-lavender 浅色主题已有 `.el-dialog{background:#fff!important}` 且特异性更高不受影响
-->
<style>
.ai-generate-dialog.el-dialog,
.ai-generate-dialog .el-dialog {
background-color: rgb(var(--mg-bg-card-rgb)) !important;
box-shadow: 0 24px 60px rgba(8, 2, 16, 0.6) !important;
}
</style>
@@ -1,17 +1,13 @@
<template>
<div class="edit-top-bar">
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">项目加载和保存</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="project.open">加载文件</el-dropdown-item>
<el-dropdown-item command="project.save">保存文件</el-dropdown-item>
<el-dropdown-item command="project.saveAs">另存为</el-dropdown-item>
<el-dropdown-item command="project.props" divided>项目属性</el-dropdown-item>
<el-dropdown-item command="project.close">关闭</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
type="primary"
class="topbar-save-btn"
:loading="saving"
@click="onCmd('project.save')"
>
保存
</el-button>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">导入</el-button>
@@ -115,9 +111,6 @@
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')"></el-button>
</el-tooltip>
<el-tag v-if="saving" type="warning" effect="dark" class="topbar-saving-tag" size="small">
保存中
</el-tag>
</div>
</template>
@@ -194,8 +187,13 @@ function mark(on: boolean): string {
opacity: 0.4;
}
.topbar-saving-tag {
margin-left: 8px;
.topbar-save-btn {
font-weight: 600;
font-size: 13.5px;
padding: 6px 18px;
height: auto;
border-radius: 6px;
margin-right: 4px;
}
.topbar-filter-menu :deep(.topbar-filter-header) {
@@ -0,0 +1,302 @@
<template>
<div class="mc-panel">
<div class="mc-toolbar">
<el-input
v-model="keyword"
class="mc-search"
placeholder="请输入地图名称搜索"
clearable
:prefix-icon="Search"
/>
<el-button type="primary" :icon="Plus" @click="openCreate">添加地图关系</el-button>
</div>
<el-table
v-loading="loading"
:data="pageRows"
class="mc-table"
border
empty-text="还没有地图连接关系点击右上角添加地图关系创建跨楼层 / 拼接连接"
>
<el-table-column prop="id" label="ID" width="80" sortable />
<el-table-column prop="sourceMap" label="起始地图名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="sourceMapId" label="起始地图ID" width="110" />
<el-table-column prop="sourceStation" label="起始切换点站点" min-width="150" show-overflow-tooltip />
<el-table-column prop="targetMap" label="目的地图名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="targetMapId" label="目的地图ID" width="110" />
<el-table-column prop="targetStation" label="目的切换点站点" min-width="150" show-overflow-tooltip />
<el-table-column label="转移代价(cm" width="130">
<template #default="{ row }">
<span class="mc-cost mg-mono">{{ row.cost }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="right" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mc-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filtered.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
/>
</div>
<!-- 添加 / 编辑 地图关系 -->
<el-dialog
v-model="dialogVisible"
:title="editingId === null ? '添加地图关系' : '编辑地图关系'"
width="520px"
class="map-conn-dialog"
append-to-body
@closed="onDialogClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="right">
<el-form-item label="起始地图" prop="sourceMap">
<el-select v-model="form.sourceMap" placeholder="请选择起始地图" filterable style="width: 100%">
<el-option v-for="m in mapOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="起始切换点站点" prop="sourceStation">
<el-select
v-model="form.sourceStation"
placeholder="请选择起始切换点站点"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option v-for="s in stationOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="目的地图" prop="targetMap">
<el-select v-model="form.targetMap" placeholder="请选择目的地图" filterable style="width: 100%">
<el-option v-for="m in mapOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="目的切换点站点" prop="targetStation">
<el-select
v-model="form.targetStation"
placeholder="请选择目的切换点站点"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option v-for="s in stationOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="转移代价(cm)" prop="cost">
<el-input-number v-model="form.cost" :min="0" :step="100" :controls="false" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { mapsApi } from '@/api/mapEdit'
import { mapConnectionApi, type MapConnection } from '@/api/mapConnection'
const loading = ref(false)
const saving = ref(false)
const rows = ref<MapConnection[]>([])
const mapOptions = ref<string[]>([])
const stationOptions = ref<string[]>([])
const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const form = reactive({
sourceMap: '',
sourceStation: '',
targetMap: '',
targetStation: '',
cost: 1000
})
const rules: FormRules = {
sourceMap: [{ required: true, message: '请选择起始地图', trigger: 'change' }],
sourceStation: [{ required: true, message: '请选择起始切换点站点', trigger: 'change' }],
targetMap: [{ required: true, message: '请选择目的地图', trigger: 'change' }],
targetStation: [{ required: true, message: '请选择目的切换点站点', trigger: 'change' }],
cost: [{ required: true, message: '请输入转移代价', trigger: 'blur' }]
}
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return rows.value
return rows.value.filter(
(r) => r.sourceMap.toLowerCase().includes(kw) || r.targetMap.toLowerCase().includes(kw)
)
})
const pageRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filtered.value.slice(start, start + pageSize.value)
})
// 搜索 / 分页大小变化时回到第一页,避免停留在空白页。
watch([keyword, pageSize], () => {
page.value = 1
})
async function refresh() {
loading.value = true
try {
rows.value = await mapConnectionApi.list()
stationOptions.value = mapConnectionApi.stationSuggestions()
} catch (err) {
ElMessage.error(`加载地图连接失败:${(err as Error).message}`)
} finally {
loading.value = false
}
}
async function loadMaps() {
try {
const r = await mapsApi.list()
mapOptions.value = r.maps.map((m) => m.name)
} catch {
// 地图列表拉取失败不阻塞连接管理;下拉为空时仍可手动输入站点。
mapOptions.value = []
}
}
function resetForm() {
form.sourceMap = ''
form.sourceStation = ''
form.targetMap = ''
form.targetStation = ''
form.cost = 1000
}
function openCreate() {
editingId.value = null
resetForm()
stationOptions.value = mapConnectionApi.stationSuggestions()
dialogVisible.value = true
}
function openEdit(row: MapConnection) {
editingId.value = row.id
form.sourceMap = row.sourceMap
form.sourceStation = row.sourceStation
form.targetMap = row.targetMap
form.targetStation = row.targetStation
form.cost = row.cost
stationOptions.value = mapConnectionApi.stationSuggestions()
dialogVisible.value = true
}
function onDialogClosed() {
formRef.value?.clearValidate()
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (ok) => {
if (!ok) return
saving.value = true
try {
const payload = {
sourceMap: form.sourceMap,
sourceStation: form.sourceStation,
targetMap: form.targetMap,
targetStation: form.targetStation,
cost: form.cost
}
if (editingId.value === null) {
await mapConnectionApi.create(payload)
ElMessage.success('已添加地图连接')
} else {
await mapConnectionApi.update(editingId.value, payload)
ElMessage.success('已更新地图连接')
}
dialogVisible.value = false
await refresh()
} catch (err) {
ElMessage.error(`保存失败:${(err as Error).message}`)
} finally {
saving.value = false
}
})
}
async function onDelete(row: MapConnection) {
try {
await ElMessageBox.confirm(
`确认删除「${row.sourceMap}${row.targetMap}」这条地图连接?`,
'删除地图连接',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
await mapConnectionApi.remove(row.id)
ElMessage.success('已删除')
// 删除后当前页可能空了,回退一页。
if (pageRows.value.length === 1 && page.value > 1) page.value -= 1
await refresh()
} catch (err) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(`删除失败:${(err as Error).message}`)
}
}
onMounted(() => {
refresh()
loadMaps()
})
defineExpose({ refresh })
</script>
<style scoped>
.mc-panel {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.mc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.mc-search {
width: 300px;
max-width: 60%;
}
.mc-table {
flex: 1;
min-height: 0;
}
.mc-cost {
font-variant-numeric: tabular-nums;
}
.mc-pager {
flex-shrink: 0;
display: flex;
justify-content: center;
padding-top: 2px;
}
</style>
@@ -0,0 +1,223 @@
<template>
<div class="mm-merge">
<el-alert class="merge-tip" type="info" :closable="false" show-icon>
<template #title>
与桌面端合并一致<strong>当前使用地图</strong>为底图把选中的地图依次合并进来自动分配独立图层
站点 / 路径 ID 自动避让互不冲突再另存为目标地图可用于多楼层汇总多区域拼接
</template>
</el-alert>
<el-alert
v-if="!loadingMaps && !currentName"
class="merge-tip"
type="warning"
:closable="false"
show-icon
title="当前未设置「使用中」地图。请先到「服务器地图」页对某张地图点「使用」,再来执行合并。"
/>
<div class="merge-body">
<el-form label-width="120px" label-position="right" class="merge-form" @submit.prevent>
<el-form-item label="底图(当前地图)">
<el-tag v-if="currentName" type="success" effect="plain">{{ currentName }}</el-tag>
<span v-else class="merge-hint">未设置</span>
</el-form-item>
<el-form-item label="合并进来的地图" required>
<el-select
v-model="selected"
multiple
filterable
:loading="loadingMaps"
:disabled="!currentName"
placeholder="选择 1 张及以上要合并进当前地图的地图"
style="width: 100%"
>
<el-option v-for="m in sourceOptions" :key="m" :label="m" :value="m" />
</el-select>
</el-form-item>
<el-form-item label="另存为" required>
<el-input
v-model="target"
:disabled="!currentName"
placeholder="目标地图名称(与当前地图同名则覆盖当前地图)"
clearable
maxlength="60"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="merging" :disabled="!canMerge" @click="onMerge">开始合并</el-button>
<el-button text :disabled="!currentName" @click="reset">重置</el-button>
<span class="merge-hint">已选 {{ selected.length }} </span>
</el-form-item>
</el-form>
<div v-if="currentName" class="merge-order">
<div class="order-title">叠加顺序</div>
<ol class="order-list">
<li class="order-item">
<el-tag size="small" type="success" effect="plain">底图</el-tag>
<span class="order-name">{{ currentName }}</span>
</li>
<li v-for="(m, i) in selected" :key="m" class="order-item">
<el-tag size="small" type="info" effect="plain">叠加 {{ i + 1 }}</el-tag>
<span class="order-name">{{ m }}</span>
</li>
</ol>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mapsApi } from '@/api/mapEdit'
const emit = defineEmits<{ (e: 'merged', name: string): void }>()
const loadingMaps = ref(false)
const merging = ref(false)
const allMaps = ref<string[]>([])
const currentName = ref('')
const selected = ref<string[]>([])
const target = ref('')
// 可合并的源地图 = 全部地图去掉「当前地图」自身。
const sourceOptions = computed(() => allMaps.value.filter((m) => m !== currentName.value))
const canMerge = computed(
() => !!currentName.value && selected.value.length >= 1 && target.value.trim().length > 0
)
async function loadMaps() {
loadingMaps.value = true
try {
const r = await mapsApi.list()
allMaps.value = r.maps.map((m) => m.name)
currentName.value = r.maps.find((m) => m.isCurrent)?.name ?? ''
// 当前地图变化时,剔除已不可选的项。
selected.value = selected.value.filter((m) => sourceOptions.value.includes(m))
} catch (err) {
ElMessage.error(`加载地图列表失败:${(err as Error).message}`)
} finally {
loadingMaps.value = false
}
}
function reset() {
selected.value = []
target.value = ''
}
async function doMerge(overwrite: boolean) {
const sources = [...selected.value]
const name = target.value.trim()
const res = await mapsApi.merge(sources, name, overwrite)
if (res.ok) {
const into = res.data.overwroteCurrent ? '(已覆盖当前地图)' : ''
ElMessage.success(
`已把 ${res.data.sourceCount} 张地图合并进「${res.data.baseMap}」并另存为「${res.data.name}${into}` +
`(站点 ${res.data.sites} · 路径 ${res.data.tracks}`
)
reset()
await loadMaps()
emit('merged', name)
return
}
if (res.conflict) {
const tip =
name === currentName.value
? `目标与当前地图「${name}」同名,将用合并结果覆盖当前地图,是否继续?`
: `地图「${name}」已存在,是否替换原地图?`
try {
await ElMessageBox.confirm(tip, '目标地图已存在', {
type: 'warning',
confirmButtonText: '替换',
cancelButtonText: '取消'
})
} catch {
return
}
await doMerge(true)
return
}
ElMessage.error(res.message)
}
async function onMerge() {
if (!canMerge.value) return
merging.value = true
try {
await doMerge(false)
} finally {
merging.value = false
}
}
onMounted(loadMaps)
defineExpose({ refresh: loadMaps })
</script>
<style scoped>
.mm-merge {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: auto;
}
.merge-tip {
flex-shrink: 0;
}
.merge-body {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.merge-form {
flex: 1 1 440px;
max-width: 580px;
}
.merge-hint {
margin-left: 12px;
font-size: 12.5px;
color: var(--mg-text-muted);
}
.merge-order {
flex: 0 1 280px;
min-width: 220px;
padding: 12px 16px;
border-radius: var(--mg-radius);
background: rgba(var(--mg-accent-rgb), 0.08);
border: 1px solid var(--mg-veil-border);
}
.order-title {
font-size: 13px;
font-weight: 600;
color: var(--mg-text-light);
margin-bottom: 10px;
}
.order-list {
margin: 0;
padding-left: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.order-item {
display: flex;
align-items: center;
gap: 8px;
}
.order-name {
color: var(--mg-text-light);
font-size: 13px;
word-break: break-all;
}
</style>