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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:37:07 +08:00

601 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-shell">
<div class="bg-orbs" aria-hidden="true">
<div class="bg-orb bg-orb-a" />
<div class="bg-orb bg-orb-b" />
<div class="bg-orb bg-orb-c" />
<div class="bg-orb bg-orb-d" />
</div>
<el-container class="app-container">
<el-aside :width="ui.sidebarCollapsed ? '76px' : '240px'" class="aside">
<div class="logo" :class="{ collapsed: ui.sidebarCollapsed }">
<template v-if="ui.sidebarCollapsed">
<img src="/FRLD-logo-white-no_title.png" alt="FRLD" class="logo-img-small" />
<span class="logo-abbr">迷毂</span>
</template>
<template v-else>
<img src="/FRLD-logo-white.png" alt="FRLD" class="logo-img-full" />
<div class="logo-titles">
<span class="logo-title-zh"> · 智能调度平台</span>
<span class="logo-title-en">
<b>M</b>i <b>G</b>u · <b>I</b>ntelligent <b>D</b>ispatch <b>P</b>latform
</span>
</div>
</template>
</div>
<el-menu
:default-active="activePath"
:collapse="ui.sidebarCollapsed"
:collapse-transition="false"
background-color="transparent"
text-color="rgba(255, 255, 255, 0.72)"
active-text-color="#fff"
router
class="menu">
<template v-for="m in currentMenu" :key="m.path">
<el-sub-menu v-if="m.children" :index="m.path">
<template #title>
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.label }}</span>
</template>
<el-menu-item v-for="c in m.children" :key="c.path" :index="c.path">
<span>{{ c.label }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<template #title>{{ m.label }}</template>
</el-menu-item>
</template>
</el-menu>
<div class="aside-footer">
<span class="aside-footer-badge">v1.6</span>
<span v-if="!ui.sidebarCollapsed">{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
</div>
</el-aside>
<el-container class="container-right">
<el-header class="header">
<div class="header-left">
<button class="icon-btn" @click="ui.toggleSidebar()" title="切换侧栏">
<el-icon size="18"><Fold v-if="!ui.sidebarCollapsed" /><Expand v-else /></el-icon>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ scopeLabel }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<ThemeSwitcher />
<ScopeSwitcher />
<span class="runmode-tag" :class="auth.runMode">{{ runModeLabel }}</span>
<el-dropdown trigger="click" @command="onUserCommand">
<span class="user-link">
<el-avatar size="small">{{ initial }}</el-avatar>
<span class="user-name">{{ auth.user?.displayName ?? '未登录' }}</span>
<el-icon><CaretBottom /></el-icon>
</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>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main mg-content">
<!-- 不用 mode="out-in"懒加载子路由切换时易出现离场后新组件未挂载」→ 主区全白刷新才恢复 -->
<router-view v-slot="{ Component, route: rv }">
<component :is="Component" v-if="Component" :key="rv.fullPath" class="route-page" />
</router-view>
</el-main>
<el-footer class="footer">
<span>{{ scopeLabel }} · webVRender :{{ vrPort }} · API {{ apiBase }}</span>
</el-footer>
</el-container>
</el-container>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
MapLocation, Van, Promotion, Box, OfficeBuilding, Notebook
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import ScopeSwitcher from '@/components/ScopeSwitcher.vue'
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const ui = useUiStore()
const vrPort = computed(() => (import.meta.env.VITE_VRENDER_HOST as string | undefined)?.split(':')[1] ?? '8223')
const apiBase = computed(() => (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api')
const initial = computed(() => (auth.user?.displayName ?? auth.user?.username ?? '?').slice(0, 1).toUpperCase())
const scopeLabel = computed(() => (auth.scope === 'Platform' ? '管理员 (Platform)' : '运营 (RCSMonitor)'))
const runModeLabel = computed(() => {
switch (auth.runMode) {
case 'WebOnly': return 'Web-Only'
case 'WebEnabled': return 'Web + Desktop'
case 'Detached': return 'SimpleLite 未连接'
default: return String(auth.runMode ?? 'Unknown')
}
})
// key 即「权限页面 Key」(= vue-router route.name),用于按 auth.allowedPages 过滤菜单。
// 分组节点(无 key)只要还有可见子项就保留。
interface MenuItem { path: string; label: string; icon?: unknown; key?: string; children?: MenuItem[] }
const ADMIN_MENU: MenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard' },
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor' },
{
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' },
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
{ path: '/admin/missions', label: '任务编排', key: 'admin-missions' }
]
},
{ path: '/admin/playback', label: '调度回放', icon: Notebook, key: 'admin-playback' },
{
path: '/admin/config', label: '平台配置中心', icon: Setting,
children: [
{ path: '/admin/config/system', label: '系统级配置', key: 'admin-config-system' },
{ path: '/admin/config/integrations', label: '外部系统对接', key: 'admin-config-integrations' },
{ path: '/admin/config/routing', label: '路径规划', key: 'admin-config-routing' },
{ path: '/admin/config/vehicle-hub', label: '车辆运维', key: 'admin-vehicle-hub' },
{ path: '/admin/config/charge', label: '充电策略', key: 'admin-config-charge' },
{ path: '/admin/config/task', label: '任务分配', key: 'admin-config-task' },
{ path: '/admin/config/traffic', label: '交通管制', key: 'admin-config-traffic' },
{ path: '/admin/config/auth', label: '权限与角色', key: 'admin-config-auth' },
{ path: '/admin/config/device', label: '设备接入', key: 'admin-config-device' },
{ path: '/admin/config/scenario', label: '场景模板', key: 'admin-config-scenario' },
{ path: '/admin/config/location', label: '库位管理', key: 'admin-config-location' },
{ path: '/admin/config/ops', label: '运营维护', key: 'admin-config-ops' },
{ path: '/admin/config/widget', label: '自定义控件', key: 'admin-config-widget' }
]
}
]
const MONITOR_MENU: MenuItem[] = [
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard' },
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub' },
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map' },
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops' },
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes' }
]
function filterMenu(items: MenuItem[]): MenuItem[] {
const out: MenuItem[] = []
for (const m of items) {
if (m.children) {
const kids = m.children.filter((c) => !c.key || auth.hasPage(c.key))
if (kids.length) out.push({ ...m, children: kids })
} else if (!m.key || auth.hasPage(m.key)) {
out.push(m)
}
}
return out
}
const currentMenu = computed<MenuItem[]>(() => filterMenu(auth.scope === 'Platform' ? ADMIN_MENU : MONITOR_MENU))
const activePath = computed(() => {
const path = route.path
const flat: MenuItem[] = []
const walk = (items: MenuItem[]) => {
items.forEach((i) => {
flat.push(i)
if (i.children) walk(i.children)
})
}
walk(currentMenu.value)
const hit = flat.find((i) => i.path === path)
return hit?.path ?? path
})
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
void Van; void Box; void OfficeBuilding
function onUserCommand(cmd: string) {
if (cmd === 'logout') {
auth.logout()
router.push('/login')
} else if (cmd === 'status') {
router.push('/status')
} else if (cmd === 'wizard') {
router.push('/wizard')
}
}
</script>
<style scoped>
/* ─────────────── App shell ─────────────── */
.app-shell {
position: relative;
height: 100vh;
overflow: hidden;
}
.bg-orbs {
position: absolute; inset: 0; pointer-events: none; z-index: 0;
}
.bg-orb {
position: absolute; border-radius: 50%;
filter: blur(120px);
animation: drift 24s ease-in-out infinite;
will-change: transform;
}
.bg-orb-a {
width: 520px; height: 520px;
top: -240px; left: -220px;
background: radial-gradient(circle, var(--mg-primary-hover) 0%, var(--mg-primary) 55%, transparent 85%);
opacity: var(--mg-orb-a-opacity, 0.55);
}
.bg-orb-b {
width: 640px; height: 640px;
bottom: -280px; right: -260px;
background: radial-gradient(circle, var(--mg-primary) 0%, var(--mg-primary-active) 55%, transparent 90%);
opacity: var(--mg-orb-b-opacity, 0.50);
animation-delay: -7s;
}
.bg-orb-c {
width: 320px; height: 320px;
top: 58%; right: -140px;
background: radial-gradient(circle, var(--mg-accent) 0%, var(--mg-primary-hover) 55%, transparent 95%);
opacity: var(--mg-orb-c-opacity, 0.40);
animation-delay: -14s;
}
.bg-orb-d {
width: 380px; height: 380px;
top: 12%; right: 28%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.85) 0%, var(--mg-primary) 60%, transparent 92%);
opacity: 0.30;
animation-delay: -18s;
filter: blur(140px);
}
@keyframes drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(36px, -42px) scale(1.08); }
}
.app-container {
position: relative;
z-index: 1;
height: 100vh;
}
/* ─────────────── Sidebar ─────────────── */
.aside {
position: relative;
background: linear-gradient(180deg, rgba(var(--mg-bg-aside-rgb), 0.78) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.85) 100%);
backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%);
border-right: 1px solid rgba(var(--mg-accent-rgb), 0.18);
display: flex;
flex-direction: column;
transition: width .28s cubic-bezier(.25, .8, .25, 1);
overflow: hidden;
flex-shrink: 0;
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.04),
4px 0 32px rgba(0, 0, 0, 0.45);
}
.aside::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 1px; height: 100%;
background: linear-gradient(180deg,
transparent 0%,
rgba(var(--mg-accent-rgb), 0.6) 50%,
transparent 100%);
pointer-events: none;
opacity: 0.4;
}
.logo {
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 22px 14px 18px;
border-bottom: 1px solid rgba(var(--mg-accent-rgb), 0.16);
background:
radial-gradient(ellipse at center top, rgba(var(--mg-primary-hover-rgb), 0.30) 0%, transparent 70%),
linear-gradient(180deg, rgba(var(--mg-primary-rgb), 0.16) 0%, transparent 100%);
}
.logo.collapsed { padding: 18px 4px 14px; }
.logo-img-full {
height: 36px;
margin-bottom: 12px;
filter: drop-shadow(0 2px 10px rgba(var(--mg-accent-rgb), 0.5));
}
.logo-img-small {
height: 26px;
margin-bottom: 4px;
filter: drop-shadow(0 2px 8px rgba(var(--mg-accent-rgb), 0.5));
}
.logo-titles { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.logo-title-zh {
font-size: 13px; font-weight: 700;
letter-spacing: 2.5px;
background: linear-gradient(90deg, #ffffff 0%, var(--mg-accent) 60%, #ffffff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
white-space: nowrap;
text-shadow: 0 0 18px rgba(var(--mg-accent-rgb), 0.3);
}
.logo-title-en {
color: rgba(232, 215, 245, 0.45);
font-size: 7.5px;
letter-spacing: 0.3px;
white-space: nowrap;
text-align: center;
}
.logo-title-en b { color: rgba(255, 255, 255, 0.9); font-weight: 700; font-size: 8.5px; }
.logo-abbr {
color: rgba(255, 255, 255, 0.85);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 1.5px;
}
/* Menu */
.menu {
flex: 1; overflow-y: auto; overflow-x: hidden;
border-right: none !important;
padding: 8px 0;
scrollbar-width: thin;
scrollbar-color: rgba(var(--mg-accent-rgb), 0.30) transparent;
}
.menu::-webkit-scrollbar { width: 4px; }
.menu::-webkit-scrollbar-thumb {
background: rgba(var(--mg-accent-rgb), 0.25);
border-radius: 2px;
}
.menu::-webkit-scrollbar-thumb:hover {
background: rgba(var(--mg-accent-rgb), 0.55);
}
:deep(.el-menu) { border-right: none; background: transparent !important; }
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
background: transparent !important;
margin: 2px 10px;
border-radius: 10px;
height: 42px;
line-height: 42px;
transition: background .2s ease, color .2s ease, box-shadow .2s ease;
}
:deep(.el-menu--collapse .el-menu-item),
:deep(.el-menu--collapse .el-sub-menu__title) {
margin: 2px 8px;
padding: 0 !important;
justify-content: center;
}
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background: rgba(var(--mg-primary-rgb), 0.20) !important;
color: #fff !important;
box-shadow: inset 0 0 0 1px rgba(var(--mg-accent-rgb), 0.18);
}
/* active 菜单:减噪版本(单一 left-border + 一层柔和 bg + 字重 600,不再叠 5 层 shadow */
:deep(.el-menu-item.is-active) {
background: linear-gradient(90deg,
rgba(var(--mg-primary-rgb), 0.55) 0%,
rgba(var(--mg-primary-hover-rgb), 0.20) 70%,
transparent 100%) !important;
color: #fff !important;
font-weight: 600;
box-shadow:
inset 3px 0 0 var(--mg-accent),
0 4px 14px rgba(var(--mg-primary-rgb), 0.25);
}
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: #fff !important;
font-weight: 600;
}
:deep(.el-sub-menu .el-menu) {
background: rgba(0, 0, 0, 0.18) !important;
border-radius: 8px;
margin: 0 12px;
padding: 4px 0;
}
:deep(.el-sub-menu .el-menu .el-menu-item) {
margin: 1px 4px;
border-radius: 6px;
font-size: 13px;
height: 36px;
line-height: 36px;
}
:deep(.el-sub-menu .el-menu .el-menu-item.is-active) {
background: rgba(var(--mg-accent-rgb), 0.18) !important;
box-shadow: inset 2px 0 0 var(--mg-primary-hover);
color: #fff !important;
}
/* Sidebar footer */
.aside-footer {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid rgba(var(--mg-accent-rgb), 0.14);
display: flex; align-items: center; gap: 8px;
font-size: 10.5px;
color: rgba(255, 255, 255, 0.55);
letter-spacing: 0.4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aside-footer-badge {
padding: 2px 7px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(var(--mg-primary-rgb), 0.35), rgba(var(--mg-accent-rgb), 0.20));
border: 1px solid rgba(var(--mg-accent-rgb), 0.36);
color: #fff;
font-weight: 600;
font-size: 10px;
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
box-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.35);
}
/* ─────────────── Header ─────────────── */
.container-right {
background: transparent;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.header {
position: relative;
display: flex; justify-content: space-between; align-items: center;
background: linear-gradient(180deg,
rgba(var(--mg-bg-aside-rgb), 0.72) 0%,
rgba(var(--mg-bg-aside-rgb), 0.55) 100%);
backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%);
border-bottom: 1px solid rgba(var(--mg-accent-rgb), 0.18);
padding: 0 20px;
}
.header::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--mg-accent-rgb), 0.45) 30%,
rgba(var(--mg-primary-hover-rgb), 0.6) 50%,
rgba(var(--mg-accent-rgb), 0.45) 70%,
transparent 100%);
opacity: 0.55;
}
.header-left, .header-right { display: flex; align-items: center; gap: 14px; }
.icon-btn {
appearance: none;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.75);
border-radius: 8px;
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all .2s;
}
.icon-btn:hover {
background: rgba(var(--mg-accent-rgb), 0.18);
border-color: rgba(var(--mg-accent-rgb), 0.5);
color: #fff;
}
:deep(.el-breadcrumb__inner) { color: rgba(255, 255, 255, 0.78); font-weight: 500; }
:deep(.el-breadcrumb__separator) { color: rgba(255, 255, 255, 0.35); }
:deep(.el-breadcrumb__item:last-child .el-breadcrumb__inner) { color: #fff; font-weight: 600; }
.runmode-tag {
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
letter-spacing: 0.5px;
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.85);
}
.runmode-tag.WebEnabled {
background: rgba(var(--mg-status-success-rgb), 0.18);
border-color: rgba(var(--mg-status-success-rgb), 0.45);
color: var(--mg-status-success);
}
.runmode-tag.WebOnly {
background: rgba(var(--mg-primary-hover-rgb), 0.18);
border-color: rgba(var(--mg-primary-hover-rgb), 0.45);
color: var(--mg-accent);
}
/* 会话 N+1 增量:SimpleLite 未拉起时的降级展示,用警示色而非成功色,引导用户感知"实际只有 Platform"。 */
.runmode-tag.Detached {
background: rgba(var(--mg-status-warning-rgb), 0.18);
border-color: rgba(var(--mg-status-warning-rgb), 0.45);
color: var(--mg-status-warning);
}
.user-link {
display: inline-flex; align-items: center; gap: 8px;
cursor: pointer; color: rgba(255, 255, 255, 0.85);
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
transition: all .2s;
}
.user-link:hover {
background: rgba(var(--mg-accent-rgb), 0.18);
border-color: rgba(var(--mg-accent-rgb), 0.4);
}
.user-name { font-size: 13px; color: #fff; font-weight: 500; }
:deep(.el-avatar) {
background: linear-gradient(135deg, var(--mg-accent) 0%, var(--mg-primary) 60%, var(--mg-primary-active) 100%);
color: #fff;
font-weight: 700;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2);
}
/* ─────────────── Main / Footer ─────────────── */
.main {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
padding: 24px 24px;
background: transparent;
color: #fff;
}
.footer {
background: rgba(var(--mg-bg-aside-rgb), 0.25);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
display: flex; align-items: center; justify-content: center;
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0.3px;
height: 32px !important; line-height: 32px;
}
.route-page {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
</style>