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:
@@ -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>
|
||||
Reference in New Issue
Block a user