feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.cache
|
||||
@@ -0,0 +1,4 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
shamefully-hoist=false
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
@@ -0,0 +1,157 @@
|
||||
# 迷毂 · 前端 (frontends/)
|
||||
|
||||
> 产品代号 **迷毂**(Mí Gǔ),全称「迷毂 · 智能调度平台」。
|
||||
> 单一 Vue 工程 `simple-platform-vue` 承载 admin(管理员 / Platform)与 monitor(运营 / RCSMonitor)双视图;
|
||||
> 通过路由前缀 `/admin/*` 与 `/monitor/*` 切换,后续可按 ARCHITECTURE.md §13 拆分为独立工程。
|
||||
>
|
||||
> 落地范围与依据:本目录是 [ARCHITECTURE.md](../ARCHITECTURE.md) v1.7 §6/§9/§13/§17/§18 中前端形态的初版骨架;
|
||||
> 真实业务接入由 `Platform.Server`(同仓库新增的 .NET 8 工程)反代 SimpleLite 完成。
|
||||
>
|
||||
> 品牌主色:默认 **工业紫** `#6a1b9a`(6 套主题可切换),详见 ARCHITECTURE.md §17.7。
|
||||
> 工程包名 `simple-platform-vue` / 仓库目录 `frontends/apps/simple-platform-vue` 保持不动,避免破坏 pnpm/CI 脚本,仅用户可见文案统一为「迷毂」。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
frontends/
|
||||
├── package.json # workspace 根
|
||||
├── pnpm-workspace.yaml # pnpm monorepo 占位(当前只有一个 app)
|
||||
└── apps/
|
||||
└── simple-platform-vue/
|
||||
├── package.json
|
||||
├── vite.config.ts # base=/ ;dev 时 /api 代理到 http://127.0.0.1:8080
|
||||
├── index.html
|
||||
├── env.d.ts
|
||||
├── .env.development
|
||||
├── public/
|
||||
│ ├── favicon.svg # 紫色渐变方块 + 「迷」字
|
||||
│ └── login-bg.jpg # 登录页背景图 (1.8MB,来源 C:\Tool\wallpapper\wall_Beach.jpg)
|
||||
└── src/
|
||||
├── main.ts # Pinia + applyCurrentTheme() + Element Plus
|
||||
├── App.vue
|
||||
├── styles/
|
||||
│ ├── theme.css # --mg-* 玻璃令牌 + Element Plus 覆盖
|
||||
│ └── themes.ts # 6 套主题预设 + applyThemeVars()
|
||||
├── router/index.ts # /login /status /admin/* /monitor/* (title base="迷毂")
|
||||
├── stores/ {auth, config, ui}.ts
|
||||
├── api/ {http, auth, config, projection, ops}.ts
|
||||
├── layouts/{AppShell, BlankLayout}.vue # 紫色渐变侧边栏 / 紫色三段渐变背景
|
||||
├── components/
|
||||
│ ├── Workspace3D.vue # ★ iframe 包 http://localhost:8223
|
||||
│ ├── ThemeSwitcher.vue # 顶栏主题下拉(§17.7)
|
||||
│ ├── ScopeSwitcher.vue
|
||||
│ ├── PermissionGuard.vue
|
||||
│ ├── DataTablePro.vue
|
||||
│ └── ConfigPageBase.vue
|
||||
├── views/
|
||||
│ ├── LoginView.vue
|
||||
│ ├── ServiceStatusView.vue # 复刻 §3.4 状态窗
|
||||
│ ├── admin/
|
||||
│ │ ├── DashboardView.vue
|
||||
│ │ ├── MapMonitorView.vue # ★ 地图监控页(iframe 8223 + 实时车辆侧栏)
|
||||
│ │ ├── MapEditorView.vue / TrackTableView.vue
|
||||
│ │ ├── CarPanelView.vue / MissionEditorView.vue
|
||||
│ │ ├── CadToolbarView.vue / PlaybackView.vue
|
||||
│ │ └── config/ # 14 个配置中心页(§9)
|
||||
│ └── monitor/
|
||||
│ ├── MonitorDashboardView.vue
|
||||
│ ├── MonitorMapView.vue # ★ 只读 iframe 8223 + 运维白名单
|
||||
│ ├── OpsActionPanelView.vue
|
||||
│ └── AnnotationView.vue
|
||||
├── types/ {auth, map, car, mission, ops, config}.ts
|
||||
└── mock/ {server.ts, data/*.ts}
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```pwsh
|
||||
# 1) 安装依赖(首次)
|
||||
cd frontends
|
||||
pnpm install
|
||||
|
||||
# 2) 启动 Vite 开发服务器(默认 :5173)
|
||||
pnpm dev
|
||||
|
||||
# 3) 打开浏览器
|
||||
# http://localhost:5173/login
|
||||
# 任意非空用户名 + 任意密码即可登录(鉴权全为 Mock)
|
||||
# scope=Platform → /admin/map-monitor (★ 地图监控页)
|
||||
# scope=RCSMonitor → /monitor/map (★ 只读地图监控页)
|
||||
```
|
||||
|
||||
## 地图监控页与 webVRender
|
||||
|
||||
地图监控页(`/admin/map-monitor` 与 `/monitor/map`)通过 `<Workspace3D>` 组件以 iframe 嵌入
|
||||
SimpleLite WebTerminal 提供的 webVRender,默认 URL:
|
||||
|
||||
```
|
||||
http://localhost:8223/?scope=Platform&token=<jwt>&ro=0 # 管理员,可交互
|
||||
http://localhost:8223/?scope=RCSMonitor&token=<jwt>&ro=1 # 运营,只读
|
||||
```
|
||||
|
||||
可通过 `.env.development` 中的 `VITE_VRENDER_HOST` 覆盖默认主机,或直接给组件传 `host` 属性。
|
||||
|
||||
### iframe 嵌入注意事项(CORS / X-Frame-Options)
|
||||
|
||||
- 当前 SimpleLite WebTerminal 暂未声明 `X-Frame-Options`/`Content-Security-Policy: frame-ancestors`,
|
||||
浏览器多数情况下会按默认策略放行同源 / 局域网嵌入。
|
||||
- 若浏览器控制台报「Refused to display 'http://localhost:8223/' in a frame」,请在 SimpleLite WebTerminal
|
||||
侧加上 `X-Frame-Options: ALLOWALL` 或 `Content-Security-Policy: frame-ancestors *`(本轮代码未改动 SimpleLite)。
|
||||
- 若需通过 `Platform.Server` 走同源代理,可以让 iframe 指向 `http://localhost:8080/vr/`,由 YARP
|
||||
转发到 `http://127.0.0.1:8223/`(见 `Platform.Server/appsettings.json` 的 `vrender-route`)。
|
||||
|
||||
## 与后端联调
|
||||
|
||||
- Vite dev 模式下,所有 `/api/*` 请求经 Vite proxy 转到 `http://127.0.0.1:8080`,由 `Platform.Server` 处理:
|
||||
- `/api/auth/login` → `Platform.Server` `AuthController`(Mock JWT)
|
||||
- `/api/config/{section}` → `Platform.Server` `ConfigController`(14 维度占位)
|
||||
- `/api/health` → `Platform.Server` `HealthController`
|
||||
- `/api/projection/*` → `Platform.Server` `ProjectionController`(占位)
|
||||
- `/api/sl/*` → YARP 反代 SimpleLite WebAPI `http://127.0.0.1:8222/`
|
||||
- `/api/sl/ops/execute` 与 `/api/sl/ops/audits` → `Platform.Server` `OpsController`(白名单网关占位)
|
||||
|
||||
### Mock 开关:`VITE_USE_MOCK`
|
||||
|
||||
`api/auth.ts`、`api/config.ts`、`api/ops.ts` 读取 `import.meta.env.VITE_USE_MOCK`:
|
||||
|
||||
| 环境 | 文件 | 默认值 | 行为 |
|
||||
|---------------|---------------------|--------|---------------------------------------------------|
|
||||
| dev (`pnpm dev`) | `.env.development` | `true` | 走 `mock/server.ts`,**无后端也能跑通 UI** |
|
||||
| prod (`pnpm build`) | `.env.production` | `false` | 强制打到 Platform.Server,没启后端会失败(这是期望行为)|
|
||||
|
||||
要在 dev 环境联调真实后端,把 `.env.development` 里的 `VITE_USE_MOCK` 注释掉或改为 `false` 即可。
|
||||
不要再像旧版那样改源码里的 `const MOCK = true` —— 那条常量已经下线,统一由 env 控制。
|
||||
|
||||
> 其他 `api/*.ts`(如 `reflection.ts`、`projection.ts`、`workbench.ts`)从一开始就直连真实 API,
|
||||
> 不受 `VITE_USE_MOCK` 影响;它们后端必到位才能用。
|
||||
|
||||
## 生产构建
|
||||
|
||||
```pwsh
|
||||
# 在 frontends 目录
|
||||
pnpm --filter simple-platform-vue build # 产出 apps/simple-platform-vue/dist/
|
||||
|
||||
# 部署到 Platform.Server (一次拷贝,单一合并工程)
|
||||
# Windows / PowerShell
|
||||
Remove-Item ..\Platform.Server\wwwroot\* -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item apps\simple-platform-vue\dist\* ..\Platform.Server\wwwroot\ -Recurse
|
||||
```
|
||||
|
||||
部署完成后访问 `http://localhost:8080/login`、`/admin/dashboard`、`/admin/map-monitor`、`/monitor/map`,所有非 API 路径都会回退到 `wwwroot/index.html`,由 vue-router 内部解析。
|
||||
|
||||
> 已通过冒烟验证:`pnpm build` 通过 vue-tsc 严格类型检查 + Vite 生产构建(2412 模块、44 chunk),`Platform.Server` 启动后 `/`、`/admin/map-monitor`、`/monitor/map`、`/api/health`、`/api/config/system` 与 `/assets/index-*.js` 均返回 200。
|
||||
|
||||
## 品牌与主题(v1.6.1 对齐 FRLD / ddms)
|
||||
|
||||
- 产品名「**迷毂**」全局可见文案:登录窗标题/副标题、`<title>`、侧边栏顶部、favicon。
|
||||
- 母公司 logo:`public/FRLD-logo-white.png`(11.5 KB 展开态)+ `public/FRLD-logo-white-no_title.png`(3.5 KB 折叠态)。来源 `E:\ddms\frontend\public`,与法睿兰达 FAIRYLAND 运营管理引擎(FAME / ddms)共用同一品牌资产。
|
||||
- 中英双标题:中文 `迷 毂 · 智能调度平台`、英文 `Mi Gu · Intelligent Dispatch Platform`(首字母加粗)。
|
||||
- 品牌主色 `#7c3aed`(Tailwind violet-600):通过 `src/styles/theme.css` 覆盖 Element Plus 的 `--el-color-primary` 与 `--el-color-primary-rgb`(双重 `:root` + `html !important` 兜底)。配套 `--mg-primary-{50..950}` 阶梯、`--mg-accent #a855f7`、`--mg-bg-aside-{1,2}` 侧栏深紫渐变。
|
||||
- 登录卡:单列 460px,背景 `linear-gradient(135deg, #2d1b69 0%, #7c3aed 100%)`(ddms 同款),透明输入 + 白底紫字大按钮 + 双卡 scope 选择器。
|
||||
- 侧边栏顶端 logo 块:FRLD logo 38px + 中英双标题居中(折叠时变 28px logo + 「迷毂」缩写)。
|
||||
- 完整视觉规范与色卡见 [`../ARCHITECTURE.md` §17](../ARCHITECTURE.md#17-视觉规范v16-新增v161-对齐-frld)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [../ARCHITECTURE.md](../ARCHITECTURE.md) — v1.6 总体架构(§3 启动登录、§5 三端职责、§6 共享内核、§9 配置中心、§10 关键交互、§17 视觉规范)
|
||||
- [../Platform.Server/README.md](../Platform.Server/README.md) — 后端骨架启动说明
|
||||
@@ -0,0 +1,5 @@
|
||||
VITE_API_BASE=/api
|
||||
VITE_VRENDER_HOST=localhost:8223
|
||||
# dev 默认走前端 mock,方便无后端时直接 pnpm dev 调 UI;
|
||||
# 想接 Platform.Server 真实接口时把下一行注释掉或改成 false。
|
||||
VITE_USE_MOCK=true
|
||||
@@ -0,0 +1,6 @@
|
||||
# 生产构建(pnpm build / build-platform-frontend.bat)默认值。
|
||||
# - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。
|
||||
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
|
||||
VITE_API_BASE=/api
|
||||
VITE_VRENDER_HOST=localhost:8223
|
||||
VITE_USE_MOCK=false
|
||||
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AiGenerateDialog: typeof import('./src/components/map-editor/AiGenerateDialog.vue')['default']
|
||||
ConfigPageBase: typeof import('./src/components/ConfigPageBase.vue')['default']
|
||||
DataTablePro: typeof import('./src/components/DataTablePro.vue')['default']
|
||||
EditPropertyPanel: typeof import('./src/components/map-editor/EditPropertyPanel.vue')['default']
|
||||
EditStatusBar: typeof import('./src/components/map-editor/EditStatusBar.vue')['default']
|
||||
EditToolRail: typeof import('./src/components/map-editor/EditToolRail.vue')['default']
|
||||
EditTopBar: typeof import('./src/components/map-editor/EditTopBar.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default']
|
||||
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
|
||||
FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default']
|
||||
MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default']
|
||||
MonitorSelectionPanel: typeof import('./src/components/workbench/MonitorSelectionPanel.vue')['default']
|
||||
PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default']
|
||||
PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default']
|
||||
ProjectBrowseDialog: typeof import('./src/components/map-editor/ProjectBrowseDialog.vue')['default']
|
||||
ReflectionKindTable: typeof import('./src/components/orchestration/ReflectionKindTable.vue')['default']
|
||||
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScopeSwitcher: typeof import('./src/components/ScopeSwitcher.vue')['default']
|
||||
SelectionDetailPanel: typeof import('./src/components/workbench/SelectionDetailPanel.vue')['default']
|
||||
SitePickDialog: typeof import('./src/components/workbench/SitePickDialog.vue')['default']
|
||||
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
|
||||
ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default']
|
||||
VehicleMonitorPanel: typeof import('./src/components/workbench/VehicleMonitorPanel.vue')['default']
|
||||
WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default']
|
||||
Workspace3D: typeof import('./src/components/Workspace3D.vue')['default']
|
||||
WorkspaceCanvasToolbar: typeof import('./src/components/workspace/WorkspaceCanvasToolbar.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
# 迷毂智能调度平台 · 设计系统 (MASTER)
|
||||
|
||||
> 由 ui-ux-pro-max skill 推导生成 (v1)。本文档是项目的**单一设计真理**。
|
||||
> 页面级覆盖请放在 `design-system/pages/<page-name>.md`,覆盖优先级高于本 MASTER。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品定位 (Product Identity)
|
||||
|
||||
| 维度 | 取值 |
|
||||
|---|---|
|
||||
| **产品类型** | B2B 工业 SaaS · AGV 智能调度平台 · 实时监控仪表板 |
|
||||
| **行业** | 智能制造 / 物流自动化 / 仓储机器人 |
|
||||
| **匹配 reasoning rule** | `Autonomous Drone Fleet` (id=97) 主匹配 / `Logistics-Delivery` (id=25) + `Smart Home/IoT Dashboard` (id=54) + `SaaS Dashboard` (id=19) 辅助 |
|
||||
| **目标用户** | 现场调度员、运维工程师、车队管理员、平台管理员 |
|
||||
| **使用环境** | 桌面端为主 (1920×1080 / 2560×1440)、长时间在线、信息密集 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计模式与风格 (Pattern & Style)
|
||||
|
||||
| 维度 | 主选 | 辅助 |
|
||||
|---|---|---|
|
||||
| **Landing/页面模式** | Real-Time + Feature-Rich | Bento Grid (主体布局) |
|
||||
| **核心 UI 风格** | Real-Time Monitoring + Data-Dense Dashboard | Glassmorphism (卡片层级) + Dimensional Layering (z-轴层级) |
|
||||
| **可选风格元素** | HUD/Sci-Fi FUI 微元素 (装饰用,不主导) | — |
|
||||
|
||||
### Real-Time Monitoring 必备特征
|
||||
- 实时状态指示器(脉动 dot / pulsing badge)
|
||||
- 流式图表(数值平滑过渡而非跳变)
|
||||
- 告警通知层级(critical → warning → info)
|
||||
- 连接状态/自动刷新指示
|
||||
- 数值用 `font-variant-numeric: tabular-nums` 防抖动
|
||||
|
||||
### Data-Dense Dashboard 必备特征
|
||||
- 多 widget 并排(KPI 行 + 主图 + 列表)
|
||||
- 紧凑 padding(卡片 12-18px、KPI 项 10-12px)
|
||||
- 高效栅格布局(CSS Grid)
|
||||
- 表头常驻 hover tooltip
|
||||
- 列表行 hover 高亮 + 平滑过渡
|
||||
|
||||
---
|
||||
|
||||
## 3. 颜色系统 (Color System)
|
||||
|
||||
### 3.1 状态色(项目级强约束 · 跨主题保持语义)
|
||||
|
||||
> **关键原则**:状态色与品牌色解耦。不要把品牌紫色当 "执行中"、把品牌橙色当 "警告"。
|
||||
|
||||
| Token | 浅色主题 (light) | 深色主题 (dark) | 语义 |
|
||||
|---|---|---|---|
|
||||
| `--mg-status-success` | `#16A34A` | `#22C55E` | 正常 / 成功 / 已完成 |
|
||||
| `--mg-status-warning` | `#D97706` | `#F59E0B` | 异常 / 警告 / 等待人工 |
|
||||
| `--mg-status-danger` | `#DC2626` | `#EF4444` | 严重故障 / critical / 阻断 |
|
||||
| `--mg-status-info` | `#2563EB` | `#3B82F6` | 信息 / 进行中 / 通信 |
|
||||
| `--mg-status-idle` | `#64748B` | `#94A3B8` | 待执行 / 已暂停 / 静默 |
|
||||
|
||||
### 3.2 数据可视化色板 (ECharts / Progress / Chart)
|
||||
|
||||
按"冷色→暖色"递进,避免单一色相被多 series 共用:
|
||||
|
||||
```
|
||||
'#3B82F6' 蓝 优先级 1
|
||||
'#22C55E' 绿 优先级 2
|
||||
'#A855F7' 紫 优先级 3 (品牌色复用)
|
||||
'#F59E0B' 琥珀 优先级 4
|
||||
'#EF4444' 红 优先级 5 (告警语义保留时慎用)
|
||||
'#06B6D4' 青 优先级 6
|
||||
'#EC4899' 粉 优先级 7
|
||||
'#84CC16' 黄绿 优先级 8
|
||||
```
|
||||
|
||||
### 3.3 品牌主色(保留现有 7 套主题)
|
||||
|
||||
当前默认 `fame-lavender`(浅紫白主区 + 深紫侧栏)保持不变,所有变量在 `themes.ts` 中维护。
|
||||
新增的状态色变量在每个主题预设里要补齐。
|
||||
|
||||
### 3.4 文字与对比度
|
||||
|
||||
| Context | 浅色主题 | 深色主题 |
|
||||
|---|---|---|
|
||||
| `text-light` (主文字) | `#0F172A` (slate-900) | `#FFFFFF` |
|
||||
| `text-muted` (次要) | `#475569` (slate-600 ≥ 4.5:1) | `rgba(255,255,255,0.72)` |
|
||||
| `text-dim` (装饰) | `#94A3B8` | `rgba(255,255,255,0.50)` |
|
||||
| `text-faint` (占位) | `#CBD5E1` | `rgba(255,255,255,0.35)` |
|
||||
|
||||
> ⚠ 不要用 `slate-400 (#94A3B8)` 做正文 (light mode) ——对比度不够。
|
||||
|
||||
---
|
||||
|
||||
## 4. 字体 (Typography)
|
||||
|
||||
> 选自 `Minimal Swiss` (Inter) + `Developer Mono` (JetBrains Mono) 组合,Dashboard Data 类目最佳实践。
|
||||
|
||||
| 用途 | 字体栈 | 备注 |
|
||||
|---|---|---|
|
||||
| **UI 主字体** | `'PingFang SC', 'Microsoft YaHei', Inter, -apple-system, 'Segoe UI', sans-serif` | 中文优先,英文 fallback Inter |
|
||||
| **数据数字** (KPI/坐标/ID) | `'JetBrains Mono', 'Cascadia Mono', Menlo, Consolas, monospace` | 等宽,`font-variant-numeric: tabular-nums` |
|
||||
| **代码/JSON** | 同数据数字 | — |
|
||||
|
||||
### 字号分级
|
||||
|
||||
| Token | 用途 | 字号 |
|
||||
|---|---|---|
|
||||
| `text-xs` | 标签/时间戳 | 11-12px |
|
||||
| `text-sm` | 次要说明 | 12.5-13px |
|
||||
| `text-base` | 正文/列表 | 14px |
|
||||
| `text-md` | 卡片标题 | 15-16px |
|
||||
| `text-lg` | 模块标题 | 18-20px |
|
||||
| `text-xl` | 页面标题 | 24-28px |
|
||||
| `text-2xl` | KPI 数值 | 28-32px |
|
||||
| `text-hero` | Hero 标题 | 36-44px |
|
||||
|
||||
---
|
||||
|
||||
## 5. 间距与圆角 (Spacing & Radius)
|
||||
|
||||
### 5.1 间距尺度 (8pt 网格)
|
||||
|
||||
`4 / 8 / 12 / 16 / 18 / 24 / 32 / 48 / 64`
|
||||
|
||||
### 5.2 圆角
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--mg-radius-sm` | 8px | 小标签、按钮、tag |
|
||||
| `--mg-radius` | 14px | 卡片、面板 |
|
||||
| `--mg-radius-lg` | 20px | Hero 区、对话框 |
|
||||
| `--mg-radius-xl` | 24px | 全屏覆盖、巨型卡片 |
|
||||
|
||||
### 5.3 卡片层级 (Dimensional Layering)
|
||||
|
||||
```
|
||||
z-0 背景 / orb / 网格
|
||||
z-1 body 主区
|
||||
z-2 普通卡片 shadow: 0 4px 14px rgba(_, .06) light / 0 12px 30px rgba(0,0,0,.5) dark
|
||||
z-3 浮起卡片 (hover) shadow: 0 8px 24px rgba(_, .10) light / 0 18px 40px rgba(0,0,0,.6) dark
|
||||
z-4 Header / 侧栏 shadow: 0 1px 6px rgba(_, .05)
|
||||
z-5 Dropdown / Toast
|
||||
z-6 Modal / Dialog
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 动画 (Motion)
|
||||
|
||||
| 场景 | 时长 | 缓动 |
|
||||
|---|---|---|
|
||||
| 颜色/边框变化 | 200ms | `ease-out` |
|
||||
| 卡片 hover (transform) | 250ms | `cubic-bezier(.25, .8, .25, 1)` |
|
||||
| 路由切换 | 250ms | 同上 |
|
||||
| 状态脉动 (live dot) | 1.5-2s | `ease-in-out infinite` |
|
||||
| Real-time 数值平滑 | 600-800ms | 同卡片 hover |
|
||||
|
||||
> ⚠ 严禁 hover 用 `transform: scale()` 引起 layout shift;用 `translateY(-2px)` + `box-shadow` 增强即可。
|
||||
> ⚠ 必须遵守 `prefers-reduced-motion: reduce` —— 关闭装饰性动画。
|
||||
|
||||
---
|
||||
|
||||
## 7. 反模式清单 (Anti-Patterns)
|
||||
|
||||
来自 ui-reasoning.csv (Autonomous Drone Fleet + SaaS Dashboard) + Common Rules:
|
||||
|
||||
| 禁忌 | 原因 |
|
||||
|---|---|
|
||||
| ❌ 把品牌紫当作 "执行中" 状态色 | 状态色与品牌色冲突,扫读时干扰 |
|
||||
| ❌ KPI 数值用变宽字体 | 实时跳动时数字晃动,影响阅读 |
|
||||
| ❌ 浅色主题用 `slate-400` 做正文 | 对比度 < 4.5:1,违反 WCAG AA |
|
||||
| ❌ 侧栏 active 用 4+ 重 box-shadow + text-shadow | 视觉噪音,装饰过度 |
|
||||
| ❌ 硬编码 `#1a0b3d` 等颜色 | 跨主题切换失效 |
|
||||
| ❌ 用 emoji 做 UI icon (🚀⚙️🎨) | 不统一,在不同 OS 渲染差异大 — 用 Heroicons / Element Plus icons |
|
||||
| ❌ 实时图表用 hard refresh (闪一下) | 视觉干扰 — 用 `series.universalTransition` 或 `dataset` 平滑过渡 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施清单 (Pre-Delivery Checklist)
|
||||
|
||||
每次发版前对照检查:
|
||||
|
||||
- [ ] 所有状态色用 `--mg-status-*` token,不直接 hardcode `#22c55e`
|
||||
- [ ] 所有 KPI / 坐标 / ID 数字用 `font-family: var(--mg-font-mono)` + `font-variant-numeric: tabular-nums`
|
||||
- [ ] 卡片/表单 hover 不引起 layout shift
|
||||
- [ ] 浅色主题文字对比度 ≥ 4.5:1 (WCAG AA)
|
||||
- [ ] 浮动元素 (header / aside) 在两套主题下都可见
|
||||
- [ ] 响应式断点:375 / 768 / 1024 / 1440 / 1920 都过一遍
|
||||
- [ ] 主题切换时无硬编码色 (grep `#[0-9a-f]{3,6}` in DashboardView/AppShell)
|
||||
|
||||
---
|
||||
|
||||
## 9. 引用与扩展
|
||||
|
||||
- **设计系统生成依据**:`.cursor/skills/ui-ux-pro-max/data/ui-reasoning.csv` (rows 19, 25, 54, 97), `colors.csv` (rows 26, 51, 53), `styles.csv` (rows 28, 31, 39, 46), `typography.csv` (rows 5, 9, 42)
|
||||
- **页面级覆盖**:`design-system/pages/<page-name>.md`
|
||||
- **变量来源**:`src/styles/theme.css` (CSS 变量声明) + `src/styles/themes.ts` (TS 主题预设)
|
||||
- **Element Plus 适配**:`theme.css` 中 `.mg-content .el-*` 选择器统一管理
|
||||
|
||||
---
|
||||
|
||||
> **Generated**: ui-ux-pro-max v2.0 推理 + 项目实际代码 cross-check
|
||||
> **Last sync**: 2026-05-24
|
||||
@@ -0,0 +1,128 @@
|
||||
# Dashboard 页面 · 设计覆盖 (page override)
|
||||
|
||||
> 作用域:`/admin/dashboard` (DashboardView.vue) + AppShell 左侧导航。
|
||||
> 优先级:本文件 > MASTER.md。仅记录 deviation,共性规则继承自 MASTER。
|
||||
|
||||
---
|
||||
|
||||
## 1. 页面布局结构 (Bento Grid)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Hero Banner (深色沉浸 · 跨主题保持工业感) │
|
||||
│ Left: tag + title + 8 个 quick-entry (4×2) │
|
||||
│ Right: AGV 工业 SVG 插画 (主题色驱动 gradient) │
|
||||
├──────────────────────────────────┬─────────────────────────────┤
|
||||
│ KPI Strip (6 项 · 等宽数字) │ 待办事项 (Tabs) │
|
||||
│ 任务占比 (4 行 · progress) │ - 故障告警 (danger) │
|
||||
│ 任务执行情况 (donut + legend) │ - 异常任务 (warning) │
|
||||
│ │ - 通信异常 (info) │
|
||||
│ │ - 服务器报警 (warning) │
|
||||
└──────────────────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
栅格:1.45fr : 1fr,断点见 MASTER §6。
|
||||
|
||||
---
|
||||
|
||||
## 2. Hero Banner 颜色规则(关键变更)
|
||||
|
||||
**原问题**:`hero-banner` 内嵌 `linear-gradient(135deg, #1a0b3d 0%, #2d1b69 50%, #14082e 100%)` 这种**硬编码紫色**,在切换到 `deep-azure` / `emerald-forge` 等主题时会"撞色"。
|
||||
|
||||
**新规则**:使用 `--mg-bg-aside-1` / `--mg-bg-aside-2` + 主色 RGB,跨主题保持工业沉浸感。
|
||||
|
||||
```css
|
||||
.hero-banner {
|
||||
background:
|
||||
radial-gradient(ellipse at 0% 0%, rgba(var(--mg-primary-hover-rgb), 0.50) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 100% 100%, rgba(var(--mg-accent-rgb), 0.45) 0%, transparent 55%),
|
||||
linear-gradient(135deg, var(--mg-bg-aside-1) 0%, var(--mg-bg-aside-2) 100%);
|
||||
border: 1px solid rgba(var(--mg-accent-rgb), 0.30);
|
||||
}
|
||||
```
|
||||
|
||||
SVG `linearGradient` 中的 `#3b1e7a/#0e0a24` 等也建议改为接受 `var()` 注入,但 SVG 内联色本身允许多套主题保留紫色装饰风(hero scene 是装饰艺术,可保留 stylized 颜色)。
|
||||
|
||||
---
|
||||
|
||||
## 3. KPI 数字 (高优先级 · 必改)
|
||||
|
||||
| 元素 | 原 | 新 |
|
||||
|---|---|---|
|
||||
| 字体 | 默认 sans (PingFang/Inter) | `var(--mg-font-mono)` JetBrains Mono |
|
||||
| `font-variant-numeric` | normal | `tabular-nums` |
|
||||
| 颜色 (light theme) | `var(--mg-text-light)` | 同 — 保留 |
|
||||
| 颜色 (industrial-purple) | gradient text | 保留 |
|
||||
| 字号 | 22px | 26px (突出主指标) |
|
||||
| `letter-spacing` | 0.5px | 0 (mono 自带间距) |
|
||||
|
||||
实现:在 `.kpi-value` 中加 `font-family: var(--mg-font-mono); font-variant-numeric: tabular-nums;`
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态色规范化 (DashboardView.vue 强约束)
|
||||
|
||||
### 4.1 任务执行情况 (donut)
|
||||
|
||||
| 状态 | 原 (hardcoded) | 新 (token) |
|
||||
|---|---|---|
|
||||
| 待执行 | `#a78bfa` (品牌紫色挪用) | `var(--mg-status-idle)` |
|
||||
| 执行中 | `#7c3aed` (品牌紫色挪用) | `var(--mg-status-info)` |
|
||||
| 待整理 | `#f59e0b` | `var(--mg-status-warning)` |
|
||||
|
||||
不要再用品牌紫色当作运行状态——会造成"系统色"和"品牌色"语义混淆。
|
||||
|
||||
### 4.2 待办事项 tab tone
|
||||
|
||||
| Tab | tone class | 颜色 (light) | 颜色 (dark) |
|
||||
|---|---|---|---|
|
||||
| 故障告警 | `danger` | `--mg-status-danger` | 同 |
|
||||
| 异常任务 | `warning` | `--mg-status-warning` | 同 |
|
||||
| 通信异常 | `info` | `--mg-status-info` | 同 |
|
||||
| 服务器报警 | `warning` | `--mg-status-warning` | 同 |
|
||||
|
||||
`.tab-count.danger / .warning / .info / .success` 在 theme.css 内统一,不再每个组件 hardcode。
|
||||
|
||||
---
|
||||
|
||||
## 5. 卡片层级 (Bento)
|
||||
|
||||
| 卡片 | z-level | 浮起 |
|
||||
|---|---|---|
|
||||
| Hero Banner | z-2 | 不浮起(始终展示) |
|
||||
| 主体两栏 (map-card / todo-card) | z-2 | hover 升 z-3 (translateY -2px) |
|
||||
| KPI 项 (.kpi-item) | z-2 inner | hover translateY -1px |
|
||||
| 待办列表 (.todo-list li) | z-2 inner | hover bg 加深 |
|
||||
|
||||
---
|
||||
|
||||
## 6. AppShell 侧栏调整
|
||||
|
||||
### 6.1 active 菜单项 (高优先级 · 减噪)
|
||||
|
||||
**原问题**:active 菜单堆叠了 5 重 box-shadow + text-shadow + 多重 background gradient。
|
||||
|
||||
**新规则**:
|
||||
- 用单一 left-border (`inset 3px 0 0 var(--mg-accent)`) + 一层柔和 bg gradient (角度从 90° 改为 270° 减少撞色) + 一层窄阴影
|
||||
- 删除 text-shadow(在浅色主题里不可见,在深色里造成模糊)
|
||||
- font-weight 600 即可强调,不需要发光
|
||||
|
||||
### 6.2 子菜单缩进
|
||||
- 父菜单 padding-left 16px,子菜单 24px
|
||||
- 子菜单 active 颜色不再用霓虹紫,而是 `color: var(--mg-accent); border-left: 2px solid var(--mg-primary-hover)` 提供清晰但克制的视觉线索
|
||||
|
||||
### 6.3 滚动条
|
||||
- 侧栏菜单 `.menu` 自定义滚动条宽 4px、半透明色,避免影响紧凑视觉
|
||||
|
||||
---
|
||||
|
||||
## 7. 检查清单(与 MASTER §8 互补)
|
||||
|
||||
实施完成后核对:
|
||||
|
||||
- [ ] DashboardView 中无任何 `#7c3aed/#a78bfa` 状态色 hardcode
|
||||
- [ ] KPI value 等宽对齐(tabular-nums)
|
||||
- [ ] 切换主题:fame-lavender → deep-azure → emerald-forge,Hero/卡片/侧栏色彩跟随
|
||||
- [ ] 侧栏 active 项视觉清晰,无 5+ shadow 叠加
|
||||
- [ ] `prefers-reduced-motion: reduce` 下 quick-item 入场动画 / live dot 关闭
|
||||
- [ ] 浏览器缩放到 100%/125%/150% 不破坏栅格
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_VRENDER_HOST: string
|
||||
// 'true' = 走前端 mock;其余值(含未设置)一律走真实后端。
|
||||
readonly VITE_USE_MOCK?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>迷毂 · 智能调度平台</title>
|
||||
<meta name="theme-color" content="#2e3f8a" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "simple-platform-vue",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Simple-FR 管理员 + 运营双视图前端(admin/monitor 单工程切换)。",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .ts,.vue --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.7",
|
||||
"echarts": "^5.5.1",
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.7",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"typescript": "^5.6.3",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.10",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7d2bac"/>
|
||||
<stop offset="100%" stop-color="#3b0b58"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
||||
<text x="16" y="22.5" text-anchor="middle" font-family="'PingFang SC','Microsoft YaHei',sans-serif" font-size="15" font-weight="700" fill="#fff">迷</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 476 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
color: #303133;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
import http from './http'
|
||||
import type { LoginRequest, LoginResponse, MeResponse, Scope } from '@/types/auth'
|
||||
import { mockLogin } from '@/mock/server'
|
||||
|
||||
// 是否走前端 Mock。由 VITE_USE_MOCK 控制:
|
||||
// - .env.development 默认 'true' → 本地无后端即可联调
|
||||
// - .env.production 默认 'false' → 生产强制走 Platform.Server 真实 API
|
||||
// 任何不等于字符串 'true' 的取值都视为 false,避免 build 时静态替换出错。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
if (MOCK) return mockLogin(req)
|
||||
const { data } = await http.post<LoginResponse>('/auth/login', req)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (MOCK) return
|
||||
await http.post('/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* AR-6 (会话 45):服务端切换 scope 并重发 token + perms。
|
||||
* 替代过去 stores/auth.ts switchScope 客户端硬编码 allowedOps 的伪权限做法。
|
||||
* Mock 模式下退化为本地翻转 scope(mockLogin 已经依据 scope 返回不同 perms)。
|
||||
*/
|
||||
export async function switchScope(scope: Scope): Promise<LoginResponse> {
|
||||
if (MOCK) {
|
||||
// mock 直接复用 mockLogin 的 perm 推算逻辑,伪造一次「免密码登录」。
|
||||
return mockLogin({ username: 'mock-user', password: 'mock', scope })
|
||||
}
|
||||
const { data } = await http.post<LoginResponse>('/auth/switch-scope', { scope })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿当前 token / Cookie 让后端实校一次身份。成功表示 token 仍被服务端接受,
|
||||
* 失败(401)由 http 拦截器统一清 state + 跳 /login。
|
||||
*
|
||||
* 解决「Platform.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍 true 误放行」的窗口。
|
||||
*/
|
||||
export async function getMe(): Promise<MeResponse> {
|
||||
if (MOCK) {
|
||||
// mock 模式下没有 secret 概念,本地 store 里有什么就是什么;直接 throw 让守卫
|
||||
// 走「认可本地状态」分支,避免 mock 联调误判为「身份失效」。
|
||||
throw new Error('mock-mode-skip-me-validation')
|
||||
}
|
||||
const { data } = await http.get<MeResponse>('/auth/me')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import http from './http'
|
||||
import type { ConfigEnvelope, ConfigSection } from '@/types/config'
|
||||
import { mockGetConfig, mockPutConfig } from '@/mock/server'
|
||||
|
||||
// 见 auth.ts 中关于 VITE_USE_MOCK 的说明:生产默认走 Platform.Server `/api/config/*`,
|
||||
// 仅当 `.env.development` 显式声明 `VITE_USE_MOCK=true` 时才回到前端假数据。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export async function getConfig<T = unknown>(section: ConfigSection): Promise<ConfigEnvelope<T>> {
|
||||
if (MOCK) return mockGetConfig<T>(section)
|
||||
const { data } = await http.get<ConfigEnvelope<T>>(`/config/${section}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function putConfig<T = unknown>(
|
||||
section: ConfigSection,
|
||||
payload: T
|
||||
): Promise<ConfigEnvelope<T>> {
|
||||
if (MOCK) return mockPutConfig<T>(section, payload)
|
||||
const { data } = await http.put<ConfigEnvelope<T>>(`/config/${section}`, payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const baseURL = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
|
||||
// AR-5 (会话 45):withCredentials=true 让后端下发的 httpOnly Cookie (simple.auth.token)
|
||||
// 自动随请求带回。Platform.Server `/api/auth/login` 同时下发 Cookie 和返回 token 字段,
|
||||
// 过渡期内 Authorization: Bearer header 仍然兼容(旧浏览器 / 跨进程脚本)。
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
// 双轨:优先用 localStorage 里的 token(过渡期 fallback),Cookie 会自动带;
|
||||
// 后端首先看 Authorization: Bearer,没有再看 Cookie,两路任一通过即可。
|
||||
const token = localStorage.getItem('simple.auth.token')
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
const scope = localStorage.getItem('simple.auth.scope')
|
||||
if (scope) {
|
||||
config.headers.set('X-Scope', scope)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/**
|
||||
* 把 axios 异常翻译成更友好的中文文案,特别是 SimpleLite 链路:
|
||||
*
|
||||
* /api/sl/projection/** → Platform.Server YARP → SimpleLite EmbedIO :8222
|
||||
*
|
||||
* 如果 SimpleLite 没启动 / 端口未监听,YARP 一定回 502;超时一般是 504。把这两种
|
||||
* 情况单独识别,写成「SimpleLite 未连接,请先启动 SimpleLite (端口 8222)」之类,
|
||||
* 比直接抛 `Request failed with status code 502` 友好得多,也避免用户怀疑前端 bug。
|
||||
*/
|
||||
function describeError(err: AxiosError): string {
|
||||
const status = err.response?.status
|
||||
const url = err.config?.url ?? ''
|
||||
const isSimpleLite = url.startsWith('/sl/') || url.startsWith('sl/')
|
||||
if (status === 502 || status === 504 || err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK') {
|
||||
if (isSimpleLite) {
|
||||
return 'SimpleLite 未连接:请先启动 SimpleLite 后端(监听端口 8222)后重试。'
|
||||
}
|
||||
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
|
||||
}
|
||||
// 后端 envelope 模式:{ success: false, message: 'xxx' }
|
||||
const data = err.response?.data as { message?: string } | undefined
|
||||
if (data?.message) return data.message
|
||||
return err.message
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401) {
|
||||
// 会话 45 HC-1:401 全量清 state,避免老 user/scope/perm 残留导致 UI 误判。
|
||||
try {
|
||||
localStorage.removeItem('simple.auth.token')
|
||||
localStorage.removeItem('simple.auth.user')
|
||||
localStorage.removeItem('simple.auth.scope')
|
||||
localStorage.removeItem('simple.auth.runMode')
|
||||
localStorage.removeItem('simple.auth.perm')
|
||||
} catch { /* 某些隐私模式 / iframe 沙箱可能禁用 localStorage */ }
|
||||
if (location.pathname !== '/login') location.assign('/login')
|
||||
}
|
||||
// 用 Object.defineProperty 覆盖 message,避免后续 `(err as Error).message` 还是看到原文。
|
||||
const friendly = describeError(err)
|
||||
try { Object.defineProperty(err, 'message', { value: friendly, configurable: true }) } catch { /* ignore */ }
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,282 @@
|
||||
import http from './http'
|
||||
|
||||
/**
|
||||
* 与 SimpleLite `MapEditApiController` 对应的前端胶水。
|
||||
* 路径前缀:`/sl/projection/map-edit`(YARP → SimpleLite `/projection/map-edit`)。
|
||||
*
|
||||
* 也覆盖 AI 服务配置 `/sl/projection/ai-config`(GET / POST),由 AiConfigController 提供。
|
||||
*
|
||||
* 所有方法都通过统一信封 `{ success, code, data, message }` 返回;调用方拿到的是 `data` 部分,
|
||||
* 失败时直接抛 Error,让上层 try/catch 或 ElMessage.error 统一处理。
|
||||
*/
|
||||
|
||||
const BASE = '/sl/projection/map-edit'
|
||||
const AI_BASE = '/sl/projection/ai-config'
|
||||
|
||||
export interface MapEditEnvelope<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
data: T | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export type MapEditKind =
|
||||
| 'site'
|
||||
| 'track'
|
||||
| 'bezier'
|
||||
| 'arc'
|
||||
| 'nurbs'
|
||||
| 'image'
|
||||
| 'text'
|
||||
| 'model'
|
||||
| 'special'
|
||||
|
||||
export interface CreateSitePayload {
|
||||
x: number
|
||||
y: number
|
||||
z?: number
|
||||
name?: string
|
||||
layer?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface CreateTrackPayload {
|
||||
siteA: number
|
||||
siteB: number
|
||||
name?: string
|
||||
layer?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface CreateImagePayload {
|
||||
x: number
|
||||
y: number
|
||||
imagePath: string
|
||||
size?: number
|
||||
angle?: number
|
||||
opacity?: number
|
||||
name?: string
|
||||
layer?: string
|
||||
}
|
||||
|
||||
export interface CreateTextPayload {
|
||||
x: number
|
||||
y: number
|
||||
content: string
|
||||
color?: string
|
||||
size?: number
|
||||
name?: string
|
||||
layer?: string
|
||||
}
|
||||
|
||||
export interface CreateModelPayload {
|
||||
x: number
|
||||
y: number
|
||||
modelPath: string
|
||||
scale?: number
|
||||
rotation?: number
|
||||
zOffset?: number
|
||||
name?: string
|
||||
layer?: string
|
||||
}
|
||||
|
||||
export interface CreateCarPayload {
|
||||
x: number
|
||||
y: number
|
||||
/** 朝向,弧度。可省略,默认 0。 */
|
||||
theta?: number
|
||||
name?: string
|
||||
layer?: string
|
||||
}
|
||||
|
||||
export interface BatchOp {
|
||||
action: 'create' | 'delete' | 'patch'
|
||||
kind?: MapEditKind | string
|
||||
id?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PickResult {
|
||||
x: number
|
||||
y: number
|
||||
siteId: number
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
cars: number
|
||||
onlineCars: number
|
||||
alarmingCars: number
|
||||
sites: number
|
||||
tracks: number
|
||||
specials: number
|
||||
maps: number
|
||||
missions: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AssetUploadResult {
|
||||
fileName: string
|
||||
savedPath: string
|
||||
/** 相对 url,前端拼上 webVRender host 即可显示。 */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ViewFilterSnapshot {
|
||||
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
|
||||
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
|
||||
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
||||
}
|
||||
|
||||
/** 部分更新;只传你要改的字段即可。后端不会覆盖未传字段。 */
|
||||
export interface ViewFilterPatch {
|
||||
selectFilter?: Partial<ViewFilterSnapshot['selectFilter']>
|
||||
alignSnap?: Partial<ViewFilterSnapshot['alignSnap']>
|
||||
showViewport?: Partial<ViewFilterSnapshot['showViewport']>
|
||||
}
|
||||
|
||||
export interface AiMapGenerateRequest {
|
||||
prompt: string
|
||||
mode?: 'sites' | 'tracks' | 'sites+tracks' | 'full'
|
||||
bounds?: [number, number, number, number]
|
||||
referenceImageBase64?: string
|
||||
layer?: string
|
||||
constraints?: string
|
||||
}
|
||||
|
||||
export interface AiMapGenerateResult {
|
||||
created: Array<{ tool: string; kind: string; id: number; typeName?: string; error?: string }>
|
||||
assistantText?: string
|
||||
usedTools: number
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
model: string
|
||||
systemPrompt: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
timeoutSec: number
|
||||
/** 仅 GET 返回时携带:apiKey 非空 → true。 */
|
||||
configured?: boolean
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<{ data: MapEditEnvelope<T> }>): Promise<T> {
|
||||
const { data } = await promise
|
||||
if (!data?.success) throw new Error(data?.message ?? 'request failed')
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
export const mapEditApi = {
|
||||
// 对象创建/删除
|
||||
createSite: (p: CreateSitePayload) =>
|
||||
unwrap<{ kind: 'site'; id: number; name?: string }>(http.post(`${BASE}/objects/site`, p)),
|
||||
|
||||
createTrack: (kind: 'track' | 'bezier' | 'arc' | 'nurbs', p: CreateTrackPayload) =>
|
||||
unwrap<{ kind: 'track'; id: number; typeName: string }>(http.post(`${BASE}/objects/${kind}`, p)),
|
||||
|
||||
createImage: (p: CreateImagePayload) =>
|
||||
unwrap<{ kind: 'image'; id: number }>(http.post(`${BASE}/objects/image`, p)),
|
||||
|
||||
createText: (p: CreateTextPayload) =>
|
||||
unwrap<{ kind: 'text'; id: number }>(http.post(`${BASE}/objects/text`, p)),
|
||||
|
||||
createModel: (p: CreateModelPayload) =>
|
||||
unwrap<{ kind: 'model'; id: number }>(http.post(`${BASE}/objects/model`, p)),
|
||||
|
||||
/** 直接创建一辆模拟车 (DummyCar),不经过画布 pick。 */
|
||||
createCar: (p: CreateCarPayload) =>
|
||||
unwrap<{ kind: 'car'; id: number; typeName: string }>(http.post(`${BASE}/objects/car`, p)),
|
||||
|
||||
deleteObject: (kind: MapEditKind | string, id: number) =>
|
||||
unwrap<{ kind: string; id: number; deleted: boolean }>(http.delete(`${BASE}/objects/${kind}/${id}`)),
|
||||
|
||||
batch: (ops: BatchOp[]) =>
|
||||
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
|
||||
|
||||
copyFieldsTo: (kind: MapEditKind | string, id: number, fieldNames: string[], targets: Array<{ kind: string; id: number }>) =>
|
||||
unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })),
|
||||
|
||||
// 拾取 / 仪表盘
|
||||
pick: () => unwrap<PickResult>(http.post(`${BASE}/pick`)),
|
||||
|
||||
dashboardSummary: () =>
|
||||
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
|
||||
|
||||
// 资产上传:传 base64
|
||||
uploadAsset: (filename: string, dataBase64: string) =>
|
||||
unwrap<AssetUploadResult>(http.post(`${BASE}/assets/upload`, { filename, data: dataBase64 }, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})),
|
||||
|
||||
// AI 生图
|
||||
aiMapGenerate: (req: AiMapGenerateRequest) =>
|
||||
unwrap<AiMapGenerateResult>(http.post(`${BASE}/ai/map-generate`, req)),
|
||||
|
||||
projectSave: (path?: string) =>
|
||||
unwrap<{ path: string; savedAt: string }>(http.post(`${BASE}/project/save`, { path: path ?? '' })),
|
||||
|
||||
projectLoad: (path: string) =>
|
||||
unwrap<{ path: string; sites: number; tracks: number; specials: number; missions: number }>(
|
||||
http.post(`${BASE}/project/load`, { path })
|
||||
),
|
||||
|
||||
projectCurrent: () =>
|
||||
unwrap<{ sites: number; tracks: number; specials: number; maps: number; missions: number; timestamp: string; cwd: string }>(
|
||||
http.get(`${BASE}/project/current`)
|
||||
),
|
||||
|
||||
projectBrowse: (dir?: string) =>
|
||||
unwrap<{
|
||||
dir: string
|
||||
parent: string | null
|
||||
subdirs: Array<{ name: string; fullPath: string }>
|
||||
jsons: Array<{ name: string; fullPath: string; sizeBytes: number; modified: string }>
|
||||
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
|
||||
|
||||
/**
|
||||
* 在 SimpleLite 桌面进程上弹 Windows 原生「打开文件」对话框,
|
||||
* 让用户挑一个项目 JSON。返回 { path, cancelled };cancelled=true 时 path 为 null。
|
||||
* initialDir 默认 SimpleLite 程序工作目录。
|
||||
*/
|
||||
projectNativePickOpen: (initialDir?: string) =>
|
||||
unwrap<{ path: string | null; cancelled: boolean }>(
|
||||
http.post(`${BASE}/project/native-pick-open`, { initialDir: initialDir ?? '' })
|
||||
),
|
||||
|
||||
/**
|
||||
* 弹 Windows 原生「另存为」对话框。返回 { path, cancelled }。
|
||||
* suggestedFileName 默认 project.current.json。
|
||||
*/
|
||||
projectNativePickSave: (initialDir?: string, suggestedFileName?: string) =>
|
||||
unwrap<{ path: string | null; cancelled: boolean }>(
|
||||
http.post(`${BASE}/project/native-pick-save`, {
|
||||
initialDir: initialDir ?? '',
|
||||
suggestedFileName: suggestedFileName ?? 'project.current.json'
|
||||
})
|
||||
),
|
||||
|
||||
sampleSitesAlongTrack: (trackId: number, opts: { spacingMm: number; includeEndpoints?: boolean; layer?: string }) =>
|
||||
unwrap<{ created: number; ids: number[] }>(
|
||||
http.post(`${BASE}/objects/track/${trackId}/sample-sites`, opts)
|
||||
),
|
||||
|
||||
/**
|
||||
* 读取后端「过滤」菜单当前的三组开关。编辑器载入时拉一次,让前端 reactive 状态
|
||||
* 与 SimpleLite 真实开关一致(避免「前端勾着、后端没改」的偏差)。
|
||||
*/
|
||||
getViewFilter: () => unwrap<ViewFilterSnapshot>(http.get(`${BASE}/view-filter`)),
|
||||
|
||||
/**
|
||||
* 部分更新「过滤」菜单。只传要改的字段即可,未传字段保持原状。
|
||||
* 后端写入对应 SimpleUI 静态字段 + 触发 SimpleSceneRenderer.NotifyStateChanged()
|
||||
* 立即重画工作区,对 SelectFilter 变化还会同步 CycleGUI 引擎层 SelectObject 模式。
|
||||
*/
|
||||
setViewFilter: (patch: ViewFilterPatch) =>
|
||||
unwrap<ViewFilterSnapshot>(http.post(`${BASE}/view-filter`, patch))
|
||||
}
|
||||
|
||||
export const aiConfigApi = {
|
||||
get: () => unwrap<AiConfig>(http.get(`${AI_BASE}/`)),
|
||||
save: (cfg: AiConfig) => unwrap<{ saved: boolean }>(http.post(`${AI_BASE}/`, cfg))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import http from './http'
|
||||
import type { OpsAuditEntry } from '@/types/ops'
|
||||
import { mockOpsExecute, mockOpsAudits } from '@/mock/server'
|
||||
|
||||
// 见 auth.ts 中关于 VITE_USE_MOCK 的说明:运维白名单网关默认走真实 `/api/sl/ops/*`,
|
||||
// 仅在前端 dev 调试时由 `.env.development` 把 VITE_USE_MOCK 设为 'true' 回到假数据。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export interface OpsExecuteReq {
|
||||
opCode: string
|
||||
targetId: string
|
||||
reason?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface OpsExecuteResp {
|
||||
ok: boolean
|
||||
auditId: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function executeOp(req: OpsExecuteReq): Promise<OpsExecuteResp> {
|
||||
if (MOCK) return mockOpsExecute(req)
|
||||
const { data } = await http.post<OpsExecuteResp>('/sl/ops/execute', req)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listAudits(): Promise<OpsAuditEntry[]> {
|
||||
if (MOCK) return mockOpsAudits()
|
||||
const { data } = await http.get<OpsAuditEntry[]>('/sl/ops/audits')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import http from './http'
|
||||
import { reflectionApi, type ReflectionObject } from './reflection'
|
||||
import type { Site, Track } from '@/types/map'
|
||||
import type { Car, CarState } from '@/types/car'
|
||||
import type { Mission, MissionStatus } from '@/types/mission'
|
||||
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
|
||||
|
||||
/** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。
|
||||
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export async function listSites(): Promise<Site[]> {
|
||||
if (MOCK) return mockSites()
|
||||
const { data } = await http.get<Site[]>('/sl/projection/sites')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listTracks(): Promise<Track[]> {
|
||||
if (MOCK) return mockTracks()
|
||||
const { data } = await http.get<Track[]>('/sl/projection/tracks')
|
||||
return data
|
||||
}
|
||||
|
||||
function mapStatusToCarState(status?: string | null): CarState {
|
||||
if (!status) return 'idle'
|
||||
const s = status.toLowerCase()
|
||||
if (/fault|error|failed|故障|异常|失联|超时|检修/.test(s)) return 'fault'
|
||||
if (/charg|充电/.test(s)) return 'charging'
|
||||
if (/pause|暂停|挂起/.test(s)) return 'paused'
|
||||
if (/offline|离线/.test(s)) return 'offline'
|
||||
if (/run|busy|working|运行|工作|执行|忙/.test(s)) return 'running'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function mapStatusToMissionStatus(status?: string | null): MissionStatus {
|
||||
if (!status) return 'queued'
|
||||
const s = status.toLowerCase()
|
||||
if (/run|运行|执行/.test(s)) return 'running'
|
||||
if (/pause|暂停|挂起/.test(s)) return 'paused'
|
||||
if (/complete|done|完成|成功/.test(s)) return 'completed'
|
||||
if (/cancel|取消|中止/.test(s)) return 'cancelled'
|
||||
if (/fail|error|fault|失败|异常/.test(s)) return 'failed'
|
||||
if (/assign|分配/.test(s)) return 'assigned'
|
||||
return 'queued'
|
||||
}
|
||||
|
||||
function carFromReflection(row: ReflectionObject): Car {
|
||||
return {
|
||||
id: `C${String(row.id).padStart(2, '0')}`,
|
||||
rawId: row.id,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
x: 0,
|
||||
y: 0,
|
||||
theta: 0,
|
||||
batterySoc: 0.8,
|
||||
state: mapStatusToCarState(row.status),
|
||||
lastUpdate: new Date().toISOString(),
|
||||
group: row.layer ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function missionFromReflection(row: ReflectionObject): Mission {
|
||||
return {
|
||||
id: `M${String(row.id).padStart(2, '0')}`,
|
||||
name: row.name,
|
||||
typeName: row.typeName,
|
||||
priority: 50,
|
||||
status: mapStatusToMissionStatus(row.status),
|
||||
steps: [],
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function listCarsFromReflection(): Promise<Car[]> {
|
||||
const rows = await reflectionApi.listObjects('car')
|
||||
return rows.map(carFromReflection)
|
||||
}
|
||||
|
||||
async function listMissionsFromReflection(): Promise<Mission[]> {
|
||||
const rows = await reflectionApi.listObjects('mission')
|
||||
return rows.map(missionFromReflection)
|
||||
}
|
||||
|
||||
/** 优先走 SimpleLite /projection/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
|
||||
export async function listCars(): Promise<Car[]> {
|
||||
if (MOCK) return mockCars()
|
||||
try {
|
||||
const { data } = await http.get<Car[]>('/sl/projection/cars')
|
||||
if (Array.isArray(data) && data.length > 0) return data
|
||||
const fallback = await listCarsFromReflection()
|
||||
if (fallback.length > 0) return fallback
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
return listCarsFromReflection()
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMissions(): Promise<Mission[]> {
|
||||
if (MOCK) return mockMissions()
|
||||
try {
|
||||
const { data } = await http.get<Mission[]>('/sl/projection/missions')
|
||||
if (Array.isArray(data) && data.length > 0) return data
|
||||
const fallback = await listMissionsFromReflection()
|
||||
if (fallback.length > 0) return fallback
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
return listMissionsFromReflection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import http from './http'
|
||||
import {
|
||||
mockReflectionAssemblies,
|
||||
mockReflectionBundle,
|
||||
mockReflectionDeleteField,
|
||||
mockReflectionExecute,
|
||||
mockReflectionFields,
|
||||
mockReflectionKinds,
|
||||
mockReflectionMethods,
|
||||
mockReflectionMethodsByType,
|
||||
mockReflectionObjects,
|
||||
mockReflectionSetField,
|
||||
mockReflectionStatus
|
||||
} from '@/mock/data/reflection'
|
||||
|
||||
// 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关,避免 PROJECTION/USE 双命名造成
|
||||
// .env 改一个忘改另一个的诡异半 mock 状态。env.d.ts 已声明 VITE_USE_MOCK 类型。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
/**
|
||||
* 与 SimpleLite `ReflectionApiController` 一一对应的前端胶水。
|
||||
* 路径前缀:`/sl/projection/reflection`(迷榖平台 YARP 反代下游 → SimpleLite EmbedIO)。
|
||||
*
|
||||
* 该客户端覆盖了 SimpleLite 主体以及通过 plugins/*.dll 动态加载的 Standard 等
|
||||
* 插件中所有标注了 `MethodMember` 的方法与所有继承自 Car/Mission/CarProgram/Site/Track
|
||||
* 的子类型,平台前端可据此动态渲染列表、属性、动作按钮。
|
||||
*/
|
||||
|
||||
const BASE = '/sl/projection/reflection'
|
||||
|
||||
export type ReflectionKind =
|
||||
| 'car'
|
||||
| 'vehicle'
|
||||
| 'mission'
|
||||
| 'process'
|
||||
| 'site'
|
||||
| 'track'
|
||||
| 'map'
|
||||
| 'special'
|
||||
| 'script'
|
||||
// 顶级合成 kind:等于 site + track + special 的并集,由 SimpleLite 后端 /objects/scene 直接返回,
|
||||
// 每行带 subKind 字段,前端 bundle / execute / setField 走对应底层 kind。
|
||||
| 'scene'
|
||||
|
||||
export interface ReflectionEnvelope<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
data: T | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ReflectionParam {
|
||||
name: string
|
||||
typeName: string
|
||||
hasDefault: boolean
|
||||
defaultValue?: string | null
|
||||
}
|
||||
|
||||
export interface ReflectionMethod {
|
||||
methodName: string
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
hint?: string | null
|
||||
returnType: string
|
||||
hasParams: boolean
|
||||
params: ReflectionParam[]
|
||||
}
|
||||
|
||||
export interface ReflectionObject {
|
||||
id: number
|
||||
name: string
|
||||
typeName: string
|
||||
layer?: string | null
|
||||
status?: string | null
|
||||
summary?: string | null
|
||||
/** 当顶级 kind 是合成 kind(如 "scene")时,此字段标记底层真实 kind。 */
|
||||
subKind?: string | null
|
||||
}
|
||||
|
||||
export interface ReflectionKv {
|
||||
key: string
|
||||
value: string
|
||||
locked?: boolean
|
||||
/** "typed" = 强类型 [FieldMember],不可删;"dynamic" = Prop.fields 动态字段,可删。 */
|
||||
source?: 'typed' | 'dynamic'
|
||||
/** 后端字段的 .NET 类型名(String / Single / Int32 / Boolean 等),用于前端渲染对应控件。 */
|
||||
typeName?: string
|
||||
}
|
||||
|
||||
export interface ReflectionTypeMethods {
|
||||
typeName: string
|
||||
fullTypeName?: string
|
||||
typeLabel?: string
|
||||
assemblyName: string
|
||||
methods: ReflectionMethod[]
|
||||
}
|
||||
|
||||
export interface ReflectionAssembly {
|
||||
name: string
|
||||
version?: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
/** 通过 GET /reflection/types/{kind} 拿到的可创建子类型行(进程 / 脚本 / 车辆管理面板的「新建」下拉用)。 */
|
||||
export interface ReflectionCreatableType {
|
||||
typeName: string
|
||||
shortName: string
|
||||
label: string
|
||||
assemblyName: string
|
||||
}
|
||||
|
||||
/** GET /reflection/plugins 返回的单个插件元信息。 */
|
||||
export interface PluginEntry {
|
||||
name: string
|
||||
dllPath: string
|
||||
assemblyName: string
|
||||
assemblyVersion: string
|
||||
collectible: boolean
|
||||
loadedTypes: number
|
||||
loadedAt: string
|
||||
missionTypes: number
|
||||
carTypes: number
|
||||
}
|
||||
|
||||
export interface ReflectionKindMeta {
|
||||
kind: ReflectionKind
|
||||
count: number
|
||||
label: string
|
||||
/** "primary" 表示顶级 5 分类;undefined / 其他视为底层 kind。 */
|
||||
group?: string
|
||||
}
|
||||
|
||||
export interface ReflectionSubKindMeta extends ReflectionKindMeta {
|
||||
parent: ReflectionKind
|
||||
}
|
||||
|
||||
export interface ReflectionSelection {
|
||||
kind: ReflectionKind | null
|
||||
id: number
|
||||
name: string
|
||||
typeName?: string
|
||||
}
|
||||
|
||||
export interface ScriptSourcePayload {
|
||||
id: number
|
||||
name: string
|
||||
typeName: string
|
||||
state?: string | null
|
||||
script: string
|
||||
}
|
||||
|
||||
export interface ScriptExceptionStatusPayload {
|
||||
id: number
|
||||
name: string
|
||||
typeName: string
|
||||
car: string
|
||||
state?: string | null
|
||||
exception: string
|
||||
notifies: string
|
||||
report: string
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 地图监控可见性配置(与后端 MonitorVisibilityConfig / MonitorVisibilityForKind 对应)
|
||||
//
|
||||
// `config` = 管理员勾选的白名单。空数组 = 显示全部;非空 = 仅显示列表中的 key。
|
||||
// `available` = 当前 SimpleLite 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
|
||||
// 运行时实例的 Prop.fields / status 反射键)。是 GET 返回的副产物,
|
||||
// POST 写入时不需要、也不该回传。
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MonitorVisibilityKind = 'car' | 'site' | 'track'
|
||||
|
||||
export interface MonitorVisibilityForKind {
|
||||
fields: string[]
|
||||
status: string[]
|
||||
methods: string[]
|
||||
}
|
||||
|
||||
export interface MonitorVisibilityMap {
|
||||
car: MonitorVisibilityForKind
|
||||
site: MonitorVisibilityForKind
|
||||
track: MonitorVisibilityForKind
|
||||
}
|
||||
|
||||
/** POST /monitor-config 请求体:三组白名单 + 可选按车型动作。 */
|
||||
export interface MonitorConfigSaveBody extends MonitorVisibilityMap {
|
||||
carActionByType?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface MonitorConfigPayload {
|
||||
config: MonitorVisibilityMap & { carActionByType?: Record<string, string[]> }
|
||||
available: MonitorVisibilityMap
|
||||
}
|
||||
|
||||
const MOCK_MONITOR_STORAGE_KEY = 'simple-platform-mock-monitor-config'
|
||||
|
||||
function loadMockMonitorConfig(): MonitorConfigSaveBody {
|
||||
try {
|
||||
const raw = localStorage.getItem(MOCK_MONITOR_STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw) as MonitorConfigSaveBody
|
||||
} catch { /* ignore */ }
|
||||
return emptyMonitorVisibilityMap()
|
||||
}
|
||||
|
||||
function saveMockMonitorConfig(cfg: MonitorConfigSaveBody) {
|
||||
try {
|
||||
localStorage.setItem(MOCK_MONITOR_STORAGE_KEY, JSON.stringify(cfg))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function emptyMonitorVisibilityForKind(): MonitorVisibilityForKind {
|
||||
return { fields: [], status: [], methods: [] }
|
||||
}
|
||||
|
||||
export function emptyMonitorVisibilityMap(): MonitorVisibilityMap {
|
||||
return {
|
||||
car: emptyMonitorVisibilityForKind(),
|
||||
site: emptyMonitorVisibilityForKind(),
|
||||
track: emptyMonitorVisibilityForKind()
|
||||
}
|
||||
}
|
||||
|
||||
// 语义上 available 与 config 同构(都是按 kind 分组的三组 key 列表),所以共用一个工厂。
|
||||
// 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。
|
||||
export const emptyMonitorAvailableMap = emptyMonitorVisibilityMap
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
||||
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
async function post<T>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
|
||||
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params })
|
||||
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
async function del<T>(path: string): Promise<T> {
|
||||
const { data } = await http.delete<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
||||
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
|
||||
return data.data as T
|
||||
}
|
||||
|
||||
export const reflectionApi = {
|
||||
listKinds: () => MOCK
|
||||
? Promise.resolve(mockReflectionKinds())
|
||||
: get<{ kinds: ReflectionKindMeta[]; subKinds?: ReflectionSubKindMeta[] }>('/kinds'),
|
||||
|
||||
listAssemblies: () => MOCK
|
||||
? Promise.resolve(mockReflectionAssemblies())
|
||||
: get<ReflectionAssembly[]>('/assemblies'),
|
||||
|
||||
listObjects: (kind: ReflectionKind) => MOCK
|
||||
? Promise.resolve(mockReflectionObjects(kind))
|
||||
: get<ReflectionObject[]>(`/objects/${kind}`),
|
||||
|
||||
/** 列出当前可实例化的子类型(用于「新建」下拉);mock 模式直接给空。 */
|
||||
listCreatableTypes: (kind: ReflectionKind): Promise<ReflectionCreatableType[]> => MOCK
|
||||
? Promise.resolve([])
|
||||
: get<ReflectionCreatableType[]>(`/types/${kind}`),
|
||||
|
||||
/**
|
||||
* 创建一条对象。底层 POST `/objects/{kind}?...`,所有 extras 字段都作为 query 传给后端。
|
||||
*
|
||||
* 三类典型用法:
|
||||
* - process/script + UiDiscoveryCache 子类:`createObject('process', 'TaskFlow')`
|
||||
* - car(DummyCar 兜底,typeName 可空):`createObject('car', '', { name: 'AGV-1', x: 0, y: 0 })`
|
||||
* - site/track/image/text/model:`createObject('site', '', { x: 100, y: 200, name: 'A' })`
|
||||
*
|
||||
* 后端 BuildAndPersist 会做完整的字段类型转换;前端只负责传字符串。
|
||||
*/
|
||||
createObject: (
|
||||
kind: ReflectionKind,
|
||||
typeName?: string,
|
||||
extras?: Record<string, string | number | boolean | undefined>
|
||||
) => {
|
||||
if (MOCK) return Promise.resolve({ kind, id: -1, typeName: typeName ?? '' })
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (typeName) params.typeName = typeName
|
||||
if (extras) {
|
||||
for (const [k, v] of Object.entries(extras)) {
|
||||
if (v === undefined || v === null || v === '') continue
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
return post<{ kind: string; id: number; typeName: string }>(`/objects/${kind}`, params)
|
||||
},
|
||||
|
||||
/** 删除一条对象(process / script / car / site / track / special)。 */
|
||||
deleteObject: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve({ kind, id, deleted: true })
|
||||
: del<{ kind: string; id: number; deleted: boolean }>(`/objects/${kind}/${id}`),
|
||||
|
||||
/**
|
||||
* 触发 SimpleLite 重新扫描 ./plugins 目录、加载新增 dll 并重建 UiDiscoveryCache。
|
||||
* 已加载的 dll 不会重复加载。
|
||||
* 返回:本次发现的 dll 总数、新加载的 assembly 数、当前可创建的 mission/car 类型计数。
|
||||
*/
|
||||
reloadPlugins: () => MOCK
|
||||
? Promise.resolve({ totalDlls: 0, newlyLoaded: 0, failed: 0, missionTypes: 0, carTypes: 0 })
|
||||
: post<{ totalDlls: number; newlyLoaded: number; failed: number; missionTypes: number; carTypes: number }>(
|
||||
'/plugins/reload'
|
||||
),
|
||||
|
||||
/** 列出当前已加载的所有 collectible 插件(PluginManager 跟踪范围内)。 */
|
||||
listPlugins: () => MOCK
|
||||
? Promise.resolve<PluginEntry[]>([])
|
||||
: get<PluginEntry[]>('/plugins'),
|
||||
|
||||
/**
|
||||
* 卸载一个 collectible 插件。
|
||||
* 失败原因常见:仍有 Mission / Car 实例占用插件类型 → 409。
|
||||
*/
|
||||
unloadPlugin: (name: string) => MOCK
|
||||
? Promise.resolve({ name, message: 'mock', missionTypes: 0, carTypes: 0 })
|
||||
: post<{ name: string; message: string; missionTypes: number; carTypes: number }>(
|
||||
`/plugins/${encodeURIComponent(name)}/unload`
|
||||
),
|
||||
|
||||
listMethods: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve(mockReflectionMethods(kind))
|
||||
: get<ReflectionMethod[]>(`/methods/${kind}/${id}`),
|
||||
|
||||
listMethodsByType: (kind: ReflectionKind) => MOCK
|
||||
? Promise.resolve(mockReflectionMethodsByType(kind))
|
||||
: get<ReflectionTypeMethods[]>(`/methods-by-type/${kind}`),
|
||||
|
||||
getStatus: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve(mockReflectionStatus(kind, id))
|
||||
: get<ReflectionKv[]>(`/status/${kind}/${id}`),
|
||||
|
||||
getFields: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve(mockReflectionFields(kind, id))
|
||||
: get<ReflectionKv[]>(`/fields/${kind}/${id}`),
|
||||
|
||||
setField: (kind: ReflectionKind, id: number, field: string, value: string) => MOCK
|
||||
? Promise.resolve(mockReflectionSetField(kind, id, field, value))
|
||||
: post<{ kind: ReflectionKind; id: number; field: string; value: string }>(
|
||||
`/fields/${kind}/${id}/${encodeURIComponent(field)}`,
|
||||
{ value }
|
||||
),
|
||||
|
||||
deleteField: (kind: ReflectionKind, id: number, field: string) => MOCK
|
||||
? Promise.resolve(mockReflectionDeleteField(kind, id, field))
|
||||
: del<{ kind: ReflectionKind; id: number; field: string }>(
|
||||
`/fields/${kind}/${id}/${encodeURIComponent(field)}`
|
||||
),
|
||||
|
||||
getBundle: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve(mockReflectionBundle(kind, id))
|
||||
: get<{
|
||||
kind: string
|
||||
id: number
|
||||
typeName: string
|
||||
fullTypeName?: string
|
||||
assembly: string
|
||||
summary: ReflectionObject
|
||||
methods: ReflectionMethod[]
|
||||
status: ReflectionKv[]
|
||||
/** 扁平 key→value 兼容旧组件。 */
|
||||
fields: Record<string, string>
|
||||
/** 带 source/locked/typeName 的字段表,新版「对象管理」面板用。 */
|
||||
fieldList?: ReflectionKv[]
|
||||
}>(`/bundle/${kind}/${id}`),
|
||||
|
||||
execute: (kind: ReflectionKind, id: number, method: string, params?: Record<string, string | number | boolean>) => MOCK
|
||||
? Promise.resolve(mockReflectionExecute(
|
||||
kind,
|
||||
id,
|
||||
method,
|
||||
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
|
||||
))
|
||||
: post<{ returnValue: string }>(
|
||||
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
|
||||
params
|
||||
),
|
||||
|
||||
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
|
||||
gotoCarSite: (carId: number, siteId: number) => MOCK
|
||||
? Promise.resolve({ carId, siteId, message: `mock goto ${carId} -> ${siteId}` })
|
||||
: post<{ carId: number; siteId: number; message: string }>(
|
||||
`/car/${carId}/goto-site`,
|
||||
{ siteId }
|
||||
),
|
||||
|
||||
getScriptSource: (id: number): Promise<ScriptSourcePayload> => MOCK
|
||||
? Promise.resolve({
|
||||
id,
|
||||
name: `MockScript#${id}`,
|
||||
typeName: 'CarProgram',
|
||||
state: 'Running',
|
||||
script: '// mock script'
|
||||
})
|
||||
: get<ScriptSourcePayload>(`/scripts/${id}/source`),
|
||||
|
||||
getScriptExceptionStatus: (id: number): Promise<ScriptExceptionStatusPayload> => MOCK
|
||||
? Promise.resolve({
|
||||
id,
|
||||
name: `MockScript#${id}`,
|
||||
typeName: 'CarProgram',
|
||||
car: 'AGV-1(#1)',
|
||||
state: 'Running',
|
||||
exception: '(无)',
|
||||
notifies: '(无)',
|
||||
report: '=== CarProgram 异常状态报告 ===\nstate : Running\n\n--- exception ---\n(无)'
|
||||
})
|
||||
: get<ScriptExceptionStatusPayload>(`/scripts/${id}/exception-status`),
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 地图监控配置(Configuration.conf.monitorVisibility)
|
||||
// /monitor-config GET 现有配置 + 各 kind 可勾选 fields/status/methods 全集
|
||||
// /monitor-config POST body JSON 全量覆盖
|
||||
// 空白名单 = 显示全部;非空 = 仅显示列表中的 key。
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getMonitorConfig: (): Promise<MonitorConfigPayload> => MOCK
|
||||
? Promise.resolve({
|
||||
config: loadMockMonitorConfig(),
|
||||
available: emptyMonitorAvailableMap()
|
||||
})
|
||||
: get<MonitorConfigPayload>('/monitor-config'),
|
||||
|
||||
saveMonitorConfig: async (cfg: MonitorConfigSaveBody): Promise<MonitorConfigSaveBody> => {
|
||||
if (MOCK) {
|
||||
saveMockMonitorConfig(cfg)
|
||||
return cfg
|
||||
}
|
||||
const { data } = await http.post<ReflectionEnvelope<MonitorConfigSaveBody>>(
|
||||
`${BASE}/monitor-config`,
|
||||
cfg
|
||||
)
|
||||
if (!data?.success) throw new Error(data?.message ?? 'saveMonitorConfig failed')
|
||||
return data.data as MonitorConfigSaveBody
|
||||
},
|
||||
|
||||
// 选中同步:让 SimpleLite 3D 场景同步高亮被点击对象
|
||||
getSelection: () => MOCK
|
||||
? Promise.resolve<ReflectionSelection>({ kind: null, id: 0, name: '' })
|
||||
: get<ReflectionSelection>('/selection'),
|
||||
|
||||
setSelection: (kind: ReflectionKind, id: number) => MOCK
|
||||
? Promise.resolve({ kind, id, name: `mock(${kind}#${id})` })
|
||||
: post<{ kind: ReflectionKind; id: number; name: string }>('/selection', { kind, id }),
|
||||
|
||||
clearSelection: () => MOCK
|
||||
? Promise.resolve({ cleared: true })
|
||||
: post<{ cleared: boolean }>('/selection/clear'),
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 项目属性(Scene.conf 单例)
|
||||
// /project/fields GET → 列出 Scene.conf 全部 [FieldMember] 字段
|
||||
// /project/fields/{key} POST → ?value=xxx 写入单字段
|
||||
// /project/save POST → 把内存项目(含修改后的 conf)写回 JSON
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getProjectFields: (): Promise<ProjectPropertiesPayload> => MOCK
|
||||
? Promise.resolve({
|
||||
target: 'Scene.conf',
|
||||
lastLoadedPath: null,
|
||||
autoloadPath: null,
|
||||
fields: []
|
||||
})
|
||||
: get<ProjectPropertiesPayload>('/project/fields'),
|
||||
|
||||
setProjectField: (field: string, value: string) => MOCK
|
||||
? Promise.resolve({ field, value })
|
||||
: post<{ field: string; value: string }>(
|
||||
`/project/fields/${encodeURIComponent(field)}`,
|
||||
{ value }
|
||||
),
|
||||
|
||||
/** 保存当前项目到磁盘。path 为空则后端用 LastLoadedPath / Configuration.conf.autoload。 */
|
||||
saveProject: (path?: string) => MOCK
|
||||
? Promise.resolve({ path: path ?? '(mock)' })
|
||||
: post<{ path: string }>('/project/save', path ? { path } : undefined),
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 核心配置(simple.json / Configuration.conf)
|
||||
// /app-config/fields GET → 列出 Configuration.conf 全部 [FieldMember]
|
||||
// /app-config/fields/{key} POST → ?value=xxx 写入单字段
|
||||
// /app-config/save POST → 调 Configuration.ToFile("simple.json")
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getAppConfigFields: (): Promise<AppConfigPayload> => MOCK
|
||||
? Promise.resolve({ target: 'Configuration.conf', savePath: 'simple.json', fields: [] })
|
||||
: get<AppConfigPayload>('/app-config/fields'),
|
||||
|
||||
setAppConfigField: (field: string, value: string) => MOCK
|
||||
? Promise.resolve({ field, value })
|
||||
: post<{ field: string; value: string }>(
|
||||
`/app-config/fields/${encodeURIComponent(field)}`,
|
||||
{ value }
|
||||
),
|
||||
|
||||
saveAppConfig: () => MOCK
|
||||
? Promise.resolve({ path: 'simple.json' })
|
||||
: post<{ path: string }>('/app-config/save'),
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 车型样式(WorkspaceCarStylesByType / WorkspaceAlarmColorScheme)
|
||||
// /car-style/types GET 列出所有 Car 子类型样式
|
||||
// /car-style/{typeFullName} GET 单个车型当前样式
|
||||
// /car-style/{typeFullName} POST body JSON 全量覆盖
|
||||
// /car-style/{typeFullName} DELETE 恢复为默认
|
||||
// /car-style/alarm-colors GET 7 种报警键 → 颜色
|
||||
// /car-style/alarm-colors POST body JSON 全量覆盖映射
|
||||
// /car-style/save POST 写回 simple.json
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
getCarStyleTypes: () => MOCK
|
||||
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
||||
: get<CarStyleTypesPayload>('/car-style/types'),
|
||||
|
||||
getCarStyle: (typeFullName: string) => MOCK
|
||||
? Promise.resolve(defaultCarStyleDto())
|
||||
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
||||
|
||||
/** 用 axios 直接以 JSON body 提交,避免拼 query。 */
|
||||
putCarStyle: async (typeFullName: string, body: CarStyleDto) => {
|
||||
if (MOCK) return body
|
||||
const { data } = await http.post<ReflectionEnvelope<CarStyleDto>>(
|
||||
`${BASE}/car-style/${encodeURIComponent(typeFullName)}`,
|
||||
body
|
||||
)
|
||||
if (!data?.success) throw new Error(data?.message ?? 'putCarStyle failed')
|
||||
return data.data as CarStyleDto
|
||||
},
|
||||
|
||||
deleteCarStyle: (typeFullName: string) => MOCK
|
||||
? Promise.resolve({ typeFullName, removed: true })
|
||||
: del<{ typeFullName: string; removed: boolean }>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
||||
|
||||
getAlarmColors: () => MOCK
|
||||
? Promise.resolve<AlarmColorsPayload>({ defaultKeys: [], entries: [] })
|
||||
: get<AlarmColorsPayload>('/car-style/alarm-colors'),
|
||||
|
||||
putAlarmColors: async (palette: Record<string, number>) => {
|
||||
if (MOCK) return { count: Object.keys(palette).length }
|
||||
const { data } = await http.post<ReflectionEnvelope<{ count: number }>>(
|
||||
`${BASE}/car-style/alarm-colors`,
|
||||
palette
|
||||
)
|
||||
if (!data?.success) throw new Error(data?.message ?? 'putAlarmColors failed')
|
||||
return data.data as { count: number }
|
||||
},
|
||||
|
||||
saveCarStyle: () => MOCK
|
||||
? Promise.resolve({ path: 'simple.json' })
|
||||
: post<{ path: string }>('/car-style/save')
|
||||
}
|
||||
|
||||
export interface CarStyleDto {
|
||||
bodyLengthM: number
|
||||
bodyWidthM: number
|
||||
bodyColorArgb: number
|
||||
outlineColorArgb: number
|
||||
labelColorArgb: number
|
||||
showLabel: boolean
|
||||
modelPath: string
|
||||
}
|
||||
|
||||
export interface CarStyleTypeRow {
|
||||
typeName: string
|
||||
shortName: string
|
||||
label: string
|
||||
assemblyName: string
|
||||
hasOverride: boolean
|
||||
style: CarStyleDto
|
||||
}
|
||||
|
||||
export interface CarStyleTypesPayload {
|
||||
globalDefault: CarStyleDto
|
||||
types: CarStyleTypeRow[]
|
||||
}
|
||||
|
||||
export interface AlarmColorEntry {
|
||||
key: string
|
||||
colorArgb: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface AlarmColorsPayload {
|
||||
defaultKeys: string[]
|
||||
entries: AlarmColorEntry[]
|
||||
}
|
||||
|
||||
function defaultCarStyleDto(): CarStyleDto {
|
||||
return {
|
||||
bodyLengthM: 0.64,
|
||||
bodyWidthM: 0.42,
|
||||
bodyColorArgb: 0xffffffff,
|
||||
outlineColorArgb: 0xff2a2a2a,
|
||||
labelColorArgb: 0xffffffff,
|
||||
showLabel: true,
|
||||
modelPath: ''
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectPropertyRow {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
typeName: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export interface ProjectPropertiesPayload {
|
||||
target: string
|
||||
lastLoadedPath: string | null
|
||||
autoloadPath: string | null
|
||||
fields: ProjectPropertyRow[]
|
||||
}
|
||||
|
||||
export interface AppConfigPayload {
|
||||
target: string
|
||||
savePath: string
|
||||
fields: ProjectPropertyRow[]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import http from './http'
|
||||
import type { WorkbenchList, WorkbenchNav, SelectionDetail, DetailTab } from '@/types/workbench'
|
||||
import { mockWorkbenchList, mockSelectionDetail } from '@/mock/data/workbench'
|
||||
|
||||
// 与 api/* 其它模块统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。
|
||||
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
export async function listWorkbench(nav: WorkbenchNav): Promise<WorkbenchList> {
|
||||
if (MOCK) return mockWorkbenchList(nav)
|
||||
const { data } = await http.get<WorkbenchList>(`/sl/projection/workbench/${nav}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSelectionDetail(
|
||||
objectKind: string,
|
||||
objectId: string,
|
||||
tab: DetailTab
|
||||
): Promise<SelectionDetail> {
|
||||
if (MOCK) return mockSelectionDetail(objectKind, objectId, tab)
|
||||
const { data } = await http.get<SelectionDetail>('/sl/projection/selection/detail', {
|
||||
params: { objectKind, objectId, tab }
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import http from './http'
|
||||
|
||||
/**
|
||||
* 工作区画布工具栏 API(对应 SimpleLite `WorkspaceToolbarApiController`)。
|
||||
*
|
||||
* 历史背景(会话33):原 SimpleLite iframe 内 ImGui 底栏 (`Panel_4` /
|
||||
* `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`) 提供 8 个按钮,平台 iframe
|
||||
* 嵌入时该底栏会盖在画布上影响交互。本会话把这条底栏整体迁到 Vue 端
|
||||
* `WorkspaceCanvasToolbar.vue` 渲染,状态读写改走该 HTTP API。
|
||||
*
|
||||
* 路径前缀:`/sl/projection/toolbar` (Platform.Server YARP → SimpleLite EmbedIO :8222)
|
||||
*/
|
||||
|
||||
const BASE = '/sl/projection/toolbar'
|
||||
|
||||
export type AlignKey = 'sites' | 'cars' | 'tracks'
|
||||
export type SelectKey = 'tracks' | 'cars' | 'decor' | 'sites'
|
||||
export type DisplayKey = 'labels' | 'primitives' | 'cars'
|
||||
|
||||
export interface ToolbarRecordingEntry {
|
||||
fileName: string
|
||||
fileSizeBytes: number
|
||||
fileWriteTime: string
|
||||
note: string
|
||||
durationMs: number
|
||||
frameCount: number
|
||||
headerReadable: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface ToolbarRecordingState {
|
||||
isRecording: boolean
|
||||
mode: string
|
||||
isPlaying: boolean
|
||||
elapsedMs: number
|
||||
frameCount: number
|
||||
droppedFrames: number
|
||||
currentRecordingFile: string | null
|
||||
recordingsDirectory: string
|
||||
entries: ToolbarRecordingEntry[]
|
||||
}
|
||||
|
||||
export interface ToolbarLayer {
|
||||
name: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface ToolbarState {
|
||||
align: { sites: boolean; cars: boolean; tracks: boolean }
|
||||
select: { tracks: boolean; cars: boolean; decor: boolean; sites: boolean }
|
||||
display: { labels: boolean; primitives: boolean; cars: boolean }
|
||||
layers: ToolbarLayer[]
|
||||
recording: ToolbarRecordingState
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
success: boolean
|
||||
code: number
|
||||
data: T | null
|
||||
message: string
|
||||
}
|
||||
|
||||
function compatTogglePayload(key: string, value: boolean) {
|
||||
return { key, value, Key: key, Value: value }
|
||||
}
|
||||
|
||||
function compatLayerPayload(name: string, visible: boolean) {
|
||||
return { name, visible, Name: name, Visible: visible }
|
||||
}
|
||||
|
||||
function compatNotePayload(note: string) {
|
||||
return { note, Note: note }
|
||||
}
|
||||
|
||||
function compatFilePayload(fileName: string) {
|
||||
return { fileName, FileName: fileName }
|
||||
}
|
||||
|
||||
async function unwrap<T>(p: Promise<{ data: Envelope<T> }>): Promise<T> {
|
||||
const resp = (await p).data
|
||||
if (!resp?.success) throw new Error(resp?.message ?? '请求失败')
|
||||
if (resp.data == null) throw new Error('服务端返回空数据')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export const workspaceToolbarApi = {
|
||||
getState: () => unwrap<ToolbarState>(http.get(`${BASE}/state`)),
|
||||
|
||||
setAlign: (key: AlignKey, value: boolean) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/align`, compatTogglePayload(key, value))),
|
||||
|
||||
setSelect: (key: SelectKey, value: boolean) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/select`, compatTogglePayload(key, value))),
|
||||
|
||||
setDisplay: (key: DisplayKey, value: boolean) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/display`, compatTogglePayload(key, value))),
|
||||
|
||||
setLayerVisibility: (name: string, visible: boolean) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/layer`, compatLayerPayload(name, visible))),
|
||||
|
||||
startRecording: (note?: string) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/recording/start`, compatNotePayload(note ?? ''))),
|
||||
|
||||
stopRecording: () => unwrap<ToolbarState>(http.post(`${BASE}/recording/stop`)),
|
||||
|
||||
deleteRecording: (fileName: string) =>
|
||||
unwrap<ToolbarState>(http.delete(`${BASE}/recording/${encodeURIComponent(fileName)}`)),
|
||||
|
||||
startPlayback: (fileName: string) =>
|
||||
unwrap<ToolbarState>(http.post(`${BASE}/playback/start`, compatFilePayload(fileName))),
|
||||
|
||||
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<PermissionGuard widget-id="ConfigCenter">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="cpb-header">
|
||||
<span class="cpb-title">{{ title }}</span>
|
||||
<el-tag size="small" effect="plain">section={{ section }}</el-tag>
|
||||
<el-tag size="small" type="info" effect="plain">v{{ envelope?.version ?? '-' }}</el-tag>
|
||||
<el-tag size="small" type="success" effect="plain" v-if="envelope?.updatedAt">{{ relativeTime }}</el-tag>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
|
||||
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="description" class="cpb-desc">{{ description }}</p>
|
||||
|
||||
<div class="cpb-body">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never" body-style="padding: 12px">
|
||||
<template #header>表单</template>
|
||||
<slot :payload="payload" :update="update" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never" body-style="padding: 0">
|
||||
<template #header>JSON 预览(保存即下发占位)</template>
|
||||
<pre class="cpb-json">{{ jsonText }}</pre>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
</PermissionGuard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends object">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Refresh, Check } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PermissionGuard from '@/components/PermissionGuard.vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import type { ConfigEnvelope, ConfigSection } from '@/types/config'
|
||||
|
||||
const props = defineProps<{
|
||||
section: ConfigSection
|
||||
title: string
|
||||
description?: string
|
||||
defaults: T
|
||||
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
|
||||
normalizePayload?: (payload: T) => T
|
||||
/** 加载完成后二次合并(如从 SimpleLite 拉 monitor) */
|
||||
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
|
||||
/** 写入 Platform 之前先执行(如先落盘 SimpleLite monitor) */
|
||||
beforeSave?: (payload: T) => void | Promise<void>
|
||||
/** 保存成功后副作用(如再次同步 SimpleLite) */
|
||||
afterSave?: (payload: T) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const store = useConfigStore()
|
||||
const envelope = ref<ConfigEnvelope<T> | null>(null)
|
||||
const payload = ref<T>(JSON.parse(JSON.stringify(props.defaults)) as T)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const jsonText = computed(() => JSON.stringify(payload.value, null, 2))
|
||||
const relativeTime = computed(() => envelope.value?.updatedAt
|
||||
? new Date(envelope.value.updatedAt).toLocaleString('zh-CN')
|
||||
: '—')
|
||||
|
||||
function update(next: T) { payload.value = next }
|
||||
|
||||
function applyPayload(raw: T | undefined | null) {
|
||||
let next = raw
|
||||
? (JSON.parse(JSON.stringify(raw)) as T)
|
||||
: (JSON.parse(JSON.stringify(props.defaults)) as T)
|
||||
if (props.normalizePayload) next = props.normalizePayload(next)
|
||||
payload.value = next
|
||||
}
|
||||
|
||||
async function reloadFromServer(force: boolean, toastOnSuccess: boolean) {
|
||||
loading.value = true
|
||||
try {
|
||||
const env = await store.load<T>(props.section, force)
|
||||
envelope.value = env
|
||||
applyPayload(env.payload as T)
|
||||
if (props.afterLoad) await props.afterLoad(payload.value, update)
|
||||
if (toastOnSuccess) ElMessage.success('已重载')
|
||||
} catch (e) {
|
||||
ElMessage.error(`加载失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(force = false) {
|
||||
await reloadFromServer(force, force)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
let body = payload.value
|
||||
if (props.normalizePayload) body = props.normalizePayload(body)
|
||||
|
||||
if (props.beforeSave) {
|
||||
try {
|
||||
await props.beforeSave(body)
|
||||
} catch (e) {
|
||||
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let monitorSyncWarn = ''
|
||||
try {
|
||||
const env = await store.save<T>(props.section, body)
|
||||
envelope.value = env
|
||||
payload.value = body
|
||||
if (props.afterSave) {
|
||||
try {
|
||||
await props.afterSave(body)
|
||||
} catch (e) {
|
||||
monitorSyncWarn = `(平台配置已保存,但 SimpleLite 同步失败:${e instanceof Error ? e.message : String(e)})`
|
||||
}
|
||||
}
|
||||
await reloadFromServer(true, false)
|
||||
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 SimpleLite' : ''
|
||||
ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.section, () => reload())
|
||||
|
||||
onMounted(() => reload())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cpb-header { display: flex; align-items: center; gap: 8px; }
|
||||
.cpb-title { font-weight: 600; font-size: 15px; color: #fff; }
|
||||
.cpb-header .spacer { flex: 1; }
|
||||
.cpb-desc {
|
||||
color: rgba(232, 215, 245, 0.7);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cpb-body { margin-top: 4px; }
|
||||
.cpb-json {
|
||||
margin: 0; padding: 14px;
|
||||
font-size: 12px; line-height: 1.55;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
background: rgba(15, 4, 32, 0.55);
|
||||
color: #d6c5ee;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-card class="dtp-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="dtp-header">
|
||||
<span class="dtp-title">{{ title }}</span>
|
||||
<div class="dtp-actions">
|
||||
<el-input v-if="searchable" v-model="kw" :placeholder="searchPlaceholder" clearable size="small" style="width: 220px" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="filtered" stripe size="small" :max-height="maxHeight" border>
|
||||
<el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :width="c.width" :min-width="c.minWidth">
|
||||
<template #default="scope">
|
||||
<slot :name="`col-${c.prop}`" :row="scope.row">
|
||||
{{ scope.row[c.prop] }}
|
||||
</slot>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<slot name="extra-columns" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Column { prop: string; label: string; width?: number | string; minWidth?: number | string }
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
data: Array<Record<string, unknown>>
|
||||
columns: Column[]
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
maxHeight?: number | string
|
||||
}>()
|
||||
|
||||
const kw = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!props.searchable || !kw.value) return props.data
|
||||
const q = kw.value.trim().toLowerCase()
|
||||
return props.data.filter((row) =>
|
||||
Object.values(row).some((v) => String(v ?? '').toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dtp-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.dtp-title { font-weight: 600; }
|
||||
.dtp-actions { display: flex; gap: 8px; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<template v-if="visibility === 'interactive'">
|
||||
<slot />
|
||||
</template>
|
||||
<div v-else-if="visibility === 'readonly'" class="pg-readonly" :title="`${widgetId} 当前为只读`">
|
||||
<slot />
|
||||
<div class="pg-mask" />
|
||||
</div>
|
||||
<!-- hidden: 不渲染任何内容 -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{ widgetId: string }>()
|
||||
const auth = useAuthStore()
|
||||
const visibility = computed(() => auth.widgetOf(props.widgetId))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pg-readonly { position: relative; }
|
||||
.pg-mask {
|
||||
position: absolute; inset: 0; background: rgba(255, 255, 255, 0.3);
|
||||
pointer-events: all; cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<el-radio-group v-model="current" size="small" @change="onChange">
|
||||
<el-radio-button label="Platform">管理员</el-radio-button>
|
||||
<el-radio-button label="RCSMonitor">运营</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Scope } from '@/types/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 会话 45 AR-6:switchScope 已变成异步(调后端 /auth/switch-scope)。
|
||||
// computed.set 不能 await,只能 fire-and-forget 并在 catch 里弹错。
|
||||
const current = computed<Scope>({
|
||||
get: () => auth.scope ?? 'Platform',
|
||||
set: (v) => { void switchAndNavigate(v) }
|
||||
})
|
||||
|
||||
async function onChange(v: string | number | boolean | undefined) {
|
||||
await switchAndNavigate(v as Scope)
|
||||
}
|
||||
|
||||
async function switchAndNavigate(scope: Scope) {
|
||||
try {
|
||||
await auth.switchScope(scope)
|
||||
router.push(scope === 'Platform' ? '/admin/map-monitor' : '/monitor/map')
|
||||
} catch (err) {
|
||||
ElMessage.error((err as Error)?.message ?? `切换到 ${scope} 失败`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="theme-customizer">
|
||||
<p class="tc-hint">
|
||||
为各主题单独调色;修改后立即预览。当前生效主题会同步应用到全站。保存系统配置时一并写入本地。
|
||||
</p>
|
||||
<el-collapse v-model="expanded" accordion>
|
||||
<el-collapse-item v-for="t in ui.availableThemes" :key="t.id" :name="t.id">
|
||||
<template #title>
|
||||
<div class="tc-title-row">
|
||||
<span class="tc-swatch" :style="swatchStyle(t.id)" />
|
||||
<span class="tc-name">{{ t.name }}</span>
|
||||
<el-tag v-if="hasCustom(t.id)" size="small" type="warning" effect="plain">已自定义</el-tag>
|
||||
<el-tag v-if="t.id === ui.themeId" size="small" type="success" effect="plain">当前</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="tc-panel">
|
||||
<el-form label-width="100px" class="tc-form">
|
||||
<el-form-item
|
||||
v-for="f in THEME_COLOR_FIELDS"
|
||||
:key="f.key"
|
||||
:label="f.label">
|
||||
<div class="tc-picker-row">
|
||||
<el-color-picker
|
||||
:model-value="ui.getThemeColor(t.id, f.key)"
|
||||
color-format="hex"
|
||||
:predefine="predefineColors"
|
||||
@update:model-value="(v: string | null) => onPick(t.id, f.key, v)" />
|
||||
<span class="tc-hex">{{ ui.getThemeColor(t.id, f.key) }}</span>
|
||||
<span class="tc-field-hint">{{ f.hint }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="tc-actions">
|
||||
<el-button size="small" @click="ui.setThemeId(t.id)">切换到此主题</el-button>
|
||||
<el-button size="small" :disabled="!hasCustom(t.id)" @click="resetOne(t.id)">恢复默认</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div class="tc-footer">
|
||||
<el-button size="small" type="warning" plain :disabled="!anyCustom" @click="resetAll">
|
||||
重置全部主题配色
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { THEME_COLOR_FIELDS, type ThemeColorKey } from '@/styles/themeCustomize'
|
||||
import { findTheme } from '@/styles/themes'
|
||||
|
||||
const ui = useUiStore()
|
||||
const expanded = ref<string>(ui.themeId)
|
||||
|
||||
const predefineColors = [
|
||||
'#7c3aed', '#8b5cf6', '#c4b5fd', '#5b21b6', '#4c1d95',
|
||||
'#6366f1', '#a855f7', '#9333ea', '#1565c0', '#00796b',
|
||||
'#b71c1c', '#455a64', '#e65100'
|
||||
]
|
||||
|
||||
const anyCustom = computed(() =>
|
||||
ui.availableThemes.some((t) => hasCustom(t.id))
|
||||
)
|
||||
|
||||
function hasCustom(themeId: string): boolean {
|
||||
const o = ui.themeOverrides[themeId]
|
||||
return !!o && Object.keys(o).length > 0
|
||||
}
|
||||
|
||||
function swatchStyle(themeId: string) {
|
||||
const merged = ui.getMergedTheme(findTheme(themeId))
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${merged.preview} 0%, ${merged.previewAccent} 100%)`
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(themeId: string, key: ThemeColorKey, value: string | null) {
|
||||
if (!value) return
|
||||
ui.setThemeColor(themeId, key, value)
|
||||
if (themeId === ui.themeId) {
|
||||
ElMessage.success('已应用当前主题配色')
|
||||
}
|
||||
}
|
||||
|
||||
function resetOne(themeId: string) {
|
||||
ui.clearThemeOverrides(themeId)
|
||||
ElMessage.info('已恢复该主题默认配色')
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
ui.clearAllThemeOverrides()
|
||||
ElMessage.info('已恢复全部主题默认配色')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-customizer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tc-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mg-text-dim, rgba(255, 255, 255, 0.55));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tc-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.tc-swatch {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.tc-name {
|
||||
font-weight: 600;
|
||||
color: var(--mg-text-light, #fff);
|
||||
}
|
||||
.tc-panel {
|
||||
padding: 4px 8px 12px;
|
||||
}
|
||||
.tc-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tc-hex {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--mg-text-muted, rgba(255, 255, 255, 0.75));
|
||||
}
|
||||
.tc-field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--mg-text-dim, rgba(255, 255, 255, 0.45));
|
||||
}
|
||||
.tc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-left: 100px;
|
||||
}
|
||||
.tc-footer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="onSelect">
|
||||
<button class="theme-trigger" type="button" :title="`当前主题:${ui.activeTheme.name}`">
|
||||
<span class="swatch" :style="swatchStyle" />
|
||||
<span class="theme-label">{{ ui.activeTheme.name }}</span>
|
||||
<el-icon class="caret"><CaretBottom /></el-icon>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="theme-menu">
|
||||
<el-dropdown-item
|
||||
v-for="t in ui.availableThemes"
|
||||
:key="t.id"
|
||||
:command="t.id"
|
||||
:class="{ 'is-active': t.id === ui.themeId }">
|
||||
<span class="option-swatch" :style="optionSwatch(t)" />
|
||||
<span class="option-text">
|
||||
<span class="option-name">{{ t.name }}</span>
|
||||
<span class="option-desc">{{ t.description }}</span>
|
||||
</span>
|
||||
<el-icon v-if="t.id === ui.themeId" class="check"><Check /></el-icon>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CaretBottom, Check } from '@element-plus/icons-vue'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import type { ThemePreset } from '@/styles/themes'
|
||||
|
||||
const ui = useUiStore()
|
||||
|
||||
const swatchStyle = computed(() => ({
|
||||
background: `linear-gradient(135deg, ${ui.activeTheme.preview} 0%, ${ui.activeTheme.previewAccent} 100%)`
|
||||
}))
|
||||
|
||||
function optionSwatch(t: ThemePreset) {
|
||||
const merged = ui.getMergedTheme(t)
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${merged.preview} 0%, ${merged.previewAccent} 100%)`
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
ui.setThemeId(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 会话 N+2:把硬编码的白字 / 白底改成基于 --mg-* 主题变量,
|
||||
* 让 fame-lavender 浅紫主题下(header 白底)也能看清楚。
|
||||
* 默认深主题:--mg-text-light = #fff,--mg-glass-bg = rgba(255,255,255,0.06);
|
||||
* fame-lavender:--mg-text-light = #1a0f3d 深紫,--mg-glass-bg = rgba(255,255,255,0.95) 近纯白。 */
|
||||
.theme-trigger {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--mg-glass-border, rgba(255, 255, 255, 0.18));
|
||||
background: var(--mg-glass-bg, rgba(255, 255, 255, 0.05));
|
||||
color: var(--mg-text-light, rgba(255, 255, 255, 0.9));
|
||||
cursor: pointer;
|
||||
transition: var(--mg-transition, all 0.25s ease);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.theme-trigger:hover {
|
||||
background: rgba(var(--mg-accent-rgb), 0.18);
|
||||
border-color: rgba(var(--mg-accent-rgb), 0.45);
|
||||
color: var(--mg-text-light, #fff);
|
||||
}
|
||||
.swatch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 2px var(--mg-glass-border-hi, rgba(255, 255, 255, 0.25));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-label {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.caret {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 下拉面板项布局(teleport 到 body,需全局样式) */
|
||||
.theme-menu .el-dropdown-menu__item {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 240px;
|
||||
padding: 10px 16px !important;
|
||||
}
|
||||
.theme-menu .el-dropdown-menu__item.is-active {
|
||||
background: rgba(var(--mg-accent-rgb), 0.22) !important;
|
||||
}
|
||||
.theme-menu .option-swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.theme-menu .option-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.theme-menu .option-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.theme-menu .option-desc {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.theme-menu .check {
|
||||
color: var(--mg-accent);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="workspace-3d-wrap" :class="{ 'workspace-3d--canvas-only': canvasOnly || hideToolbar }">
|
||||
<div v-if="!(canvasOnly || hideToolbar)" class="workspace-3d-toolbar">
|
||||
<el-tag :type="readOnly ? 'info' : 'success'" effect="dark" size="small">
|
||||
{{ readOnly ? '只读' : '可交互' }}
|
||||
</el-tag>
|
||||
<span class="hint">webVRender · http://{{ resolvedHost }} · scope={{ scope }}</span>
|
||||
<div class="spacer" />
|
||||
<el-tag v-if="lastPick" type="warning" size="small">
|
||||
Pick ({{ lastPick.x.toFixed(0) }}, {{ lastPick.y.toFixed(0) }})
|
||||
</el-tag>
|
||||
<el-tag v-if="lastSelect.length" type="primary" size="small">
|
||||
Selected: {{ lastSelect.length }}
|
||||
</el-tag>
|
||||
<el-button size="small" :icon="Refresh" @click="reload">重载</el-button>
|
||||
<el-button size="small" :icon="FullScreen" @click="enterFullscreen">全屏</el-button>
|
||||
</div>
|
||||
<div ref="frameWrap" class="workspace-3d-frame">
|
||||
<iframe
|
||||
v-if="iframeSrc"
|
||||
ref="frame"
|
||||
:src="iframeSrc"
|
||||
class="workspace-3d-iframe"
|
||||
allow="fullscreen"
|
||||
@load="onReady" />
|
||||
<div v-if="!loaded" class="workspace-3d-mask">
|
||||
<el-icon class="is-loading" size="32"><Loading /></el-icon>
|
||||
<span>正在连接 webVRender (http://{{ resolvedHost }}) ...</span>
|
||||
<span class="muted">如长时间未加载,请确认 SimpleLite 已以 Web-Enabled 模式启动且 8223 端口可达。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Refresh, FullScreen, Loading } from '@element-plus/icons-vue'
|
||||
import type { Scope } from '@/types/auth'
|
||||
|
||||
interface PickEvent { x: number; y: number }
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
host?: string
|
||||
scope?: Scope
|
||||
token?: string
|
||||
readOnly?: boolean
|
||||
/** 嵌入平台页时传 true:SimpleLite 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
|
||||
embedUi?: boolean
|
||||
/**
|
||||
* 地图编辑器纯画布模式:比 embedUi 更裸,连底栏都不创建。
|
||||
* 设为 true 时 Vue 端会先调 /declareCanvasOnly,并在 iframe URL 上加 ?ui=canvas-only。
|
||||
* 适用于 MapEditorView,所有 UI(顶栏 / 工具栏 / 属性面板)由 Vue 接管。
|
||||
*/
|
||||
canvasOnly?: boolean
|
||||
/** 隐藏 Vue 端的顶部信息工具条(迷榖 / 重载 / 全屏)。地图编辑器内推荐设为 true,由 EditTopBar 接管。 */
|
||||
hideToolbar?: boolean
|
||||
}>(), {
|
||||
host: undefined,
|
||||
scope: 'Platform',
|
||||
token: '',
|
||||
readOnly: false,
|
||||
embedUi: false,
|
||||
canvasOnly: false,
|
||||
hideToolbar: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'pick', wp: PickEvent): void
|
||||
(e: 'select', names: string[]): void
|
||||
(e: 'ready'): void
|
||||
}>()
|
||||
|
||||
const frame = ref<HTMLIFrameElement>()
|
||||
const frameWrap = ref<HTMLDivElement>()
|
||||
const loaded = ref(false)
|
||||
const lastPick = ref<PickEvent | null>(null)
|
||||
const lastSelect = ref<string[]>([])
|
||||
const iframeSrc = ref<string>('')
|
||||
|
||||
const resolvedHost = computed(() => props.host ?? (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223')
|
||||
|
||||
const vrUrl = computed(() => {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('scope', props.scope ?? 'Platform')
|
||||
if (props.token) qs.set('token', props.token)
|
||||
qs.set('ro', props.readOnly ? '1' : '0')
|
||||
if (props.canvasOnly) qs.set('ui', 'canvas-only')
|
||||
else if (props.embedUi) qs.set('ui', 'embed')
|
||||
return `http://${resolvedHost.value}/?${qs.toString()}`
|
||||
})
|
||||
|
||||
/**
|
||||
* 嵌入 / canvas-only 模式必须在 webVRender 主页加载(即发起 WebSocket 连接)之前先打一次
|
||||
* 对应的 GET 声明端点,让 SimpleLite 把"下一个连入的 WebTerminal"打上对应模式标签。
|
||||
* 否则 SimpleLite 拿不到任何提示(CycleGUI 不会把 URL query 透传给 Terminal),
|
||||
* 会默认创建完整工作区面板(工作台 / 浮动条 / CAD / 底栏),违反平台 UI 接管的要求。
|
||||
*/
|
||||
async function declareEmbedIfNeeded() {
|
||||
const endpoint = props.canvasOnly
|
||||
? '/declareCanvasOnly'
|
||||
: props.embedUi
|
||||
? '/declareEmbedUi'
|
||||
: null
|
||||
if (!endpoint) return
|
||||
try {
|
||||
await fetch(`http://${resolvedHost.value}${endpoint}`, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
keepalive: true
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(`[Workspace3D] ${endpoint} 请求失败,SimpleLite 可能仍以完整面板模式启动:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareAndLoad() {
|
||||
loaded.value = false
|
||||
await declareEmbedIfNeeded()
|
||||
iframeSrc.value = vrUrl.value
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
loaded.value = true
|
||||
emit('ready')
|
||||
}
|
||||
|
||||
function onMessage(ev: MessageEvent) {
|
||||
if (!frame.value || ev.source !== frame.value.contentWindow) return
|
||||
const msg = ev.data
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
if (msg.type === 'workspace.pick' && msg.payload && typeof msg.payload.x === 'number') {
|
||||
lastPick.value = msg.payload
|
||||
emit('pick', msg.payload)
|
||||
} else if (msg.type === 'workspace.select' && Array.isArray(msg.payload)) {
|
||||
lastSelect.value = msg.payload as string[]
|
||||
emit('select', msg.payload as string[])
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
prepareAndLoad()
|
||||
}
|
||||
|
||||
function enterFullscreen() {
|
||||
const el = frameWrap.value
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) document.exitFullscreen()
|
||||
else el.requestFullscreen()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onMessage)
|
||||
prepareAndLoad()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
})
|
||||
|
||||
defineExpose({ reload, enterFullscreen })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-3d-wrap {
|
||||
display: flex; flex-direction: column; height: 100%; min-height: 480px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--mg-radius, 16px);
|
||||
background: rgba(15, 4, 32, 0.45);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
box-shadow: 0 8px 24px rgba(10, 2, 16, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||
overflow: hidden;
|
||||
transition: all .25s cubic-bezier(.25, .8, .25, 1);
|
||||
}
|
||||
.workspace-3d-wrap.workspace-3d--canvas-only {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.workspace-3d-toolbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 12px;
|
||||
color: rgba(232, 215, 245, 0.85);
|
||||
}
|
||||
.workspace-3d-toolbar .hint {
|
||||
color: rgba(201, 166, 227, 0.7);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.workspace-3d-toolbar .spacer { flex: 1; }
|
||||
.workspace-3d-frame {
|
||||
flex: 1; position: relative;
|
||||
background: radial-gradient(ellipse at 50% 40%, #2d1456 0%, #1a0930 50%, #0f0420 100%);
|
||||
}
|
||||
.workspace-3d-iframe {
|
||||
width: 100%; height: 100%; border: 0; background: transparent; display: block;
|
||||
}
|
||||
.workspace-3d-mask {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(var(--mg-primary-rgb), 0.35) 0%, rgba(var(--mg-bg-app-deep-rgb), 0.92) 80%),
|
||||
rgba(var(--mg-bg-app-deep-rgb), 0.85);
|
||||
color: rgba(232, 220, 255, 0.92);
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.workspace-3d-mask .muted { color: rgba(var(--mg-accent-rgb), 0.7); font-size: 12px; }
|
||||
.workspace-3d-mask .el-icon { color: var(--mg-accent); filter: drop-shadow(0 0 10px rgba(var(--mg-accent-rgb), 0.6)); }
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<section class="mmcg">
|
||||
<header class="mmcg-head">
|
||||
<span class="mmcg-title">{{ title }}</span>
|
||||
<el-tag size="small" effect="plain" type="info">{{ summaryText }}</el-tag>
|
||||
<div class="spacer" />
|
||||
<el-button-group v-if="available.length">
|
||||
<el-button size="small" :disabled="!canSelectAll" @click="selectAll">全选</el-button>
|
||||
<el-button size="small" :disabled="!selected.length" @click="clearAll">清空</el-button>
|
||||
</el-button-group>
|
||||
</header>
|
||||
|
||||
<p v-if="!available.length" class="mmcg-empty muted">{{ emptyTip }}</p>
|
||||
|
||||
<el-checkbox-group
|
||||
v-else
|
||||
:model-value="selected"
|
||||
class="mmcg-grid"
|
||||
@update:model-value="onChange">
|
||||
<el-checkbox v-for="key in available" :key="key" :value="key" :label="key" class="mmcg-cb" />
|
||||
</el-checkbox-group>
|
||||
|
||||
<!-- 处理"残留键":已勾选但不再出现在 available 里(后端实现变了 / 插件卸载了)-->
|
||||
<div v-if="orphanKeys.length" class="mmcg-orphans">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`检测到 ${orphanKeys.length} 个残留键(后端已经不再暴露,但仍在白名单中)`">
|
||||
<template #default>
|
||||
<div class="orphan-list">
|
||||
<el-tag
|
||||
v-for="key in orphanKeys"
|
||||
:key="key"
|
||||
type="warning"
|
||||
size="small"
|
||||
closable
|
||||
@close="removeOrphan(key)">
|
||||
{{ key }}
|
||||
</el-tag>
|
||||
<el-button link type="warning" size="small" @click="clearOrphans">一键清理全部残留</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CheckboxValueType } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
available: string[]
|
||||
selected: string[]
|
||||
emptyTip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selected', value: string[]): void
|
||||
}>()
|
||||
|
||||
const summaryText = computed(() => {
|
||||
if (!props.available.length) return '空'
|
||||
if (!props.selected.length) return `白名单空 = 显示全部 (${props.available.length})`
|
||||
return `${props.selected.length} / ${props.available.length}`
|
||||
})
|
||||
|
||||
// available 是后端最新扫描结果。selected 里可能有 "残留"——之前勾选过但后端现在不再返回(插件
|
||||
// 卸载 / 类型重命名)。这些键不会出现在 checkbox-group 里(避免渲染陌生项),但要单独列出来
|
||||
// 让用户决定是否清理;不主动删,避免误清"暂时离线的插件键"。
|
||||
const orphanKeys = computed(() =>
|
||||
props.selected.filter((k) => !props.available.includes(k))
|
||||
)
|
||||
|
||||
const canSelectAll = computed(() => props.available.length > props.selected.length)
|
||||
|
||||
function onChange(v: CheckboxValueType[]) {
|
||||
// element-plus 在泛型推断下会拿到 CheckboxValueType(string | number | boolean)。我们这里键
|
||||
// 全是 string,强制断言;同时把可能残留的 orphan 键并回去,避免「在某 kind 全选」操作把
|
||||
// orphan 默默丢掉(用户应该走"清理残留"显式确认)。
|
||||
const next = (v as string[]).filter((x) => typeof x === 'string')
|
||||
const merged = [...next, ...orphanKeys.value]
|
||||
emit('update:selected', merged)
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
emit('update:selected', [...props.available, ...orphanKeys.value])
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
// 仅清空 available 内的;orphan 保留(防误删,要走"残留清理"流程)
|
||||
emit('update:selected', [...orphanKeys.value])
|
||||
}
|
||||
|
||||
function removeOrphan(key: string) {
|
||||
emit('update:selected', props.selected.filter((k) => k !== key))
|
||||
}
|
||||
|
||||
function clearOrphans() {
|
||||
emit('update:selected', props.selected.filter((k) => props.available.includes(k)))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mmcg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mmcg-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mmcg-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
}
|
||||
.mmcg-head .spacer { flex: 1; }
|
||||
.mmcg-empty {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.62); }
|
||||
|
||||
.mmcg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 4px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.mmcg-grid :deep(.el-checkbox) {
|
||||
margin-right: 0;
|
||||
height: 28px;
|
||||
}
|
||||
.mmcg-grid :deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 12.5px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.mmcg-orphans { margin-top: 4px; }
|
||||
.orphan-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="AI 生图"
|
||||
width="640px"
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
|
||||
@close="onClose"
|
||||
>
|
||||
<el-alert v-if="!configured" type="warning" :closable="false" class="cfg-alert">
|
||||
尚未配置 AI 服务(apiKey / endpoint)。请先到
|
||||
<el-link type="primary" @click="goConfig">系统级配置 → AI 服务</el-link>
|
||||
完成配置。
|
||||
</el-alert>
|
||||
|
||||
<el-form label-width="100px" size="default">
|
||||
<el-form-item label="提示词" required>
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="一条 U 型生产线,含 5 个工站,间距 2m,方向单向通行..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="参考图片">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
@change="onPickImage"
|
||||
>
|
||||
<div class="upload-hint">
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<span>{{ form.referenceImageBase64 ? '已选择图片 (点击替换)' : '拖入或点击上传参考图(可选,多模态)' }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="生成模式">
|
||||
<el-radio-group v-model="form.mode">
|
||||
<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>
|
||||
</el-form-item>
|
||||
<el-form-item label="范围 (mm)">
|
||||
<el-input-number v-model="bounds[0]" placeholder="x1" :step="500" />
|
||||
<el-input-number v-model="bounds[1]" placeholder="y1" :step="500" class="ml" />
|
||||
<el-input-number v-model="bounds[2]" placeholder="x2" :step="500" class="ml" />
|
||||
<el-input-number v-model="bounds[3]" placeholder="y2" :step="500" class="ml" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图层">
|
||||
<el-input v-model="form.layer" placeholder="ai-generated(默认)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="附加约束">
|
||||
<el-input v-model="form.constraints" placeholder="例如:单向通行、最小转弯半径 1500mm" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-collapse v-if="result" v-model="resultPanel" class="result-panel">
|
||||
<el-collapse-item title="AI 返回 / 落地结果" name="r">
|
||||
<div v-if="result.assistantText" class="ai-text">{{ result.assistantText }}</div>
|
||||
<div class="ai-stat">
|
||||
落地对象 <b>{{ result.created?.length ?? 0 }}</b>,工具调用次数 <b>{{ result.usedTools }}</b>。
|
||||
</div>
|
||||
<el-table :data="result.created ?? []" size="small" max-height="220">
|
||||
<el-table-column label="工具" prop="tool" width="160" />
|
||||
<el-table-column label="kind" prop="kind" width="80" />
|
||||
<el-table-column label="id" prop="id" width="80" />
|
||||
<el-table-column label="typeName" prop="typeName" />
|
||||
<el-table-column label="错误">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.error" type="danger" size="small">{{ row.error }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="onClose">关闭</el-button>
|
||||
<el-button type="primary" :loading="busy" :disabled="!form.prompt.trim() || !configured" @click="onGenerate">
|
||||
生成并落地
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { mapEditApi, type AiMapGenerateRequest, type AiMapGenerateResult } from '@/api/mapEdit'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
configured: boolean
|
||||
/** 默认 bounds:x1, y1, x2, y2,mm。 */
|
||||
defaultBounds?: [number, number, number, number]
|
||||
/** 默认图层。 */
|
||||
defaultLayer?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'generated', r: AiMapGenerateResult): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const busy = ref(false)
|
||||
const result = ref<AiMapGenerateResult | null>(null)
|
||||
const resultPanel = ref<string[]>(['r'])
|
||||
|
||||
const form = reactive<AiMapGenerateRequest>({
|
||||
prompt: '',
|
||||
mode: 'sites+tracks',
|
||||
referenceImageBase64: undefined,
|
||||
layer: props.defaultLayer ?? 'ai-generated',
|
||||
constraints: ''
|
||||
})
|
||||
|
||||
const bounds = ref<[number, number, number, number]>(
|
||||
props.defaultBounds ?? [-10000, -10000, 10000, 10000]
|
||||
)
|
||||
|
||||
watch(() => props.defaultBounds, (nv) => { if (nv) bounds.value = nv as [number, number, number, number] })
|
||||
watch(() => props.defaultLayer, (nv) => { if (nv) form.layer = nv })
|
||||
|
||||
function onClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function goConfig() {
|
||||
emit('update:modelValue', false)
|
||||
router.push('/admin/config/system')
|
||||
}
|
||||
|
||||
async function onPickImage(f: UploadFile) {
|
||||
if (!f.raw) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '')
|
||||
const base64 = result.includes(',') ? result.split(',', 2)[1]! : result
|
||||
form.referenceImageBase64 = base64
|
||||
}
|
||||
reader.readAsDataURL(f.raw)
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
busy.value = true
|
||||
form.bounds = bounds.value
|
||||
const r = await mapEditApi.aiMapGenerate(form)
|
||||
result.value = r
|
||||
ElMessage.success(`AI 已生成 ${r.created?.length ?? 0} 个对象`)
|
||||
emit('generated', r)
|
||||
} catch (err) {
|
||||
ElMessage.error(`AI 生图失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cfg-alert { margin-bottom: 12px; }
|
||||
.ml { margin-left: 6px; }
|
||||
/* dialog 内文字色统一用 inherit / CSS 变量,避免白底主题下出现 "白字白底" 看不清。 */
|
||||
.upload-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--el-text-color-regular, rgba(232,215,245,0.7));
|
||||
}
|
||||
.result-panel { margin-top: 8px; }
|
||||
.ai-text {
|
||||
color: var(--el-text-color-primary, rgba(232,215,245,0.85));
|
||||
padding: 6px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-stat {
|
||||
padding: 6px 0;
|
||||
color: var(--el-text-color-primary, rgba(232,215,245,0.85));
|
||||
}
|
||||
.ai-stat b { color: var(--mg-accent, #c4a4ff); }
|
||||
</style>
|
||||
@@ -0,0 +1,851 @@
|
||||
<template>
|
||||
<div class="edit-property-panel">
|
||||
<el-tabs v-model="tab" class="prop-tabs">
|
||||
<el-tab-pane label="对象" name="object">
|
||||
<div v-if="selectionCount === 0" class="empty-hint">
|
||||
<el-icon class="empty-icon"><InfoFilled /></el-icon>
|
||||
<span class="empty-title">未选中对象</span>
|
||||
<span class="empty-desc">在画布上点击或框选以编辑属性。</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-hint">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载属性…</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="object-pane">
|
||||
<!-- Header card:ID / 类型 / 名称 / 图层 / 删除按钮 -->
|
||||
<section class="prop-card prop-card--header">
|
||||
<div class="prop-card-row">
|
||||
<div class="prop-id-block">
|
||||
<span class="prop-id-tag">{{ selectionCount > 1 ? `多选 ${selectionCount}` : `#${primary?.id ?? '-'}` }}</span>
|
||||
<span class="prop-type-tag">{{ primary?.typeName ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="prop-header-actions">
|
||||
<el-tag
|
||||
v-if="primary?.status"
|
||||
:type="statusTagType"
|
||||
size="small"
|
||||
effect="dark"
|
||||
class="prop-status-tag"
|
||||
>
|
||||
{{ primary.status }}
|
||||
</el-tag>
|
||||
<el-tooltip
|
||||
:content="selectionCount > 1 ? `删除选中的 ${selectionCount} 个对象(可 Ctrl+Z 撤销)` : '删除该对象(可 Ctrl+Z 撤销)'"
|
||||
placement="top"
|
||||
:show-after="220"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="prop-delete-btn"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
<span class="prop-delete-icon">{{ deleting ? '⟳' : '🗑' }}</span>
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prop-card-row prop-name-row">
|
||||
<label class="prop-name-label">名称</label>
|
||||
<el-input
|
||||
v-if="primary"
|
||||
:model-value="editingName"
|
||||
size="small"
|
||||
placeholder="对象名"
|
||||
@update:model-value="(v: string) => editingName = v"
|
||||
@blur="onPrimaryNameBlur"
|
||||
@change="onPrimaryNameSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="prop-card-row prop-layer-row">
|
||||
<label class="prop-name-label">图层</label>
|
||||
<el-select
|
||||
v-if="primary"
|
||||
:model-value="primary.layer ?? ''"
|
||||
size="small"
|
||||
placeholder="—"
|
||||
class="prop-layer-select"
|
||||
@update:model-value="(v: any) => onPrimaryLayerChange(v)"
|
||||
>
|
||||
<el-option label="(无)" value="" />
|
||||
<el-option v-for="l in layers" :key="l" :label="l" :value="l" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 几何 / 位置:x, y, z, size, angle, rotation, scale, radius, siteA, siteB, ... -->
|
||||
<section v-if="positionFields.length > 0" class="prop-card prop-card--geom">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">位置 / 几何</span>
|
||||
<span class="prop-card-flag" :class="{ 'is-synced': syncFlash }" :title="syncFlash ? '已同步到画布' : '改动会立即同步到 SimpleLite 画布'">
|
||||
<span class="prop-card-flag-dot" />
|
||||
<span class="prop-card-flag-text">{{ syncFlash ? '已同步' : '同步到画布' }}</span>
|
||||
</span>
|
||||
<span class="prop-card-meta">{{ positionFields.length }} 项</span>
|
||||
</header>
|
||||
<div class="prop-grid">
|
||||
<div v-for="f in positionFields" :key="f.key" class="prop-grid-item">
|
||||
<label class="prop-field-label">
|
||||
{{ fieldDisplayLabel(f.key) }}
|
||||
<span v-if="fieldUnit(f.key)" class="prop-field-unit">{{ fieldUnit(f.key) }}</span>
|
||||
</label>
|
||||
<el-input-number
|
||||
v-if="isNumberField(f.key)"
|
||||
:model-value="numericValue(f.value)"
|
||||
:controls="false"
|
||||
:step="numericStep(f.key)"
|
||||
size="small"
|
||||
class="prop-field-num"
|
||||
@change="(v: number | undefined) => onFieldChange(f.key, v == null ? '' : String(v))"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="f.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange(f.key, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 样式:颜色、显示开关 -->
|
||||
<section v-if="styleFields.length > 0" class="prop-card prop-card--style">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">样式 / 外观</span>
|
||||
<span class="prop-card-meta">{{ styleFields.length }} 项</span>
|
||||
</header>
|
||||
<div class="prop-grid">
|
||||
<div v-for="f in styleFields" :key="f.key" class="prop-grid-item">
|
||||
<label class="prop-field-label">{{ fieldDisplayLabel(f.key) }}</label>
|
||||
<el-color-picker
|
||||
v-if="isColorField(f.key, f.value)"
|
||||
:model-value="f.value || '#ffffff'"
|
||||
show-alpha
|
||||
size="small"
|
||||
@change="(v: string | null) => onFieldChange(f.key, v ?? '')"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="isBooleanField(f.key, f.value)"
|
||||
:model-value="parseBool(f.value)"
|
||||
size="small"
|
||||
@change="(v: any) => onFieldChange(f.key, v ? 'true' : 'false')"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="f.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange(f.key, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 其它 / 自定义字段(兜底,可添加) -->
|
||||
<section class="prop-card prop-card--custom">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">其它字段 / 自定义</span>
|
||||
<span class="prop-card-meta">{{ otherFields.length }} 项</span>
|
||||
</header>
|
||||
<div v-if="otherFields.length > 0" class="prop-grid">
|
||||
<div v-for="f in otherFields" :key="f.key" class="prop-grid-item prop-grid-item--wide">
|
||||
<label class="prop-field-label" :title="f.key">{{ f.key }}</label>
|
||||
<div class="prop-field-row">
|
||||
<el-input
|
||||
:model-value="f.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange(f.key, v)"
|
||||
/>
|
||||
<el-tooltip content="删除该字段" placement="top">
|
||||
<button
|
||||
class="prop-field-icon-btn"
|
||||
@click="onFieldChange(f.key, '')"
|
||||
>×</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prop-empty-row">无其它字段</div>
|
||||
<button class="prop-add-field-btn" @click="onAddField">+ 添加字段</button>
|
||||
</section>
|
||||
|
||||
<!-- 运行状态(只读) -->
|
||||
<section v-if="status.length > 0" class="prop-card prop-card--status">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">运行状态</span>
|
||||
<span class="prop-card-meta">{{ status.length }} 项</span>
|
||||
</header>
|
||||
<div class="prop-status-list">
|
||||
<div v-for="s in status" :key="s.key" class="prop-status-item">
|
||||
<span class="prop-status-key">{{ s.key }}</span>
|
||||
<span class="prop-status-value" :title="s.value">{{ s.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作 -->
|
||||
<section v-if="methods.length > 0" class="prop-card prop-card--actions">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">操作</span>
|
||||
<span class="prop-card-meta">{{ methods.length }} 项</span>
|
||||
</header>
|
||||
<div class="prop-actions">
|
||||
<el-tooltip
|
||||
v-for="m in methods"
|
||||
:key="m.methodName"
|
||||
:content="m.description || m.label || m.methodName"
|
||||
placement="top"
|
||||
:show-after="220"
|
||||
>
|
||||
<button
|
||||
class="prop-action-btn"
|
||||
:disabled="actionBusy === m.methodName"
|
||||
@click="onAction(m.methodName)"
|
||||
>
|
||||
<span v-if="actionBusy === m.methodName" class="prop-action-spinner">⟳</span>
|
||||
<span class="prop-action-icon">▶</span>
|
||||
<span class="prop-action-label">{{ m.label || m.methodName }}</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="类型默认" name="defaults">
|
||||
<el-form label-width="120px" size="small" class="defaults-form">
|
||||
<el-alert type="info" :closable="false">
|
||||
为「新建站点 / 新建路径」预设默认样式与字段。保存后下次创建即生效。
|
||||
</el-alert>
|
||||
<el-divider content-position="left">站点</el-divider>
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.site.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名字前缀">
|
||||
<el-input v-model="defaults.site.namePrefix" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.site.color" show-alpha />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">路径</el-divider>
|
||||
<el-form-item label="默认图层">
|
||||
<el-input v-model="defaults.track.layer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认线宽">
|
||||
<el-input-number v-model="defaults.track.lineWidth" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-color-picker v-model="defaults.track.color" show-alpha />
|
||||
</el-form-item>
|
||||
<el-button size="small" type="primary" @click="onSaveDefaults">保存默认</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="图层" name="layers">
|
||||
<el-table :data="layerRows" size="small" class="layer-table">
|
||||
<el-table-column prop="name" label="图层" width="120" />
|
||||
<el-table-column label="可见" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.visible" @change="onLayerToggle(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可选" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.selectable" @change="onLayerToggle(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="颜色">
|
||||
<template #default="{ row }">
|
||||
<el-color-picker v-model="row.color" @change="onLayerToggle(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-input v-model="newLayerName" size="small" placeholder="新图层名" class="new-layer">
|
||||
<template #append>
|
||||
<el-button @click="onAddLayer">添加</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoFilled, Loading } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ReflectionMethod, ReflectionObject } from '@/api/reflection'
|
||||
|
||||
interface PropFieldKv { key: string; value: string }
|
||||
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
|
||||
|
||||
const props = defineProps<{
|
||||
selectionCount: number
|
||||
primary: ReflectionObject | null
|
||||
fields: PropFieldKv[]
|
||||
status: PropFieldKv[]
|
||||
methods: ReflectionMethod[]
|
||||
loading: boolean
|
||||
layers: string[]
|
||||
defaults: {
|
||||
site: { layer: string; namePrefix: string; color: string }
|
||||
track: { layer: string; lineWidth: number; color: string }
|
||||
}
|
||||
layerRows: LayerRow[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename', name: string): void
|
||||
(e: 'change-layer', layer: string): void
|
||||
(e: 'set-field', key: string, value: string): void
|
||||
(e: 'add-field'): void
|
||||
(e: 'exec-action', method: string): void
|
||||
(e: 'save-defaults'): void
|
||||
(e: 'layer-toggle', row: LayerRow): void
|
||||
(e: 'add-layer', name: string): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const tab = ref<'object' | 'defaults' | 'layers'>('object')
|
||||
const actionBusy = ref<string | null>(null)
|
||||
const newLayerName = ref('')
|
||||
const deleting = ref(false)
|
||||
const syncFlash = ref(false)
|
||||
let syncFlashTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function flashSync() {
|
||||
syncFlash.value = true
|
||||
if (syncFlashTimer) clearTimeout(syncFlashTimer)
|
||||
syncFlashTimer = setTimeout(() => { syncFlash.value = false }, 1200)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// 字段分组
|
||||
//
|
||||
// 后端 ReflectionApiController/MapEditApiController 对每个被 [FieldMember] 标的
|
||||
// public 字段都吐出来(含 `id / name / layerName / x / y / z / color /
|
||||
// displaySetting / radius / direction / typeInfo / siteA / siteB / mustFree / ...`)。
|
||||
// 这里的策略:
|
||||
// - 顶部 Header 卡片已经覆盖 id / name / layerName / status,所以从 fields 中剔除这些;
|
||||
// - 「位置 / 几何」吃掉所有跟坐标 / 角度 / 大小 / 半径 / 端点站点强相关的 key;
|
||||
// - 「样式 / 外观」吃掉 color / displaySetting / direction / typeInfo / mustFree / 文字渲染相关;
|
||||
// - 剩下的统统进「其它字段」(含用户 [FieldMember] 自定义、tag 反序、随便加上去的 free-form key)。
|
||||
// 这样不管后端塞什么字段进来都能露出来 + 可编辑。
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEADER_KEYS = new Set(['id', 'name', 'layerName'])
|
||||
|
||||
const POSITION_KEYS = new Set([
|
||||
'x', 'y', 'z',
|
||||
'cx', 'cy', 'cz',
|
||||
'dx', 'dy', 'dz',
|
||||
'sx', 'sy', 'ex', 'ey',
|
||||
'startX', 'startY', 'endX', 'endY',
|
||||
'size', 'width', 'height', 'depth',
|
||||
'angle', 'rotation', 'theta',
|
||||
'scale',
|
||||
'radius', 'r',
|
||||
'siteA', 'siteB'
|
||||
])
|
||||
|
||||
const STYLE_KEYS = new Set([
|
||||
'color',
|
||||
'displaySetting',
|
||||
'direction',
|
||||
'typeInfo',
|
||||
'mustFree',
|
||||
'imagePath',
|
||||
'modelPath',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'textColor',
|
||||
'lineWidth',
|
||||
'opacity'
|
||||
])
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
x: 'X',
|
||||
y: 'Y',
|
||||
z: 'Z',
|
||||
size: '大小',
|
||||
width: '宽',
|
||||
height: '高',
|
||||
depth: '厚',
|
||||
angle: '角度',
|
||||
rotation: '旋转',
|
||||
theta: 'θ',
|
||||
scale: '缩放',
|
||||
radius: '半径',
|
||||
siteA: '起点站',
|
||||
siteB: '终点站',
|
||||
startX: '起点 X',
|
||||
startY: '起点 Y',
|
||||
endX: '终点 X',
|
||||
endY: '终点 Y',
|
||||
cx: '中心 X',
|
||||
cy: '中心 Y',
|
||||
color: '颜色',
|
||||
displaySetting: '显示设置',
|
||||
direction: '方向',
|
||||
typeInfo: '类型信息',
|
||||
mustFree: '必须互斥',
|
||||
imagePath: '图片路径',
|
||||
modelPath: '模型路径',
|
||||
fontSize: '字号',
|
||||
textColor: '文字色',
|
||||
lineWidth: '线宽',
|
||||
opacity: '透明度'
|
||||
}
|
||||
|
||||
const FIELD_UNITS: Record<string, string> = {
|
||||
x: 'mm', y: 'mm', z: 'mm',
|
||||
cx: 'mm', cy: 'mm', cz: 'mm',
|
||||
dx: 'mm', dy: 'mm', dz: 'mm',
|
||||
startX: 'mm', startY: 'mm',
|
||||
endX: 'mm', endY: 'mm',
|
||||
width: 'mm', height: 'mm', depth: 'mm',
|
||||
size: 'mm', radius: 'mm',
|
||||
angle: '°', rotation: '°', theta: '°',
|
||||
fontSize: 'px', lineWidth: 'px'
|
||||
}
|
||||
|
||||
const editableFields = computed(() => props.fields.filter((f) => !HEADER_KEYS.has(f.key)))
|
||||
|
||||
const positionFields = computed(() => editableFields.value.filter((f) => POSITION_KEYS.has(f.key)))
|
||||
const styleFields = computed(() => editableFields.value.filter((f) => STYLE_KEYS.has(f.key)))
|
||||
const otherFields = computed(() =>
|
||||
editableFields.value.filter((f) => !POSITION_KEYS.has(f.key) && !STYLE_KEYS.has(f.key))
|
||||
)
|
||||
|
||||
function fieldDisplayLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? key
|
||||
}
|
||||
function fieldUnit(key: string): string {
|
||||
return FIELD_UNITS[key] ?? ''
|
||||
}
|
||||
|
||||
function numericValue(v: string): number | undefined {
|
||||
if (v == null || v === '') return undefined
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
function numericStep(key: string): number {
|
||||
if (key.toLowerCase().includes('angle') || key.toLowerCase().includes('rotation') || key === 'theta') return 1
|
||||
if (key === 'scale' || key === 'opacity') return 0.1
|
||||
return 10
|
||||
}
|
||||
function isNumberField(key: string): boolean {
|
||||
return POSITION_KEYS.has(key)
|
||||
}
|
||||
function isColorField(key: string, value: string): boolean {
|
||||
if (key === 'color' || key === 'textColor') return true
|
||||
return /^#[0-9a-fA-F]{3,8}$/.test(value)
|
||||
}
|
||||
function isBooleanField(_key: string, value: string): boolean {
|
||||
if (value === 'true' || value === 'false' || value === 'True' || value === 'False') return true
|
||||
return false
|
||||
}
|
||||
function parseBool(v: string): boolean {
|
||||
return v === 'true' || v === 'True' || v === '1'
|
||||
}
|
||||
|
||||
const statusTagType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
|
||||
const s = (props.primary?.status ?? '').toLowerCase()
|
||||
if (!s) return 'info'
|
||||
if (s.includes('error') || s.includes('alarm') || s.includes('failed')) return 'danger'
|
||||
if (s.includes('warn') || s.includes('idle')) return 'warning'
|
||||
if (s.includes('online') || s.includes('running') || s.includes('ready') || s.includes('ok')) return 'success'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
// 本地名称缓冲:避免 v-model 直接 mutate 父级传下的 primary props(违反 vue/no-mutating-props)。
|
||||
// 父级 primary 变化时同步进 editingName;用户改完按回车/失焦时 emit('rename') 向上抛新值。
|
||||
const editingName = ref<string>('')
|
||||
watch(() => props.primary?.name ?? '', (n) => { editingName.value = n }, { immediate: true })
|
||||
|
||||
function onPrimaryNameBlur() { onPrimaryNameSubmit() }
|
||||
function onPrimaryNameSubmit() {
|
||||
if (!props.primary) return
|
||||
const next = editingName.value
|
||||
if (next === (props.primary.name ?? '')) return
|
||||
emit('rename', next)
|
||||
}
|
||||
function onPrimaryLayerChange(v: string) {
|
||||
emit('change-layer', v)
|
||||
}
|
||||
function onFieldChange(key: string, value: string) {
|
||||
emit('set-field', key, value)
|
||||
if (POSITION_KEYS.has(key)) flashSync()
|
||||
}
|
||||
function onAddField() { emit('add-field') }
|
||||
async function onAction(method: string) {
|
||||
try {
|
||||
actionBusy.value = method
|
||||
emit('exec-action', method)
|
||||
} finally {
|
||||
actionBusy.value = null
|
||||
}
|
||||
}
|
||||
function onSaveDefaults() { emit('save-defaults') }
|
||||
function onLayerToggle(row: LayerRow) { emit('layer-toggle', row) }
|
||||
function onAddLayer() {
|
||||
const n = newLayerName.value.trim()
|
||||
if (!n) return
|
||||
emit('add-layer', n)
|
||||
newLayerName.value = ''
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (props.selectionCount === 0 || deleting.value) return
|
||||
// 仅置 busy;真正的删除由父组件 deleteSelection 走 history.run,
|
||||
// SSE object-deleted 回来后 selection 自动清空、本面板会被父组件解除。
|
||||
// 这里 100ms 后自动放开按钮,避免父组件 await 期间用户疯狂双击导致重复入栈。
|
||||
deleting.value = true
|
||||
emit('delete')
|
||||
setTimeout(() => { deleting.value = false }, 600)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-property-panel {
|
||||
height: 100%;
|
||||
width: 360px;
|
||||
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
|
||||
border-left: 1px solid rgba(255,255,255,0.10);
|
||||
padding: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: rgba(232,215,245,0.92);
|
||||
}
|
||||
.prop-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__nav-wrap)::after {
|
||||
background: transparent;
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__item) {
|
||||
color: rgba(220, 200, 240, 0.7);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: #fff;
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__active-bar) {
|
||||
background: linear-gradient(90deg, rgba(170, 110, 250, 1) 0%, rgba(255, 110, 220, 1) 100%);
|
||||
height: 2px;
|
||||
}
|
||||
.prop-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 56px 16px; text-align: center;
|
||||
color: rgba(220, 200, 240, 0.55);
|
||||
flex-direction: column;
|
||||
}
|
||||
.empty-icon { font-size: 28px; color: rgba(180, 130, 220, 0.45); }
|
||||
.empty-title { font-size: 13px; color: rgba(232, 215, 245, 0.75); font-weight: 500; }
|
||||
.empty-desc { font-size: 11.5px; color: rgba(220, 200, 240, 0.5); }
|
||||
.loading-hint {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 32px 16px; text-align: center;
|
||||
color: rgba(232,215,245,0.65); font-size: 12.5px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.object-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.prop-card {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.20);
|
||||
}
|
||||
.prop-card--header {
|
||||
background: linear-gradient(140deg, rgba(170, 110, 250, 0.18) 0%, rgba(70, 30, 110, 0.10) 100%);
|
||||
border-color: rgba(170, 110, 250, 0.28);
|
||||
}
|
||||
.prop-card--geom {
|
||||
background: linear-gradient(140deg, rgba(80, 140, 220, 0.10) 0%, rgba(40, 70, 130, 0.06) 100%);
|
||||
border-color: rgba(120, 170, 230, 0.22);
|
||||
}
|
||||
.prop-card--style {
|
||||
background: linear-gradient(140deg, rgba(220, 110, 200, 0.10) 0%, rgba(150, 70, 130, 0.06) 100%);
|
||||
border-color: rgba(230, 130, 200, 0.22);
|
||||
}
|
||||
.prop-card--custom {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(170, 110, 250, 0.18);
|
||||
}
|
||||
.prop-card--status { background: rgba(80, 140, 220, 0.06); border-color: rgba(120, 170, 230, 0.18); }
|
||||
.prop-card--actions { background: rgba(140, 80, 200, 0.06); border-color: rgba(170, 110, 250, 0.18); }
|
||||
|
||||
.prop-card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.prop-card-title {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(220, 180, 250, 0.85);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.prop-card-meta {
|
||||
font-size: 10.5px;
|
||||
color: rgba(200, 180, 220, 0.5);
|
||||
}
|
||||
.prop-card-flag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 10.5px;
|
||||
color: rgba(180, 200, 240, 0.6);
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
background: rgba(120, 160, 220, 0.10);
|
||||
transition: background .25s ease, color .25s ease, box-shadow .25s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
.prop-card-flag.is-synced {
|
||||
background: linear-gradient(120deg, rgba(80, 200, 130, 0.32), rgba(120, 220, 200, 0.32));
|
||||
color: rgba(220, 255, 230, 0.95);
|
||||
box-shadow: 0 0 8px rgba(80, 200, 130, 0.45);
|
||||
}
|
||||
.prop-card-flag-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgba(180, 200, 240, 0.6);
|
||||
transition: background .25s ease, box-shadow .25s ease;
|
||||
}
|
||||
.prop-card-flag.is-synced .prop-card-flag-dot {
|
||||
background: #6ce7a8;
|
||||
box-shadow: 0 0 6px rgba(108, 231, 168, 0.85);
|
||||
}
|
||||
.prop-card-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.prop-card-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.prop-id-block { display: flex; align-items: center; gap: 6px; }
|
||||
.prop-id-tag {
|
||||
background: rgba(170, 110, 250, 0.30);
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.prop-type-tag {
|
||||
color: rgba(232, 215, 245, 0.75);
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.prop-status-tag { font-weight: 500; }
|
||||
|
||||
.prop-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prop-delete-btn {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.4px;
|
||||
color: rgba(255, 200, 210, 0.95);
|
||||
background: linear-gradient(135deg, rgba(255, 90, 120, 0.18) 0%, rgba(220, 60, 110, 0.22) 100%);
|
||||
border: 1px solid rgba(255, 110, 140, 0.45);
|
||||
transition: background .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
|
||||
}
|
||||
.prop-delete-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(255, 90, 120, 0.55) 0%, rgba(220, 60, 110, 0.65) 100%);
|
||||
border-color: rgba(255, 140, 170, 0.85);
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px rgba(220, 70, 110, 0.45);
|
||||
}
|
||||
.prop-delete-btn:active:not(:disabled) { transform: scale(0.96); }
|
||||
.prop-delete-btn:disabled { opacity: 0.55; cursor: progress; }
|
||||
.prop-delete-icon { font-size: 12px; line-height: 1; }
|
||||
|
||||
.prop-name-row, .prop-layer-row { gap: 8px; }
|
||||
.prop-name-label {
|
||||
width: 36px;
|
||||
font-size: 11.5px;
|
||||
color: rgba(200, 180, 220, 0.7);
|
||||
flex: none;
|
||||
}
|
||||
.prop-layer-select { flex: 1; }
|
||||
|
||||
.prop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 8px;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.prop-grid-item { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.prop-grid-item--wide { grid-column: 1 / -1; }
|
||||
.prop-field-label {
|
||||
font-size: 11px;
|
||||
color: rgba(200, 180, 230, 0.78);
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prop-field-unit {
|
||||
font-size: 10px;
|
||||
color: rgba(170, 150, 200, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
flex: none;
|
||||
}
|
||||
.prop-field-num :deep(.el-input-number) { width: 100%; }
|
||||
.prop-field-num :deep(.el-input__wrapper) {
|
||||
background: rgba(255,255,255,0.06);
|
||||
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
|
||||
}
|
||||
.prop-field-row { display: flex; align-items: center; gap: 4px; }
|
||||
.prop-field-icon-btn {
|
||||
appearance: none; border: 0; cursor: pointer;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 100, 130, 0.12);
|
||||
color: rgba(255, 180, 200, 0.85);
|
||||
font-size: 14px; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: none;
|
||||
transition: background .14s, color .14s;
|
||||
}
|
||||
.prop-field-icon-btn:hover {
|
||||
background: rgba(255, 100, 130, 0.32);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prop-empty-row {
|
||||
font-size: 11.5px;
|
||||
color: rgba(200, 180, 220, 0.4);
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.prop-add-field-btn {
|
||||
appearance: none; border: 0; cursor: pointer;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(170, 110, 250, 0.14);
|
||||
border: 1px dashed rgba(170, 110, 250, 0.40);
|
||||
color: rgba(220, 180, 250, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: background .14s ease, border-color .14s ease, color .14s ease;
|
||||
}
|
||||
.prop-add-field-btn:hover {
|
||||
background: rgba(170, 110, 250, 0.26);
|
||||
border-color: rgba(170, 110, 250, 0.65);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prop-status-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.prop-status-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.prop-status-key { color: rgba(170, 200, 240, 0.85); font-weight: 500; }
|
||||
.prop-status-value {
|
||||
color: rgba(232, 245, 255, 0.95);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prop-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.prop-action-btn {
|
||||
appearance: none; border: 0; cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.20) 0%, rgba(120, 70, 220, 0.20) 100%);
|
||||
border: 1px solid rgba(170, 110, 250, 0.35);
|
||||
color: rgba(232, 215, 245, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex; align-items: center; justify-content: flex-start; gap: 6px;
|
||||
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
|
||||
text-align: left;
|
||||
}
|
||||
.prop-action-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(170, 110, 250, 0.62) 0%, rgba(255, 110, 220, 0.55) 100%);
|
||||
border-color: rgba(255, 130, 220, 0.7);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(180, 90, 220, 0.40);
|
||||
}
|
||||
.prop-action-btn:active:not(:disabled) { transform: scale(0.985); }
|
||||
.prop-action-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
.prop-action-icon {
|
||||
font-size: 9px;
|
||||
color: rgba(255, 200, 250, 0.85);
|
||||
flex: none;
|
||||
}
|
||||
.prop-action-spinner {
|
||||
display: inline-block;
|
||||
animation: prop-spin .8s linear infinite;
|
||||
font-size: 13px;
|
||||
}
|
||||
.prop-action-label {
|
||||
flex: 1;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes prop-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.defaults-form .el-button { margin-top: 8px; }
|
||||
.layer-table { margin: 6px 0; }
|
||||
.new-layer { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="edit-status-bar">
|
||||
<span class="seg">
|
||||
选中 <b>{{ selectedCount }}</b> 个
|
||||
</span>
|
||||
<span class="seg">
|
||||
鼠标 ({{ formatXY(mouseX) }}, {{ formatXY(mouseY) }}) mm
|
||||
</span>
|
||||
<span class="seg">
|
||||
当前工具:<b>{{ toolLabel }}</b>
|
||||
</span>
|
||||
<span class="seg">
|
||||
吸附:<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b>
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<span class="seg">历史 {{ undoSize }} / {{ redoSize }}</span>
|
||||
<span v-if="connected" class="seg ok">● 实时通道在线</span>
|
||||
<span v-else class="seg warn">● 实时通道断开</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
selectedCount: number
|
||||
mouseX: number
|
||||
mouseY: number
|
||||
toolLabel: string
|
||||
snapOn: boolean
|
||||
undoSize: number
|
||||
redoSize: number
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
function formatXY(v: number) {
|
||||
if (!Number.isFinite(v)) return '--'
|
||||
return v.toFixed(0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 14px;
|
||||
background: rgba(20, 8, 40, 0.85);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
color: rgba(232,215,245,0.85);
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
height: 28px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.seg b { color: var(--mg-accent, #c4a4ff); }
|
||||
.seg .on { color: #67c23a; }
|
||||
.seg .off { color: #a0a0a0; }
|
||||
.seg.ok { color: #67c23a; }
|
||||
.seg.warn { color: #f56c6c; }
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="edit-tool-rail">
|
||||
<el-scrollbar class="rail-scroll">
|
||||
<div v-for="(group, gi) in groups" :key="group.id" class="rail-group">
|
||||
<div class="rail-group-title">
|
||||
<span class="rail-group-bar" />
|
||||
<span class="rail-group-text">{{ group.title }}</span>
|
||||
<span class="rail-group-bar" />
|
||||
</div>
|
||||
<div class="rail-group-list">
|
||||
<el-tooltip
|
||||
v-for="t in group.tools"
|
||||
:key="t.id"
|
||||
:content="t.tip ?? t.label"
|
||||
placement="right"
|
||||
:show-after="280"
|
||||
>
|
||||
<button
|
||||
:class="['rail-btn', { 'rail-btn--active': isActive(t.id) }]"
|
||||
@click="onClick(t.id)"
|
||||
>
|
||||
<span class="rail-btn-glyph">{{ t.glyph }}</span>
|
||||
<span class="rail-btn-label">{{ t.label }}</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="gi < groups.length - 1" class="rail-divider" />
|
||||
</div>
|
||||
|
||||
<div class="rail-group rail-group-ai">
|
||||
<div class="rail-group-title">
|
||||
<span class="rail-group-bar" />
|
||||
<span class="rail-group-text">AI</span>
|
||||
<span class="rail-group-bar" />
|
||||
</div>
|
||||
<button class="rail-btn rail-btn--ai" @click="emit('open-ai')">
|
||||
<span class="rail-btn-glyph">✦</span>
|
||||
<span class="rail-btn-label">AI 生图</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EditToolId } from '@/composables/useEditTool'
|
||||
|
||||
interface ToolDef { id: EditToolId; label: string; glyph: string; tip?: string }
|
||||
interface ToolGroup { id: string; title: string; tools: ToolDef[] }
|
||||
|
||||
const props = defineProps<{
|
||||
activeTool: EditToolId
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-tool', id: EditToolId): void
|
||||
(e: 'open-ai'): void
|
||||
}>()
|
||||
|
||||
const groups: ToolGroup[] = [
|
||||
{
|
||||
id: 'select',
|
||||
title: '选择',
|
||||
tools: [
|
||||
{ id: 'select.single', label: '单选', glyph: '⌖', tip: '点击拾取单个对象' },
|
||||
{ id: 'select.rect', label: '矩形框选', glyph: '▱' },
|
||||
{ id: 'select.lasso', label: '套索圈选', glyph: '◯' },
|
||||
{ id: 'select.byType.site', label: '仅站点', glyph: '·' },
|
||||
{ id: 'select.byType.polyline', label: '仅折线', glyph: '╱', tip: '仅 UITrack(直线/折线)' },
|
||||
{ id: 'select.byType.bezier', label: '仅贝塞尔', glyph: '∽', tip: '仅 UIBezierTrack' },
|
||||
{ id: 'select.byType.arc', label: '仅弧形', glyph: '◜', tip: '仅 UICircularArcTrack' },
|
||||
{ id: 'select.byType.decor', label: '仅装饰', glyph: '★' },
|
||||
{ id: 'select.byType.currentLayer', label: '当前图层', glyph: '▤', tip: '保留当前图层对象' },
|
||||
{ id: 'select.invert', label: '反选', glyph: '⇄' },
|
||||
{ id: 'select.clear', label: '清空选中', glyph: '⊘' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'draw',
|
||||
title: '绘制',
|
||||
tools: [
|
||||
{ id: 'draw.site', label: '添加站点', glyph: '+', tip: '点击工具后在画布单击放置' },
|
||||
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或 Esc 退出' },
|
||||
{ id: 'draw.track.polyline', label: '折线路径', glyph: '─', tip: '依序点击两端站点' },
|
||||
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '∼', tip: '点两端站点 → 弹框输入中间控制点个数 → 连续点选每个控制点' },
|
||||
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点 → 输入半径(≥ |AB|/2,等于即半圆)' },
|
||||
{ id: 'draw.decor.line', label: '装饰线段', glyph: '╱' },
|
||||
{ id: 'draw.decor.rect', label: '装饰矩形', glyph: '▭' },
|
||||
{ id: 'draw.decor.circle', label: '装饰圆', glyph: '◯' },
|
||||
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T' },
|
||||
{ id: 'draw.image', label: '插入图片', glyph: '🖼' },
|
||||
{ id: 'draw.model', label: '插入模型', glyph: '◎', tip: 'CycleGUI 3D 模型 (obj/glb/gltf)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'transform',
|
||||
title: '变换',
|
||||
tools: [
|
||||
{ id: 'transform.move', label: '移动', glyph: '✥' },
|
||||
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
|
||||
{ id: 'transform.scale', label: '缩放', glyph: '⤡' },
|
||||
{ id: 'transform.duplicate', label: '复制副本', glyph: '⎘', tip: 'Ctrl+D' },
|
||||
{ id: 'transform.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' },
|
||||
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' },
|
||||
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' },
|
||||
{ id: 'transform.copyFields', label: '复制字段', glyph: '⇉', tip: '多选时第 1 个为源,其余为目标' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'align',
|
||||
title: '对齐',
|
||||
tools: [
|
||||
{ id: 'align.left', label: '左对齐', glyph: '⫷' },
|
||||
{ id: 'align.right', label: '右对齐', glyph: '⫸' },
|
||||
{ id: 'align.top', label: '上对齐', glyph: '⫵' },
|
||||
{ id: 'align.bottom', label: '下对齐', glyph: '⫶' },
|
||||
{ id: 'align.centerH', label: '水平居中', glyph: '⇔' },
|
||||
{ id: 'align.centerV', label: '垂直居中', glyph: '⇕' },
|
||||
{ id: 'align.center', label: '整体居中', glyph: '⊕' },
|
||||
{ id: 'align.distributeH', label: '水平等距', glyph: '⇿' },
|
||||
{ id: 'align.distributeV', label: '垂直等距', glyph: '⇳' },
|
||||
{ id: 'align.toFirst', label: '对齐到首', glyph: '⏮' },
|
||||
{ id: 'align.toLast', label: '对齐到尾', glyph: '⏭' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'batch',
|
||||
title: '批量生成',
|
||||
tools: [
|
||||
{ id: 'batch.linearH', label: '横向间距', glyph: '⇢' },
|
||||
{ id: 'batch.linearV', label: '纵向间距', glyph: '⇣' },
|
||||
{ id: 'batch.matrix', label: '矩阵生成', glyph: '#' },
|
||||
{ id: 'batch.alongPath', label: '沿路径采样', glyph: '⤳', tip: '先选 1+ 条路径,再点本工具' },
|
||||
{ id: 'batch.circular', label: '环形阵列', glyph: '◌' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'show',
|
||||
title: '显示',
|
||||
tools: [
|
||||
{ id: 'show.controlPoints', label: '控制点', glyph: '⊙' },
|
||||
{ id: 'show.controlHandles', label: '控制柄', glyph: '⊝' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function isActive(id: EditToolId) { return id === props.activeTool }
|
||||
function onClick(id: EditToolId) { emit('select-tool', id) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-tool-rail {
|
||||
height: 100%;
|
||||
width: 170px;
|
||||
background: linear-gradient(180deg, rgba(28, 12, 56, 0.85) 0%, rgba(18, 6, 36, 0.95) 100%);
|
||||
border-right: 1px solid rgba(255,255,255,0.10);
|
||||
padding: 4px 0 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: inset -1px 0 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.rail-scroll { height: 100%; }
|
||||
.rail-group {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 4px 8px 2px;
|
||||
}
|
||||
.rail-group-title {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(190, 160, 230, 0.7);
|
||||
text-transform: uppercase;
|
||||
margin: 4px 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-group-bar { flex: 1; height: 1px; background: rgba(190, 160, 230, 0.2); }
|
||||
.rail-group-list {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.rail-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.0) 100%);
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
.rail-btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: rgba(232,215,245,0.92);
|
||||
display: flex; align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
transition: background .14s ease, color .14s ease, box-shadow .14s ease, transform .12s ease;
|
||||
}
|
||||
.rail-btn:hover {
|
||||
background: rgba(150, 90, 230, 0.20);
|
||||
color: #fff;
|
||||
}
|
||||
.rail-btn:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.rail-btn.rail-btn--active {
|
||||
background: linear-gradient(120deg, rgba(170, 110, 250, 0.92) 0%, rgba(120, 70, 220, 0.92) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px rgba(140, 80, 230, 0.45), inset 0 0 0 1px rgba(255,255,255,0.18);
|
||||
}
|
||||
.rail-btn-glyph {
|
||||
width: 20px;
|
||||
display: inline-flex; justify-content: center; align-items: center;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
}
|
||||
.rail-btn-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rail-group-ai {
|
||||
margin-top: 14px;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.rail-group-ai::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px; right: 8px; top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(220, 140, 230, 0.35) 50%, rgba(255,255,255,0.0) 100%);
|
||||
}
|
||||
.rail-btn--ai {
|
||||
background: linear-gradient(135deg, rgba(120, 70, 220, 0.55) 0%, rgba(255, 90, 200, 0.55) 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(220, 90, 220, 0.30), inset 0 0 0 1px rgba(255,255,255,0.20);
|
||||
}
|
||||
.rail-btn--ai:hover {
|
||||
background: linear-gradient(135deg, rgba(150, 90, 240, 0.85) 0%, rgba(255, 110, 220, 0.85) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(230, 110, 230, 0.55), inset 0 0 0 1px rgba(255,255,255,0.30);
|
||||
}
|
||||
.rail-btn--ai .rail-btn-glyph {
|
||||
font-size: 16px;
|
||||
text-shadow: 0 0 8px rgba(255, 200, 250, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<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-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">导入</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="file.import.dwg">导入 .dwg/.dxf</el-dropdown-item>
|
||||
<el-dropdown-item command="file.import.geojson">导入 .geojson</el-dropdown-item>
|
||||
<el-dropdown-item command="file.upload.image" divided>导入图片</el-dropdown-item>
|
||||
<el-dropdown-item command="file.upload.model">导入 CycleGUI 模型</el-dropdown-item>
|
||||
<el-dropdown-item command="file.export.png" divided>导出 PNG</el-dropdown-item>
|
||||
<el-dropdown-item command="file.export.svg">导出 SVG</el-dropdown-item>
|
||||
<el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">编辑</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit.cut">剪切 (Ctrl+X)</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.copy">复制 (Ctrl+C)</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.paste">粘贴 (Ctrl+V)</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.delete">删除 (Del)</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.selectAll" divided>全选</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.invert">反选</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!--
|
||||
「添加」菜单:直接通过坐标 / 站点 ID 落地,不依赖画布 GetPoint。
|
||||
场景:iframe 模式下 SimpleUI.GetPoint 仅监听 LocalTerminal 时(或其他原因画布 pick 不响应),
|
||||
用户可走这条菜单先把站点 / 路径 / 模拟车造出来。
|
||||
-->
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">添加</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="add.site.byCoord">站点 (输入 x, y)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.polyline.byIds" divided>直线路径 (输入 siteA, siteB)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.bezier.byIds">贝塞尔路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.nurbs.byIds">NURBS 路径 (siteA/B + 控制点坐标)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.track.arc.byIds">弧形路径 (siteA/B + 半径)</el-dropdown-item>
|
||||
<el-dropdown-item command="add.car.dummy.byCoord" divided>模拟车 (输入 x, y, 朝向)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">撤销</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="edit.undo" :disabled="!canUndo">撤销 (Ctrl+Z)</el-dropdown-item>
|
||||
<el-dropdown-item command="edit.redo" :disabled="!canRedo">重做 (Ctrl+Y)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">视图</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="view.fitAll">缩放至全屏</el-dropdown-item>
|
||||
<el-dropdown-item command="view.fitSelection">缩放至选中</el-dropdown-item>
|
||||
<el-dropdown-item command="view.toggleGrid" divided>显示网格</el-dropdown-item>
|
||||
<el-dropdown-item command="view.toggleScale">显示比例尺</el-dropdown-item>
|
||||
<el-dropdown-item command="view.toggle2D">切换 2D / 3D</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">图层</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="layer.new">新建图层…</el-dropdown-item>
|
||||
<el-dropdown-item command="layer.toggleVisibility">切换显示图层…</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!--
|
||||
「过滤」菜单:对齐 SimpleLite 桌面底部工具栏的「选择 / 对齐 / 显示内容」三组开关。
|
||||
由 MapEditorView 维护 viewFilter 状态:
|
||||
- selectFilter.{sites,tracks,cars,decor}:未勾选的类型不会进入 selection(也就不会被高亮 + 不会出现在右侧属性面板),等价于 SimpleLite 的 SelectFilter*。
|
||||
- alignSnap.{sites,cars,tracks}:影响绘制工具吸附目标。
|
||||
- showViewport.{sceneLabels,scenePrimitives,cars}:影响右侧属性面板的类型筛选;画布渲染本身仍由 SimpleLite LayerToShow / 后端开关控制,等后续后端 API 打通后可双向同步。
|
||||
历史上「图层」菜单里的「站点层可见 / 可选」等三项是 stub,已删除并搬到这里。
|
||||
-->
|
||||
<el-dropdown trigger="click" @command="onCmd">
|
||||
<el-button text class="topbar-btn">过滤</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="topbar-filter-menu">
|
||||
<el-dropdown-item disabled class="topbar-filter-header">选择(可拾取)</el-dropdown-item>
|
||||
<el-dropdown-item command="view.select.sites">{{ mark(viewFilter.selectFilter.sites) }} 站点</el-dropdown-item>
|
||||
<el-dropdown-item command="view.select.tracks">{{ mark(viewFilter.selectFilter.tracks) }} 路径</el-dropdown-item>
|
||||
<el-dropdown-item command="view.select.cars">{{ mark(viewFilter.selectFilter.cars) }} AGV</el-dropdown-item>
|
||||
<el-dropdown-item command="view.select.decor">{{ mark(viewFilter.selectFilter.decor) }} 区域 / 装饰</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item divided disabled class="topbar-filter-header">对齐(鼠标吸附)</el-dropdown-item>
|
||||
<el-dropdown-item command="view.snap.sites">{{ mark(viewFilter.alignSnap.sites) }} 吸附站点</el-dropdown-item>
|
||||
<el-dropdown-item command="view.snap.cars">{{ mark(viewFilter.alignSnap.cars) }} 吸附 AGV</el-dropdown-item>
|
||||
<el-dropdown-item command="view.snap.tracks">{{ mark(viewFilter.alignSnap.tracks) }} 吸附路径</el-dropdown-item>
|
||||
|
||||
<el-dropdown-item divided disabled class="topbar-filter-header">显示内容</el-dropdown-item>
|
||||
<el-dropdown-item command="view.show.sceneLabels">{{ mark(viewFilter.showViewport.sceneLabels) }} 场景图元标记</el-dropdown-item>
|
||||
<el-dropdown-item command="view.show.scenePrimitives">{{ mark(viewFilter.showViewport.scenePrimitives) }} 场景图元</el-dropdown-item>
|
||||
<el-dropdown-item command="view.show.cars">{{ mark(viewFilter.showViewport.cars) }} 车辆</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<el-tooltip content="撤销 (Ctrl+Z)" placement="bottom">
|
||||
<el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')">↶</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="重做 (Ctrl+Y)" placement="bottom">
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface ViewFilterState {
|
||||
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
|
||||
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
|
||||
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
saving?: boolean
|
||||
// 由 MapEditorView 注入:「过滤」菜单的三组开关状态。父组件保证非空。
|
||||
viewFilter: ViewFilterState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cmd', cmd: string): void
|
||||
}>()
|
||||
|
||||
function onCmd(cmd: string) {
|
||||
emit('cmd', cmd)
|
||||
}
|
||||
|
||||
function mark(on: boolean): string {
|
||||
return on ? '☑' : '☐'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 14px;
|
||||
background: linear-gradient(180deg, rgba(50, 22, 88, 0.95) 0%, rgba(34, 14, 64, 0.95) 100%);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.el-divider--vertical { margin: 0 6px; background: rgba(255,255,255,0.15); }
|
||||
|
||||
.topbar-btn {
|
||||
color: #fdf6ff !important;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
padding: 6px 12px;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.topbar-btn:hover {
|
||||
color: #fff !important;
|
||||
background: rgba(170, 110, 250, 0.28) !important;
|
||||
}
|
||||
.topbar-btn:focus {
|
||||
background: rgba(170, 110, 250, 0.30) !important;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
color: rgba(253, 246, 255, 0.92) !important;
|
||||
background: rgba(255,255,255,0.05) !important;
|
||||
border: 1px solid rgba(255,255,255,0.15) !important;
|
||||
font-size: 15px;
|
||||
}
|
||||
.topbar-icon-btn:hover:not(.is-disabled) {
|
||||
background: rgba(170, 110, 250, 0.30) !important;
|
||||
border-color: rgba(170, 110, 250, 0.55) !important;
|
||||
}
|
||||
.topbar-icon-btn.is-disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.topbar-saving-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.topbar-filter-menu :deep(.topbar-filter-header) {
|
||||
font-size: 11px;
|
||||
color: rgba(232, 215, 245, 0.55);
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 2px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="780px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
class="proj-browse-dialog"
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
|
||||
@open="onOpen"
|
||||
>
|
||||
<div class="proj-browse">
|
||||
<!-- 顶栏:路径 + 快捷跳转 -->
|
||||
<div class="proj-browse-toolbar">
|
||||
<button class="proj-icon-btn" :disabled="!parent" title="返回上级" @click="goUp">
|
||||
<span class="proj-icon">←</span>
|
||||
</button>
|
||||
<button class="proj-icon-btn" title="刷新" @click="refresh">
|
||||
<span class="proj-icon">⟳</span>
|
||||
</button>
|
||||
<button class="proj-icon-btn" title="SimpleLite 主目录" @click="goCwd">
|
||||
<span class="proj-icon">⌂</span>
|
||||
</button>
|
||||
<el-input
|
||||
v-model="dir"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
class="proj-path-input"
|
||||
placeholder="输入或粘贴目录路径,回车跳转"
|
||||
@keyup.enter="refresh"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="proj-path-prefix">📁</span>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
:title="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="proj-error"
|
||||
/>
|
||||
|
||||
<!-- 双栏:左目录 / 右文件 -->
|
||||
<div class="proj-browse-body">
|
||||
<!-- 左栏:子目录 + 主目录 -->
|
||||
<div class="proj-browse-side">
|
||||
<div class="proj-side-title">服务端目录</div>
|
||||
<el-scrollbar class="proj-side-scroll">
|
||||
<button class="proj-side-btn proj-side-cwd" @click="goCwd">
|
||||
<span class="proj-side-icon">🏠</span>
|
||||
<span class="proj-side-text">SimpleLite 主目录</span>
|
||||
</button>
|
||||
<div v-if="parent" class="proj-side-section">
|
||||
<button class="proj-side-btn" @click="goUp">
|
||||
<span class="proj-side-icon">↑</span>
|
||||
<span class="proj-side-text">返回上级</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="proj-side-section">
|
||||
<div class="proj-side-section-title">子文件夹</div>
|
||||
<button
|
||||
v-for="d in subdirs"
|
||||
:key="d.fullPath"
|
||||
class="proj-side-btn"
|
||||
:class="{ 'proj-side-btn--active': false }"
|
||||
@dblclick="enterDir(d.fullPath)"
|
||||
@click="enterDir(d.fullPath)"
|
||||
>
|
||||
<span class="proj-side-icon">📁</span>
|
||||
<span class="proj-side-text" :title="d.name">{{ d.name }}</span>
|
||||
</button>
|
||||
<div v-if="subdirs.length === 0" class="proj-side-empty">无子文件夹</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:JSON 文件列表 -->
|
||||
<div class="proj-browse-main">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="jsons"
|
||||
size="small"
|
||||
height="380"
|
||||
highlight-current-row
|
||||
class="proj-browse-table"
|
||||
empty-text="该目录下没有 JSON 项目文件"
|
||||
@current-change="onRowSelect"
|
||||
@row-dblclick="onRowDbl"
|
||||
>
|
||||
<el-table-column width="36">
|
||||
<template #default>
|
||||
<span class="proj-file-icon">📄</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="proj-file-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="proj-mute">{{ formatSize(row.sizeBytes) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="modified" label="修改时间" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="proj-mute">{{ row.modified }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底栏:文件名输入框 + 类型筛选 -->
|
||||
<div class="proj-browse-footer">
|
||||
<span class="proj-footer-label">{{ mode === 'load' ? '选中文件' : '保存为' }}</span>
|
||||
<el-input
|
||||
v-model="manualPath"
|
||||
size="small"
|
||||
:placeholder="mode === 'load'
|
||||
? '在右侧选择 JSON 文件,或直接输入绝对路径'
|
||||
: '输入文件名(不写绝对路径则保存到当前目录)'"
|
||||
spellcheck="false"
|
||||
class="proj-manual-input"
|
||||
/>
|
||||
<span class="proj-footer-type">JSON (*.json)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="proj-footer-bar">
|
||||
<span class="proj-footer-tip">{{ footerTip }}</span>
|
||||
<span class="proj-footer-actions">
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="!canConfirm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ mode === 'load' ? '加载此文件' : '保存到此文件' }}
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { mapEditApi } from '@/api/mapEdit'
|
||||
|
||||
interface BrowseRow {
|
||||
kind: 'dir' | 'file'
|
||||
name: string
|
||||
fullPath: string
|
||||
sizeBytes?: number
|
||||
modified?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** 'load' 用于「加载文件」对话框;'save' 用于「另存为」对话框(可选中 .json 或新建文件名)。 */
|
||||
mode: 'load' | 'save'
|
||||
/** 上次记住的路径,用于打开时初始定位。空字符串则用 SimpleLite CWD。 */
|
||||
initialPath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'confirm', path: string): void
|
||||
}>()
|
||||
|
||||
const dir = ref('')
|
||||
const parent = ref<string | null>(null)
|
||||
const subdirs = ref<BrowseRow[]>([])
|
||||
const jsons = ref<BrowseRow[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const selected = ref<BrowseRow | null>(null)
|
||||
const manualPath = ref('')
|
||||
const cwdRoot = ref('')
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.mode === 'load' ? '加载项目(服务端目录)' : '保存项目(服务端目录)')
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (manualPath.value.trim()) return true
|
||||
if (selected.value?.kind === 'file') return true
|
||||
if (props.mode === 'save' && dir.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const footerTip = computed(() => {
|
||||
if (props.mode === 'save')
|
||||
return manualPath.value
|
||||
? `将保存到:${resolveSavePath()}`
|
||||
: `当前目录:${dir.value}`
|
||||
return selected.value
|
||||
? `已选:${selected.value.fullPath}`
|
||||
: '请在右侧选中一个 JSON 文件,或输入绝对路径'
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r = await mapEditApi.projectBrowse(dir.value || undefined)
|
||||
dir.value = r.dir
|
||||
parent.value = r.parent
|
||||
subdirs.value = r.subdirs.map((d) => ({ kind: 'dir' as const, name: d.name, fullPath: d.fullPath }))
|
||||
jsons.value = r.jsons.map((f) => ({
|
||||
kind: 'file' as const,
|
||||
name: f.name,
|
||||
fullPath: f.fullPath,
|
||||
sizeBytes: f.sizeBytes,
|
||||
modified: f.modified
|
||||
}))
|
||||
if (!cwdRoot.value) cwdRoot.value = r.dir
|
||||
selected.value = null
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (!parent.value) return
|
||||
dir.value = parent.value
|
||||
refresh()
|
||||
}
|
||||
|
||||
function goCwd() {
|
||||
dir.value = ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function enterDir(full: string) {
|
||||
dir.value = full
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onRowSelect(row: BrowseRow | null) {
|
||||
selected.value = row
|
||||
if (row?.kind === 'file') manualPath.value = row.fullPath
|
||||
}
|
||||
|
||||
function onRowDbl(row: BrowseRow) {
|
||||
if (row.kind === 'file') onConfirm()
|
||||
}
|
||||
|
||||
function resolveSavePath() {
|
||||
const m = manualPath.value.trim()
|
||||
if (!m) return `${dir.value}/project.current.json`
|
||||
// 如果用户写的是完整路径,直接用;否则当作 dir + filename。
|
||||
if (/^([A-Za-z]:[\\/]|\\\\|\/)/.test(m)) return m
|
||||
return `${dir.value.replace(/[\\/]+$/, '')}/${m.endsWith('.json') ? m : m + '.json'}`
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (props.mode === 'save') {
|
||||
const path = resolveSavePath()
|
||||
if (!path) {
|
||||
ElMessage.warning('请输入文件名或选中目标')
|
||||
return
|
||||
}
|
||||
emit('confirm', path)
|
||||
emit('update:modelValue', false)
|
||||
return
|
||||
}
|
||||
|
||||
const path = manualPath.value.trim() || (selected.value?.kind === 'file' ? selected.value.fullPath : '')
|
||||
if (!path) {
|
||||
ElMessage.warning('请先选中一个 .json 文件,或直接输入路径')
|
||||
return
|
||||
}
|
||||
emit('confirm', path)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
selected.value = null
|
||||
manualPath.value = ''
|
||||
dir.value = props.initialPath ?? ''
|
||||
refresh()
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | undefined) {
|
||||
if (bytes == null) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) onOpen()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proj-browse-dialog :deep(.el-dialog__header) {
|
||||
padding: 14px 20px;
|
||||
margin-right: 0;
|
||||
border-bottom: 1px solid rgba(170, 110, 250, 0.15);
|
||||
}
|
||||
.proj-browse-dialog :deep(.el-dialog__title) {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(60, 30, 110, 0.95);
|
||||
}
|
||||
.proj-browse-dialog :deep(.el-dialog__body) {
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
|
||||
.proj-browse {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proj-browse-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.proj-icon-btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(170, 110, 250, 0.25);
|
||||
background: rgba(245, 235, 255, 0.65);
|
||||
color: rgba(80, 40, 130, 0.85);
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
transition: background .14s, border-color .14s, color .14s;
|
||||
}
|
||||
.proj-icon-btn:hover:not(:disabled) {
|
||||
background: rgba(170, 110, 250, 0.20);
|
||||
border-color: rgba(170, 110, 250, 0.55);
|
||||
color: rgba(40, 10, 90, 0.95);
|
||||
}
|
||||
.proj-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.proj-icon { font-size: 14px; line-height: 1; }
|
||||
.proj-path-input { flex: 1; }
|
||||
.proj-path-prefix { font-size: 12px; color: rgba(120, 80, 180, 0.7); }
|
||||
.proj-error { margin: 0; }
|
||||
|
||||
.proj-browse-body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 10px;
|
||||
height: 400px;
|
||||
border: 1px solid rgba(170, 110, 250, 0.18);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(250, 245, 255, 0.50);
|
||||
}
|
||||
|
||||
.proj-browse-side {
|
||||
display: flex; flex-direction: column;
|
||||
border-right: 1px solid rgba(170, 110, 250, 0.15);
|
||||
background: rgba(245, 238, 255, 0.65);
|
||||
}
|
||||
.proj-side-title {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(120, 80, 180, 0.85);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(170, 110, 250, 0.10);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.proj-side-scroll { flex: 1; }
|
||||
.proj-side-section { margin-top: 10px; padding: 0 6px; }
|
||||
.proj-side-section:first-child { margin-top: 0; }
|
||||
.proj-side-section-title {
|
||||
font-size: 10.5px;
|
||||
color: rgba(120, 80, 180, 0.7);
|
||||
padding: 6px 6px 2px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.proj-side-btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(60, 30, 110, 0.92);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background .12s;
|
||||
}
|
||||
.proj-side-btn:hover { background: rgba(170, 110, 250, 0.18); }
|
||||
.proj-side-btn--active { background: rgba(170, 110, 250, 0.32); color: #fff; }
|
||||
.proj-side-cwd {
|
||||
margin: 6px;
|
||||
background: rgba(170, 110, 250, 0.14);
|
||||
border: 1px solid rgba(170, 110, 250, 0.30);
|
||||
}
|
||||
.proj-side-cwd:hover { background: rgba(170, 110, 250, 0.28); }
|
||||
.proj-side-icon { width: 16px; flex: none; text-align: center; }
|
||||
.proj-side-text {
|
||||
flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.proj-side-empty {
|
||||
font-size: 11.5px;
|
||||
color: rgba(120, 90, 160, 0.55);
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.proj-browse-main { padding: 0; overflow: hidden; }
|
||||
.proj-browse-table { width: 100%; }
|
||||
.proj-file-icon { font-size: 14px; }
|
||||
.proj-file-name {
|
||||
color: rgba(40, 10, 90, 0.95);
|
||||
font-weight: 500;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.proj-mute { color: rgba(120, 90, 160, 0.7); font-size: 11.5px; }
|
||||
|
||||
.proj-browse-footer {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.proj-footer-label {
|
||||
font-size: 12px;
|
||||
color: rgba(80, 40, 130, 0.85);
|
||||
font-weight: 500;
|
||||
flex: none;
|
||||
min-width: 64px;
|
||||
}
|
||||
.proj-manual-input { flex: 1; }
|
||||
.proj-footer-type {
|
||||
font-size: 11.5px;
|
||||
color: rgba(120, 90, 160, 0.7);
|
||||
flex: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.proj-footer-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.proj-footer-tip {
|
||||
font-size: 11.5px;
|
||||
color: rgba(120, 90, 160, 0.7);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 460px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div
|
||||
ref="cardRef"
|
||||
class="floating-alarm-card"
|
||||
:class="[`lvl-${level}`, { dragging }]"
|
||||
:style="cardStyle"
|
||||
@mousedown.stop="onMouseDown"
|
||||
>
|
||||
<div class="fac-header">
|
||||
<span class="fac-dot" />
|
||||
<span class="fac-title">{{ alarm.carName }} #{{ alarm.carId }}</span>
|
||||
<span class="fac-time">{{ timeShort }}</span>
|
||||
<el-icon class="fac-close" @click.stop="onClose"><Close /></el-icon>
|
||||
</div>
|
||||
<div class="fac-body">
|
||||
<div class="fac-info">{{ alarm.info }}</div>
|
||||
<div class="fac-coord">
|
||||
<span v-if="hasCoord">坐标 ({{ alarm.x?.toFixed(0) }}, {{ alarm.y?.toFixed(0) }}) mm</span>
|
||||
<span v-else class="muted">无坐标</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fac-actions">
|
||||
<el-button size="small" plain :disabled="!hasCoord" @click.stop="onLocate">定位</el-button>
|
||||
<el-button size="small" plain @click.stop="onAck">确认</el-button>
|
||||
<el-button size="small" plain @click.stop="onSnooze">稍后</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import type { AlarmEvent } from '@/composables/useMapEditStream'
|
||||
|
||||
const props = defineProps<{
|
||||
alarm: AlarmEvent
|
||||
/** 卡片初始位置(相对父容器的右下角 offset,单位 px)。 */
|
||||
initialOffset?: { right: number; bottom: number }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'ack'): void
|
||||
(e: 'snooze'): void
|
||||
(e: 'locate', a: AlarmEvent): void
|
||||
}>()
|
||||
|
||||
const cardRef = ref<HTMLDivElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
const pos = ref<{ left?: number; top?: number; right?: number; bottom?: number }>({
|
||||
right: props.initialOffset?.right ?? 24,
|
||||
bottom: props.initialOffset?.bottom ?? 24
|
||||
})
|
||||
|
||||
const hasCoord = computed(() => typeof props.alarm.x === 'number' && Number.isFinite(props.alarm.x))
|
||||
|
||||
const level = computed<'info' | 'warn' | 'error' | 'critical'>(() => {
|
||||
const s = (props.alarm.info || '').toLowerCase()
|
||||
if (s.includes('critical') || s.includes('严重') || s.includes('紧急')) return 'critical'
|
||||
if (s.includes('error') || s.includes('故障')) return 'error'
|
||||
if (s.includes('warn') || s.includes('告警')) return 'warn'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const timeShort = computed(() => {
|
||||
try {
|
||||
const d = new Date(props.alarm.timestamp)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
} catch { return '' }
|
||||
})
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
left: pos.value.left !== undefined ? `${pos.value.left}px` : undefined,
|
||||
top: pos.value.top !== undefined ? `${pos.value.top}px` : undefined,
|
||||
right: pos.value.right !== undefined ? `${pos.value.right}px` : undefined,
|
||||
bottom: pos.value.bottom !== undefined ? `${pos.value.bottom}px` : undefined
|
||||
}))
|
||||
|
||||
let startX = 0, startY = 0, originLeft = 0, originTop = 0
|
||||
|
||||
function onMouseDown(ev: MouseEvent) {
|
||||
if (!cardRef.value) return
|
||||
const rect = cardRef.value.getBoundingClientRect()
|
||||
originLeft = rect.left
|
||||
originTop = rect.top
|
||||
startX = ev.clientX
|
||||
startY = ev.clientY
|
||||
dragging.value = true
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
pos.value = {
|
||||
left: originLeft + (ev.clientX - startX),
|
||||
top: originTop + (ev.clientY - startY)
|
||||
}
|
||||
}
|
||||
function onMouseUp() {
|
||||
dragging.value = false
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
|
||||
function onClose() { emit('close') }
|
||||
function onAck() { emit('ack') }
|
||||
function onSnooze() { emit('snooze') }
|
||||
function onLocate() { emit('locate', props.alarm) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-alarm-card {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 8, 40, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 10px 32px rgba(10,2,16,0.55), 0 0 0 1px rgba(255,255,255,0.08) inset;
|
||||
color: rgba(232,215,245,0.95);
|
||||
font-size: 13px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1000;
|
||||
transition: box-shadow .2s, transform .2s;
|
||||
}
|
||||
.floating-alarm-card.dragging { cursor: grabbing; box-shadow: 0 14px 40px rgba(10,2,16,0.7); }
|
||||
.fac-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.fac-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--lvl-color, #67c23a); box-shadow: 0 0 8px var(--lvl-color, #67c23a); }
|
||||
.fac-title { flex: 1; font-weight: 600; }
|
||||
.fac-time { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: rgba(232,215,245,0.65); }
|
||||
.fac-close { cursor: pointer; opacity: .7; }
|
||||
.fac-close:hover { opacity: 1; }
|
||||
.fac-body { padding: 4px 0 6px; }
|
||||
.fac-info { line-height: 1.4; word-break: break-all; }
|
||||
.fac-coord { font-size: 11.5px; color: rgba(232,215,245,0.7); margin-top: 4px; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.fac-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.muted { color: rgba(232,215,245,0.5); }
|
||||
|
||||
.floating-alarm-card.lvl-info { --lvl-color: #67c23a; }
|
||||
.floating-alarm-card.lvl-warn { --lvl-color: #e6a23c; }
|
||||
.floating-alarm-card.lvl-error { --lvl-color: #f56c6c; }
|
||||
.floating-alarm-card.lvl-critical { --lvl-color: #d63a3a; }
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="floating-alarm-stack">
|
||||
<FloatingAlarmCard
|
||||
v-for="(a, idx) in visible"
|
||||
:key="`${a.carId}-${a.timestamp}`"
|
||||
:alarm="a"
|
||||
:initial-offset="{ right: 24, bottom: 24 + idx * 132 }"
|
||||
@close="onClose(a)"
|
||||
@ack="onAck(a)"
|
||||
@snooze="onSnooze(a)"
|
||||
@locate="onLocate"
|
||||
/>
|
||||
<div v-if="snoozed.length" class="snooze-tray" @click="restoreSnoozed">
|
||||
<el-badge :value="snoozed.length" type="warning">
|
||||
<el-button size="small" circle>≡</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import FloatingAlarmCard from './FloatingAlarmCard.vue'
|
||||
import type { AlarmEvent } from '@/composables/useMapEditStream'
|
||||
|
||||
const props = defineProps<{ alarms: AlarmEvent[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'locate', a: AlarmEvent): void
|
||||
(e: 'ack', a: AlarmEvent): void
|
||||
}>()
|
||||
|
||||
const dismissed = ref<Set<string>>(new Set())
|
||||
const snoozedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function keyOf(a: AlarmEvent) { return `${a.carId}` }
|
||||
|
||||
const visible = computed(() =>
|
||||
props.alarms
|
||||
.filter((a) => a.action !== 'clear')
|
||||
.filter((a) => !dismissed.value.has(keyOf(a)))
|
||||
.filter((a) => !snoozedIds.value.has(keyOf(a)))
|
||||
)
|
||||
|
||||
const snoozed = computed(() => props.alarms.filter((a) => snoozedIds.value.has(keyOf(a))))
|
||||
|
||||
function onClose(a: AlarmEvent) {
|
||||
dismissed.value = new Set([...dismissed.value, keyOf(a)])
|
||||
}
|
||||
function onAck(a: AlarmEvent) {
|
||||
dismissed.value = new Set([...dismissed.value, keyOf(a)])
|
||||
emit('ack', a)
|
||||
}
|
||||
function onSnooze(a: AlarmEvent) {
|
||||
snoozedIds.value = new Set([...snoozedIds.value, keyOf(a)])
|
||||
}
|
||||
function onLocate(a: AlarmEvent) {
|
||||
emit('locate', a)
|
||||
}
|
||||
function restoreSnoozed() {
|
||||
snoozedIds.value = new Set()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-alarm-stack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.floating-alarm-stack > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.snooze-tray {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
top: 24px;
|
||||
}
|
||||
</style>
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="fme-root">
|
||||
<div class="fme-header">
|
||||
<p class="fme-desc">{{ description }}</p>
|
||||
<el-descriptions v-if="pathInfos.length > 0" :column="1" size="small" border class="fme-paths">
|
||||
<el-descriptions-item v-for="info in pathInfos" :key="info.label" :label="info.label">
|
||||
<code v-if="info.value" class="fme-path">{{ info.value }}</code>
|
||||
<span v-else class="fme-path-empty">({{ info.emptyHint ?? '空' }})</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="fme-toolbar">
|
||||
<el-input
|
||||
v-model="filterText"
|
||||
placeholder="按中文/英文键过滤"
|
||||
size="small"
|
||||
clearable
|
||||
class="fme-search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-button size="small" :loading="loading" @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span class="fme-btn-text">刷新</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!hasDirty && !forceAllowSave"
|
||||
:loading="saving"
|
||||
@click="onSave"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="fme-btn-text">{{ saveButtonLabel }}{{ hasDirty ? `(${dirtyCount} 项未应用)` : '' }}</span>
|
||||
</el-button>
|
||||
|
||||
<el-button v-if="hasDirty" size="small" link type="warning" @click="resetDirty">
|
||||
放弃未应用的改动
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" shadow="never" class="fme-table-card">
|
||||
<el-table
|
||||
:data="filteredRows"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
height="100%"
|
||||
class="fme-table"
|
||||
row-key="key"
|
||||
>
|
||||
<el-table-column label="中文说明" prop="label" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="英文键" prop="key" width="240" show-overflow-tooltip />
|
||||
<el-table-column label="类型" prop="typeName" width="100" />
|
||||
<el-table-column label="当前值(可编辑)" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.locked" class="fme-locked">{{ row.value }}(🔒 锁定)</span>
|
||||
<el-switch
|
||||
v-else-if="isBool(row.typeName)"
|
||||
:model-value="boolOf(getEditing(row))"
|
||||
@update:model-value="(v: boolean | string | number) => setEditing(row, String(Boolean(v)))"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="isInt(row.typeName)"
|
||||
:model-value="intOf(getEditing(row))"
|
||||
:controls="false"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
size="small"
|
||||
class="fme-input"
|
||||
@update:model-value="(v: number | undefined) => setEditing(row, String(v ?? 0))"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="isFloat(row.typeName)"
|
||||
:model-value="floatOf(getEditing(row))"
|
||||
:controls="false"
|
||||
:step="0.1"
|
||||
size="small"
|
||||
class="fme-input"
|
||||
@update:model-value="(v: number | undefined) => setEditing(row, String(v ?? 0))"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getEditing(row)"
|
||||
size="small"
|
||||
class="fme-input"
|
||||
@update:model-value="(v: string) => setEditing(row, v)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.locked" size="small" type="info" effect="plain">只读</el-tag>
|
||||
<el-tag v-else-if="isDirty(row)" size="small" type="warning" effect="dark">已修改</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">同步</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && rows.length === 0" :description="`未发现可编辑的 [FieldMember] 字段(target: ${targetLabel})`" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Document, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type { ProjectPropertyRow } from '@/api/reflection'
|
||||
|
||||
export interface FieldMemberPathInfo {
|
||||
label: string
|
||||
value: string | null
|
||||
emptyHint?: string
|
||||
}
|
||||
|
||||
export interface FieldMemberEditorLoadResult {
|
||||
fields: ProjectPropertyRow[]
|
||||
pathInfos?: FieldMemberPathInfo[]
|
||||
saveButtonLabel?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
targetLabel: string
|
||||
description: string
|
||||
loader: () => Promise<FieldMemberEditorLoadResult>
|
||||
onSet: (field: string, value: string) => Promise<unknown>
|
||||
onSave: () => Promise<{ path: string }>
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const filterText = ref('')
|
||||
const rows = ref<ProjectPropertyRow[]>([])
|
||||
const pathInfos = ref<FieldMemberPathInfo[]>([])
|
||||
const saveButtonLabel = ref('保存')
|
||||
const editing = reactive<Record<string, string>>({})
|
||||
|
||||
// 即使无 dirty 也允许"保存"——例如桌面端修改了内存但平台仅想触发持久化。
|
||||
// 现版本默认 false:保存按钮只在有 dirty 时可点;用户可后续按需放开。
|
||||
const forceAllowSave = ref(false)
|
||||
|
||||
const filteredRows = computed<ProjectPropertyRow[]>(() => {
|
||||
const q = filterText.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter(
|
||||
(r) => r.key.toLowerCase().includes(q) || r.label.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const dirtyCount = computed(() =>
|
||||
rows.value.reduce((n, f) => (f.locked ? n : editing[f.key] !== f.value ? n + 1 : n), 0)
|
||||
)
|
||||
const hasDirty = computed(() => dirtyCount.value > 0)
|
||||
|
||||
function isBool(t: string) {
|
||||
return t === 'Boolean'
|
||||
}
|
||||
function isInt(t: string) {
|
||||
return t === 'Int32' || t === 'Int64' || t === 'Int16' || t === 'Byte'
|
||||
}
|
||||
function isFloat(t: string) {
|
||||
return t === 'Single' || t === 'Double' || t === 'Decimal'
|
||||
}
|
||||
function boolOf(s: string): boolean {
|
||||
const v = (s ?? '').trim().toLowerCase()
|
||||
return v === 'true' || v === '1' || v === 'yes'
|
||||
}
|
||||
function intOf(s: string): number {
|
||||
const n = parseInt(s, 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
function floatOf(s: string): number {
|
||||
const n = parseFloat(s)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
function getEditing(row: ProjectPropertyRow): string {
|
||||
return editing[row.key] ?? row.value
|
||||
}
|
||||
function setEditing(row: ProjectPropertyRow, v: string) {
|
||||
editing[row.key] = v
|
||||
}
|
||||
function isDirty(row: ProjectPropertyRow): boolean {
|
||||
if (row.locked) return false
|
||||
const cur = editing[row.key]
|
||||
return cur !== undefined && cur !== row.value
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await props.loader()
|
||||
rows.value = r.fields
|
||||
pathInfos.value = r.pathInfos ?? []
|
||||
if (r.saveButtonLabel) saveButtonLabel.value = r.saveButtonLabel
|
||||
for (const f of r.fields) editing[f.key] = f.value
|
||||
for (const k of Object.keys(editing)) {
|
||||
if (!r.fields.find((f) => f.key === k)) delete editing[k]
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载${props.targetLabel}失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetDirty() {
|
||||
for (const f of rows.value) editing[f.key] = f.value
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const dirtyFields = rows.value.filter((f) => !f.locked && editing[f.key] !== f.value)
|
||||
saving.value = true
|
||||
let okCount = 0
|
||||
const failed: Array<{ key: string; reason: string }> = []
|
||||
for (const f of dirtyFields) {
|
||||
try {
|
||||
await props.onSet(f.key, editing[f.key] ?? '')
|
||||
okCount += 1
|
||||
} catch (err) {
|
||||
failed.push({ key: f.key, reason: (err as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
saving.value = false
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`共 ${dirtyFields.length} 项改动:成功 ${okCount},失败 ${failed.length}(首项: ${failed[0].key} → ${failed[0].reason})。\n是否仍要把已成功应用的改动持久化到磁盘?`,
|
||||
'部分字段写入失败',
|
||||
{ confirmButtonText: '仍然保存', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
ElMessage.warning(`已取消保存(${okCount} 项已写入内存,${failed.length} 项未生效)`)
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await props.onSave()
|
||||
ElMessage.success(`已保存到:${r.path}(应用 ${okCount} 项改动${failed.length > 0 ? `;${failed.length} 项失败已忽略` : ''})`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
ElMessage.error(`保存失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.loader, () => { refresh() })
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fme-root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.fme-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fme-desc {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fme-paths code.fme-path {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.fme-path-empty {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.fme-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fme-search {
|
||||
width: 280px;
|
||||
}
|
||||
.fme-btn-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.fme-table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fme-table-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fme-table {
|
||||
flex: 1;
|
||||
}
|
||||
.fme-input {
|
||||
width: 100%;
|
||||
}
|
||||
.fme-locked {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="ref-kind-table">
|
||||
<div class="rkt-header">
|
||||
<el-input
|
||||
v-model="search"
|
||||
:placeholder="`筛选 ${kind} ...`"
|
||||
size="small"
|
||||
clearable
|
||||
class="rkt-search"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button size="small" :loading="loading" @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredObjects"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
class="rkt-table"
|
||||
highlight-current-row
|
||||
@current-change="onSelect"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" />
|
||||
<el-table-column prop="typeName" label="类型" width="180" />
|
||||
<el-table-column prop="layer" label="图层" width="120" />
|
||||
<el-table-column prop="summary" label="摘要" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<el-card v-if="currentObject" shadow="never" class="rkt-actions">
|
||||
<template #header>
|
||||
<span>{{ currentObject.typeName }} #{{ currentObject.id }} · {{ currentObject.name }}</span>
|
||||
</template>
|
||||
<div v-if="loadingMethods" class="rkt-loading-methods">加载方法中…</div>
|
||||
<div v-else-if="methods.length === 0" class="rkt-empty">该类型未暴露 MethodMember</div>
|
||||
<div v-else class="rkt-method-grid">
|
||||
<el-tooltip
|
||||
v-for="m in methods"
|
||||
:key="m.methodName"
|
||||
:content="m.description ?? m.hint ?? ''"
|
||||
:disabled="!m.description && !m.hint"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="executing === m.methodName"
|
||||
@click="runMethod(m)"
|
||||
>
|
||||
{{ m.label ?? m.methodName }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!loading && objects.length === 0" :description="emptyText ?? `当前 ${kind} 无条目`" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { reflectionApi, type ReflectionKind, type ReflectionMethod, type ReflectionObject } from '@/api/reflection'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: ReflectionKind
|
||||
/** 额外的客户端筛选关键字(用于 taskflow / trigger 这种从 mission 派生的虚拟分类)。 */
|
||||
extraFilter?: string
|
||||
emptyText?: string
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMethods = ref(false)
|
||||
const executing = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
|
||||
const objects = ref<ReflectionObject[]>([])
|
||||
const currentObject = ref<ReflectionObject | null>(null)
|
||||
const methods = ref<ReflectionMethod[]>([])
|
||||
|
||||
const filteredObjects = computed(() => {
|
||||
let arr = objects.value
|
||||
if (props.extraFilter) {
|
||||
const k = props.extraFilter.toLowerCase()
|
||||
arr = arr.filter((o) => (o.typeName ?? '').toLowerCase().includes(k) || (o.name ?? '').toLowerCase().includes(k))
|
||||
}
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (q) {
|
||||
arr = arr.filter((o) =>
|
||||
(o.name ?? '').toLowerCase().includes(q) ||
|
||||
(o.typeName ?? '').toLowerCase().includes(q) ||
|
||||
String(o.id).includes(q)
|
||||
)
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
objects.value = await reflectionApi.listObjects(props.kind)
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载 ${props.kind} 失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelect(row: ReflectionObject | null) {
|
||||
currentObject.value = row
|
||||
methods.value = []
|
||||
if (!row) return
|
||||
loadingMethods.value = true
|
||||
try {
|
||||
methods.value = await reflectionApi.listMethods(props.kind, row.id)
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载方法失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loadingMethods.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runMethod(m: ReflectionMethod) {
|
||||
if (!currentObject.value) return
|
||||
if (m.hasParams) {
|
||||
ElMessage.info(`方法 ${m.methodName} 需要参数,请在「设计与编排」详细面板里执行(待开发)`)
|
||||
return
|
||||
}
|
||||
executing.value = m.methodName
|
||||
try {
|
||||
const r = await reflectionApi.execute(props.kind, currentObject.value.id, m.methodName)
|
||||
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `:${r.returnValue}` : ''}`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`执行失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
executing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.kind, refresh)
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ref-kind-table {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.rkt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.rkt-search { flex: 0 0 320px; }
|
||||
.rkt-table { flex: 1; min-height: 200px; }
|
||||
.rkt-actions { margin-top: 8px; }
|
||||
.rkt-method-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rkt-loading-methods, .rkt-empty {
|
||||
color: rgba(232,215,245,0.7);
|
||||
font-size: 12px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<el-card v-if="visible" shadow="never" class="plugin-list-card">
|
||||
<template #header>
|
||||
<div class="plg-header">
|
||||
<span class="plg-title">已加载插件 ({{ plugins.length }})</span>
|
||||
<el-button text size="small" :icon="ArrowDown" class="plg-collapse" @click="collapsed = !collapsed">
|
||||
{{ collapsed ? '展开' : '折叠' }}
|
||||
</el-button>
|
||||
<el-button size="small" :loading="loading" :icon="Refresh" @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!collapsed">
|
||||
<el-empty
|
||||
v-if="!loading && plugins.length === 0"
|
||||
description="尚未通过 PluginManager 加载任何插件 (重启 SimpleLite 后会自动迁移现有 plugins/ 内的 dll)"
|
||||
/>
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="loading"
|
||||
:data="plugins"
|
||||
size="small"
|
||||
stripe
|
||||
border
|
||||
class="plg-table"
|
||||
>
|
||||
<el-table-column prop="name" label="插件名" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="assemblyVersion" label="版本" width="120" />
|
||||
<el-table-column label="类型贡献" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="success" effect="plain" class="plg-tag">
|
||||
进程 {{ row.missionTypes }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="primary" effect="plain" class="plg-tag">
|
||||
车/脚本 {{ row.carTypes }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="info" effect="plain" class="plg-tag">
|
||||
共 {{ row.loadedTypes }} type
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dllPath" label="路径" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.collectible" size="small" type="success">可卸载</el-tag>
|
||||
<el-tooltip
|
||||
v-else
|
||||
content="此插件被加到 default ALC(旧路径加载),重启 SimpleLite 后会自动迁移到 collectible"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="warning">不可卸载</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
text
|
||||
size="small"
|
||||
type="danger"
|
||||
:disabled="!row.collectible"
|
||||
:loading="unloading === row.name"
|
||||
@click="onUnload(row as PluginEntry)"
|
||||
>
|
||||
卸载
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 插件管理面板(仅展示 + 卸载入口;增量加载走父组件的 「刷新插件」 按钮)。
|
||||
*
|
||||
* - 列出 PluginManager 跟踪的所有插件,标注 collectible / 不可卸载;
|
||||
* - 「卸载」按钮:调用 POST /plugins/{name}/unload;后端会先检查没有 Mission/Car 实例
|
||||
* 仍在用插件类型,否则 409 失败;卸载完毕后会重建 UiDiscoveryCache。
|
||||
*/
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowDown, Refresh } from '@element-plus/icons-vue'
|
||||
import { reflectionApi, type PluginEntry } from '@/api/reflection'
|
||||
|
||||
const props = defineProps<{ visible?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const visible = computed(() => props.visible !== false)
|
||||
const plugins = ref<PluginEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const collapsed = ref(true)
|
||||
const unloading = ref<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
plugins.value = await reflectionApi.listPlugins()
|
||||
} catch (err) {
|
||||
ElMessage.warning(`插件列表加载失败:${(err as Error).message}(旧版 SimpleLite 可能未带 /plugins 接口,请重启)`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnload(p: PluginEntry) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认卸载插件 ${p.name}?\n\n` +
|
||||
`此插件贡献了 ${p.missionTypes} 个进程类型 / ${p.carTypes} 个车/脚本类型;` +
|
||||
`如果当前还有这些类型的实例存在,卸载会失败(请先在管理面板里删除)。`,
|
||||
'卸载插件',
|
||||
{ type: 'warning', confirmButtonText: '卸载', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
unloading.value = p.name
|
||||
try {
|
||||
const r = await reflectionApi.unloadPlugin(p.name)
|
||||
ElMessage.success(r.message ?? `已卸载 ${p.name}`)
|
||||
emit('changed')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
ElMessage.error(`卸载失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
unloading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => { if (v) refresh() })
|
||||
onMounted(() => { if (visible.value) refresh() })
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plugin-list-card {
|
||||
background: rgba(30, 14, 55, 0.55) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.plg-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.plg-collapse { margin-left: auto; }
|
||||
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
|
||||
.plg-table {
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.el-table) { background: transparent; }
|
||||
:deep(.el-table tr),
|
||||
:deep(.el-table th.el-table__cell),
|
||||
:deep(.el-table td.el-table__cell) {
|
||||
background: transparent !important;
|
||||
color: rgba(232, 215, 245, 0.92);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:deep(.el-card__header) {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
+1263
File diff suppressed because it is too large
Load Diff
+753
@@ -0,0 +1,753 @@
|
||||
<template>
|
||||
<div class="monitor-selection">
|
||||
<template v-if="!selection">
|
||||
<div class="empty-hint">
|
||||
<span class="empty-title">未选中对象</span>
|
||||
<span class="empty-desc">在 3D 画布或车辆列表中点击以查看信息。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="pane">
|
||||
<div v-if="initialLoading" class="pane-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中…</span>
|
||||
</div>
|
||||
|
||||
<!-- 站点 -->
|
||||
<template v-else-if="viewKind === 'site'">
|
||||
<section class="card card--header">
|
||||
<div class="title-row">
|
||||
<span class="id-tag">#{{ objectId }}</span>
|
||||
<span class="type-tag">站点</span>
|
||||
</div>
|
||||
<div class="name-line">{{ siteInfo.name }}</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-hd"><span>位置</span></header>
|
||||
<div class="kv-grid">
|
||||
<div class="kv"><span class="k">X</span><span class="v mono">{{ siteInfo.x }}</span></div>
|
||||
<div class="kv"><span class="k">Y</span><span class="v mono">{{ siteInfo.y }}</span></div>
|
||||
<div class="kv"><span class="k">Z</span><span class="v mono">{{ siteInfo.z }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="statusDisplayRows.length" class="card">
|
||||
<header class="card-hd"><span>状态</span><span class="meta">{{ statusDisplayRows.length }}</span></header>
|
||||
<div class="kv-list">
|
||||
<div v-for="row in statusDisplayRows" :key="row.key" class="kv-row">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v" :title="row.value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="siteActions.length" class="card card--actions">
|
||||
<header class="card-hd"><span>操作</span></header>
|
||||
<div class="action-grid">
|
||||
<button
|
||||
v-for="m in siteActions"
|
||||
:key="m.methodName"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:disabled="executing === m.methodName"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- 车辆 -->
|
||||
<template v-else-if="viewKind === 'vehicle'">
|
||||
<section class="card card--header">
|
||||
<div class="title-row">
|
||||
<span class="id-tag">{{ carInfo.displayId }}</span>
|
||||
<el-tag size="small" :type="carStateTagType" effect="dark">{{ carInfo.stateLabel }}</el-tag>
|
||||
</div>
|
||||
<div class="name-line">{{ carInfo.name }}</div>
|
||||
<div v-if="carInfo.typeShort" class="sub-line">{{ carInfo.typeShort }}</div>
|
||||
</section>
|
||||
|
||||
<section v-if="statusDisplayRows.length" class="card">
|
||||
<header class="card-hd">
|
||||
<span>状态</span>
|
||||
<span class="meta">{{ statusDisplayRows.length }}</span>
|
||||
</header>
|
||||
<div class="kv-list">
|
||||
<div
|
||||
v-for="row in statusDisplayRows"
|
||||
:key="row.key"
|
||||
class="kv-row"
|
||||
:class="{ 'is-warn': row.warn }">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v" :title="row.value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fieldDisplayRows.length" class="card">
|
||||
<header class="card-hd">
|
||||
<span>属性</span>
|
||||
<span class="meta">{{ fieldDisplayRows.length }}</span>
|
||||
</header>
|
||||
<div class="kv-list">
|
||||
<div v-for="row in fieldDisplayRows" :key="row.key" class="kv-row">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v" :title="row.value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card card--actions">
|
||||
<header class="card-hd">
|
||||
<span>动作</span>
|
||||
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
|
||||
</header>
|
||||
<div v-if="actionsLoading" class="muted-line">加载动作…</div>
|
||||
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div>
|
||||
<div v-else class="action-grid action-grid--vehicle">
|
||||
<button
|
||||
v-for="m in carActions"
|
||||
:key="m.methodName"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
|
||||
:disabled="executing === m.methodName"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- 路径 -->
|
||||
<template v-else-if="viewKind === 'track'">
|
||||
<section class="card card--header">
|
||||
<div class="title-row">
|
||||
<span class="id-tag">#{{ objectId }}</span>
|
||||
<span class="type-tag">路径</span>
|
||||
</div>
|
||||
<div class="name-line">{{ trackInfo.name }}</div>
|
||||
</section>
|
||||
<section v-if="statusDisplayRows.length || trackRows.length" class="card">
|
||||
<header class="card-hd"><span>信息</span></header>
|
||||
<div class="kv-list">
|
||||
<div v-for="row in trackRows" :key="row.key" class="kv-row">
|
||||
<span class="k">{{ row.label }}</span>
|
||||
<span class="v">{{ row.value }}</span>
|
||||
</div>
|
||||
<div v-for="row in statusDisplayRows" :key="'s-' + row.key" class="kv-row">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="trackActions.length" class="card card--actions">
|
||||
<header class="card-hd"><span>动作</span></header>
|
||||
<div class="action-grid">
|
||||
<button
|
||||
v-for="m in trackActions"
|
||||
:key="m.methodName"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:disabled="executing === m.methodName"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="muted-line">该类型暂不支持监控详情展示。</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<SitePickDialog
|
||||
v-model="sitePickOpen"
|
||||
:title="sitePickTitle"
|
||||
:sites="sitePickSites"
|
||||
@confirm="onSitePickConfirm"
|
||||
@pick-on-map="onSitePickOnMap"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import SitePickDialog from '@/components/workbench/SitePickDialog.vue'
|
||||
import {
|
||||
reflectionApi,
|
||||
type ReflectionKind,
|
||||
type ReflectionKv,
|
||||
type ReflectionMethod
|
||||
} from '@/api/reflection'
|
||||
import {
|
||||
executeCarGotoSite,
|
||||
executeReflectionMethod,
|
||||
loadSitePickOptions,
|
||||
methodNeedsSitePick,
|
||||
pickTargetSiteOnMap,
|
||||
type SitePickOption
|
||||
} from '@/utils/carActionExecute'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
import type { SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
const SITE_ACTION_METHODS = new Set(['AllowLock', 'PreventLock'])
|
||||
|
||||
const props = defineProps<{
|
||||
selection: SelectedObjectRef | null
|
||||
refreshKey?: number
|
||||
cars?: Car[]
|
||||
missions?: Mission[]
|
||||
}>()
|
||||
|
||||
const initialLoading = ref(false)
|
||||
const hydrated = ref(false)
|
||||
const actionsLoading = ref(false)
|
||||
const executing = ref<string | null>(null)
|
||||
const pendingGotoMethod = ref<ReflectionMethod | null>(null)
|
||||
|
||||
const bundleName = ref('')
|
||||
const bundleTypeName = ref('')
|
||||
const bundleFullTypeName = ref('')
|
||||
const fieldMap = ref<Record<string, string>>({})
|
||||
const statusRows = ref<ReflectionKv[]>([])
|
||||
const allMethods = ref<ReflectionMethod[]>([])
|
||||
const carActionByType = ref<Record<string, string[]>>({})
|
||||
const siteActionKeys = ref<string[]>([])
|
||||
const trackActionKeys = ref<string[]>([])
|
||||
const monitorConfigLoaded = ref(false)
|
||||
|
||||
const sitePickOpen = ref(false)
|
||||
const sitePickSites = ref<SitePickOption[]>([])
|
||||
const sitePickTitle = ref('选择目标站点')
|
||||
|
||||
const SELECTION_TO_REFLECTION: Record<string, ReflectionKind> = {
|
||||
car: 'car',
|
||||
vehicle: 'car',
|
||||
site: 'site',
|
||||
track: 'track'
|
||||
}
|
||||
|
||||
const reflectionKind = computed<ReflectionKind | null>(() => {
|
||||
const k = props.selection?.kind?.toLowerCase()
|
||||
return k ? SELECTION_TO_REFLECTION[k] ?? null : null
|
||||
})
|
||||
|
||||
const viewKind = computed(() => {
|
||||
const k = props.selection?.kind?.toLowerCase()
|
||||
if (k === 'site') return 'site'
|
||||
if (k === 'vehicle' || k === 'car') return 'vehicle'
|
||||
if (k === 'track') return 'track'
|
||||
return 'other'
|
||||
})
|
||||
|
||||
const objectId = computed(() => {
|
||||
const n = Number(props.selection?.id)
|
||||
return Number.isFinite(n) ? n : props.selection?.id ?? '-'
|
||||
})
|
||||
|
||||
function fmtNum(raw: string | undefined, digits = 1): string {
|
||||
if (raw == null || raw === '') return '—'
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n.toFixed(digits) : raw
|
||||
}
|
||||
|
||||
function pickField(...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = fieldMap.value[k]
|
||||
if (v != null && v !== '') return v
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function pickStatus(...candidates: string[]): string {
|
||||
for (const c of candidates) {
|
||||
const hit = statusRows.value.find((r) => r.key === c)
|
||||
if (hit?.value) return hit.value
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const hit = statusRows.value.find((r) => r.key.includes(c))
|
||||
if (hit?.value) return hit.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function isSiteDisabled(): boolean {
|
||||
const tags = pickStatus('标记', 'tags')
|
||||
if (!tags) return false
|
||||
return /unavailable/i.test(tags)
|
||||
}
|
||||
|
||||
const siteInfo = computed(() => ({
|
||||
name: pickField('name') || bundleName.value || props.selection?.name || `站点 ${objectId.value}`,
|
||||
x: fmtNum(pickField('x'), 1),
|
||||
y: fmtNum(pickField('y'), 1),
|
||||
z: fmtNum(pickField('z'), 1),
|
||||
disabled: isSiteDisabled()
|
||||
}))
|
||||
|
||||
const siteActions = computed(() => {
|
||||
const allow = siteActionKeys.value
|
||||
if (allow.length) {
|
||||
const set = new Set(allow)
|
||||
return allMethods.value.filter((m) => set.has(m.methodName))
|
||||
}
|
||||
return allMethods.value.filter((m) => SITE_ACTION_METHODS.has(m.methodName))
|
||||
})
|
||||
|
||||
const trackActions = computed(() => {
|
||||
const allow = trackActionKeys.value
|
||||
if (!allow.length) return allMethods.value
|
||||
const set = new Set(allow)
|
||||
return allMethods.value.filter((m) => set.has(m.methodName))
|
||||
})
|
||||
|
||||
function findCarInList(): Car | undefined {
|
||||
if (!props.selection || viewKind.value !== 'vehicle') return undefined
|
||||
const sid = String(props.selection.id)
|
||||
return props.cars?.find(
|
||||
(c) => c.id === sid || String(c.rawId ?? '') === sid || detailIdForCar(c) === sid
|
||||
)
|
||||
}
|
||||
|
||||
function detailIdForCar(car: Car): string {
|
||||
if (car.rawId != null) return String(car.rawId)
|
||||
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
|
||||
}
|
||||
|
||||
function missionLabelForCar(car: Car | undefined): string {
|
||||
if (!car?.missionId) return '无'
|
||||
const m = props.missions?.find((x) => x.id === car.missionId)
|
||||
if (!m) return car.missionId
|
||||
const statusMap: Record<string, string> = {
|
||||
running: '运行中',
|
||||
queued: '排队',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return `${m.name || m.id}(${statusMap[m.status] ?? m.status})`
|
||||
}
|
||||
|
||||
const carInfo = computed(() => {
|
||||
const car = findCarInList()
|
||||
const stateLabels: Record<string, string> = {
|
||||
idle: '空闲',
|
||||
running: '运行',
|
||||
charging: '充电',
|
||||
paused: '暂停',
|
||||
fault: '故障',
|
||||
offline: '离线'
|
||||
}
|
||||
const state = car?.state ?? 'offline'
|
||||
return {
|
||||
displayId: car?.id ?? props.selection?.id ?? '-',
|
||||
name: bundleName.value || car?.name || props.selection?.name || '车辆',
|
||||
typeShort: shortType(bundleTypeName.value || car?.typeName),
|
||||
stateLabel: stateLabels[state] ?? state,
|
||||
state
|
||||
}
|
||||
})
|
||||
|
||||
const carStateTagType = computed((): 'success' | 'warning' | 'danger' | 'info' => {
|
||||
switch (carInfo.value.state) {
|
||||
case 'running': return 'success'
|
||||
case 'charging': return 'warning'
|
||||
case 'fault': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
})
|
||||
|
||||
/** 状态区:展示 bundle 返回的全部 status 键值,不再折叠为少数几项。 */
|
||||
const statusDisplayRows = computed(() => {
|
||||
const rows = statusRows.value.map((r) => ({
|
||||
key: r.key,
|
||||
value: r.value || '—',
|
||||
warn: /alarm|error|fault|block|stop/i.test(`${r.key} ${r.value}`) && r.value !== '0' && r.value !== '无'
|
||||
}))
|
||||
if (viewKind.value === 'vehicle' && !rows.length) {
|
||||
const car = findCarInList()
|
||||
const mission = missionLabelForCar(car)
|
||||
const batteryRaw = pickField('batterySoc', 'battery', '电量')
|
||||
const batteryPct = batteryRaw
|
||||
? `${Math.round(Number(batteryRaw) <= 1 ? Number(batteryRaw) * 100 : Number(batteryRaw))}%`
|
||||
: car != null
|
||||
? `${Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)}%`
|
||||
: '—'
|
||||
return [
|
||||
{ key: '位姿', value: pickStatus('位姿', '站点') || '—', warn: false },
|
||||
{ key: '任务', value: mission, warn: false },
|
||||
{ key: '电量', value: batteryPct, warn: Number.parseFloat(batteryPct) < 20 }
|
||||
]
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const FIELD_SKIP = new Set(['name', 'x', 'y', 'z', 'th', 'theta', 'layerName', 'layer'])
|
||||
|
||||
const fieldDisplayRows = computed(() => {
|
||||
if (viewKind.value !== 'vehicle') return []
|
||||
const entries = Object.entries(fieldMap.value)
|
||||
.filter(([k, v]) => v != null && v !== '' && !FIELD_SKIP.has(k))
|
||||
.map(([key, value]) => ({ key, value }))
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key))
|
||||
})
|
||||
|
||||
function resolveCarActionWhitelist(): string[] {
|
||||
const map = carActionByType.value
|
||||
const full = bundleFullTypeName.value
|
||||
const short = bundleTypeName.value
|
||||
if (full && map[full]?.length) return map[full]!
|
||||
if (short && map[short]?.length) return map[short]!
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (!v.length) continue
|
||||
if (full && (k === full || k.endsWith('.' + short) || full.endsWith('.' + k))) return v
|
||||
if (short && (k === short || k.endsWith('.' + short))) return v
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const carActionWhitelist = computed(() => resolveCarActionWhitelist())
|
||||
|
||||
const carActions = computed(() => {
|
||||
const allow = carActionWhitelist.value
|
||||
if (!allow.length) return []
|
||||
const set = new Set(allow)
|
||||
return allMethods.value.filter((m) => set.has(m.methodName))
|
||||
})
|
||||
|
||||
const carActionHint = computed(() => {
|
||||
if (!monitorConfigLoaded.value) return '加载配置中…'
|
||||
if (!Object.keys(carActionByType.value).length) {
|
||||
return '未配置动作。请在「配置中心 → 运营维护」按车型勾选动作并保存。'
|
||||
}
|
||||
if (!carActionWhitelist.value.length) {
|
||||
return '当前车型未配置动作,或方法名与运营维护中勾选的不一致。'
|
||||
}
|
||||
return '当前车型无可执行动作。'
|
||||
})
|
||||
|
||||
const trackInfo = computed(() => ({
|
||||
name: pickField('name') || bundleName.value || props.selection?.name || `路径 ${objectId.value}`
|
||||
}))
|
||||
|
||||
const trackRows = computed(() => [
|
||||
{ key: 'layer', label: '图层', value: pickField('layerName', 'layer') || '—' },
|
||||
{ key: 'type', label: '类型', value: bundleTypeName.value || '—' }
|
||||
])
|
||||
|
||||
function shortType(typeName?: string): string {
|
||||
if (!typeName) return ''
|
||||
const parts = typeName.split('.')
|
||||
return parts[parts.length - 1] ?? typeName
|
||||
}
|
||||
|
||||
function selectionKey(sel: SelectedObjectRef | null): string {
|
||||
if (!sel) return ''
|
||||
return `${sel.kind}:${sel.id}`
|
||||
}
|
||||
|
||||
async function loadMonitorRuntimeConfig(force = false) {
|
||||
if (monitorConfigLoaded.value && !force) return
|
||||
try {
|
||||
const mc = await reflectionApi.getMonitorConfig()
|
||||
carActionByType.value = { ...(mc.config.carActionByType ?? {}) }
|
||||
siteActionKeys.value = [...(mc.config.site?.methods ?? [])]
|
||||
trackActionKeys.value = [...(mc.config.track?.methods ?? [])]
|
||||
} catch {
|
||||
carActionByType.value = {}
|
||||
siteActionKeys.value = []
|
||||
trackActionKeys.value = []
|
||||
} finally {
|
||||
monitorConfigLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!props.selection) {
|
||||
bundleName.value = ''
|
||||
fieldMap.value = {}
|
||||
statusRows.value = []
|
||||
allMethods.value = []
|
||||
hydrated.value = false
|
||||
return
|
||||
}
|
||||
const rk = reflectionKind.value
|
||||
const idNum = Number(props.selection.id)
|
||||
if (!rk || !Number.isFinite(idNum)) return
|
||||
|
||||
const soft = hydrated.value
|
||||
if (!soft) initialLoading.value = true
|
||||
else actionsLoading.value = true
|
||||
|
||||
try {
|
||||
await loadMonitorRuntimeConfig(!soft)
|
||||
const [bundle, methods] = await Promise.all([
|
||||
reflectionApi.getBundle(rk, idNum),
|
||||
reflectionApi.listMethods(rk, idNum)
|
||||
])
|
||||
bundleName.value = bundle.summary?.name ?? props.selection.name ?? ''
|
||||
bundleTypeName.value = bundle.typeName ?? ''
|
||||
bundleFullTypeName.value =
|
||||
('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName ?? ''
|
||||
fieldMap.value = { ...bundle.fields }
|
||||
for (const f of bundle.fieldList ?? []) {
|
||||
fieldMap.value[f.key] = f.value
|
||||
}
|
||||
statusRows.value = bundle.status ?? []
|
||||
allMethods.value = methods
|
||||
hydrated.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error(`加载选中信息失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
initialLoading.value = false
|
||||
actionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onExecute(m: ReflectionMethod) {
|
||||
const rk = reflectionKind.value
|
||||
if (!rk || !props.selection || !m.methodName) return
|
||||
const idNum = Number(props.selection.id)
|
||||
if (!Number.isFinite(idNum)) return
|
||||
|
||||
if (rk === 'car' && methodNeedsSitePick(m)) {
|
||||
pendingGotoMethod.value = m
|
||||
sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}`
|
||||
try {
|
||||
sitePickSites.value = await loadSitePickOptions()
|
||||
} catch (e) {
|
||||
ElMessage.error(`加载站点列表失败:${(e as Error).message}`)
|
||||
return
|
||||
}
|
||||
sitePickOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
executing.value = m.methodName
|
||||
try {
|
||||
const ok = await executeReflectionMethod(rk, idNum, m)
|
||||
if (ok) await loadAll()
|
||||
} finally {
|
||||
executing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onSitePickConfirm(siteId: number) {
|
||||
const idNum = Number(props.selection?.id)
|
||||
if (!Number.isFinite(idNum)) return
|
||||
executing.value = pendingGotoMethod.value?.methodName ?? 'goto'
|
||||
try {
|
||||
const ok = await executeCarGotoSite(idNum, siteId)
|
||||
if (ok) await loadAll()
|
||||
} finally {
|
||||
executing.value = null
|
||||
pendingGotoMethod.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onSitePickOnMap() {
|
||||
const siteId = await pickTargetSiteOnMap()
|
||||
sitePickOpen.value = false
|
||||
if (siteId != null) await onSitePickConfirm(siteId)
|
||||
}
|
||||
|
||||
let loadToken = 0
|
||||
watch(
|
||||
() => [selectionKey(props.selection), props.refreshKey] as const,
|
||||
() => {
|
||||
const token = ++loadToken
|
||||
void (async () => {
|
||||
await loadAll()
|
||||
if (token !== loadToken) return
|
||||
})()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.pane-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 24px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-title { font-size: 13px; color: rgba(255, 255, 255, 0.9); font-weight: 500; }
|
||||
.empty-desc { font-size: 11.5px; color: rgba(255, 255, 255, 0.65); }
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
flex: none;
|
||||
}
|
||||
.card--header {
|
||||
background: linear-gradient(140deg, rgba(170, 110, 250, 0.2) 0%, rgba(70, 30, 110, 0.08) 100%);
|
||||
border-color: rgba(170, 110, 250, 0.28);
|
||||
}
|
||||
.card--actions {
|
||||
background: rgba(140, 80, 200, 0.08);
|
||||
border-color: rgba(170, 110, 250, 0.22);
|
||||
}
|
||||
.card-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.8px;
|
||||
color: rgba(220, 180, 250, 0.88);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.card-hd .meta {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: rgba(200, 180, 220, 0.55);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.id-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(170, 110, 250, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
color: rgba(232, 215, 245, 0.75);
|
||||
}
|
||||
.name-line {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sub-line {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: rgba(200, 180, 230, 0.7);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.kv-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 38%) 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.kv-row:last-child { border-bottom: none; }
|
||||
.kv-row.is-warn .v { color: #ffb4b4; }
|
||||
.kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.k, .kv-row .k {
|
||||
color: rgba(200, 180, 230, 0.75);
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.v, .kv-row .v {
|
||||
color: #fff;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; }
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.action-grid--vehicle {
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
}
|
||||
.action-btn {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(167, 139, 250, 0.55);
|
||||
background: rgba(76, 29, 149, 0.45);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.action-btn--goto {
|
||||
border-color: rgba(250, 200, 120, 0.65);
|
||||
background: rgba(120, 70, 20, 0.45);
|
||||
}
|
||||
.action-btn:hover:not(:disabled) {
|
||||
background: rgba(109, 40, 217, 0.75);
|
||||
border-color: rgba(196, 181, 253, 0.9);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.action-btn--goto:hover:not(:disabled) {
|
||||
background: rgba(160, 100, 30, 0.75);
|
||||
border-color: rgba(255, 220, 150, 0.9);
|
||||
}
|
||||
.action-btn:disabled { opacity: 0.55; cursor: progress; }
|
||||
|
||||
.muted-line {
|
||||
font-size: 11.5px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
line-height: 1.4;
|
||||
padding: 2px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<div class="selection-detail">
|
||||
<template v-if="!selection">
|
||||
<div class="empty muted">未选择对象。在左侧列表或 3D 视图中选中后,此处显示属性 / 状态 / 动作。</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="head">
|
||||
<span class="name">{{ detail?.name ?? selection.name ?? selection.id }}</span>
|
||||
<el-tag size="small" type="info">{{ detail?.typeName ?? selection.kind }}</el-tag>
|
||||
<el-tooltip v-if="!reflectionKind" content="该对象类型未接入反射可见性过滤,所有内容按默认展示" placement="top">
|
||||
<el-tag size="small" type="warning" effect="plain">未过滤</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="detail-tabs" @tab-change="loadDetail">
|
||||
<el-tab-pane label="属性" name="properties" />
|
||||
<el-tab-pane label="状态" name="status" />
|
||||
<el-tab-pane label="动作" name="action" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-loading="loading" class="detail-body">
|
||||
<template v-if="activeTab === 'properties'">
|
||||
<el-descriptions v-if="filteredSummary.length" :column="1" size="small" border title="概要">
|
||||
<el-descriptions-item v-for="f in filteredSummary" :key="f.key" :label="f.key">
|
||||
{{ f.value }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="filteredMemberFields.length" class="section">
|
||||
<div class="section-title">成员字段</div>
|
||||
<el-table :data="filteredMemberFields" size="small" stripe>
|
||||
<el-table-column prop="key" label="字段" width="100" />
|
||||
<el-table-column prop="value" label="值" show-overflow-tooltip />
|
||||
<el-table-column label="" width="48">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.locked" size="small" type="info">锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="filteredCustomFields.length" class="section">
|
||||
<div class="section-title">自定义字段</div>
|
||||
<el-table :data="filteredCustomFields" size="small" stripe>
|
||||
<el-table-column prop="key" label="字段" width="100" />
|
||||
<el-table-column prop="value" label="值" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<div
|
||||
v-if="!filteredSummary.length && !filteredMemberFields.length && !filteredCustomFields.length && fieldWhitelistActive"
|
||||
class="muted empty">
|
||||
当前白名单未匹配到任何字段。可在「配置中心 → 运营维护(优先)」或「地图监控配置(回退)」放宽白名单。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'status'">
|
||||
<el-descriptions v-if="filteredStatusFields.length" :column="1" size="small" border>
|
||||
<el-descriptions-item
|
||||
v-for="f in filteredStatusFields"
|
||||
:key="f.key"
|
||||
:label="f.key">
|
||||
{{ f.value }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-else-if="statusWhitelistActive" class="muted empty">
|
||||
当前白名单未匹配到任何状态项。可在「配置中心 → 运营维护(优先)」或「地图监控配置(回退)」放宽白名单。
|
||||
</div>
|
||||
<div v-else class="muted empty">无状态信息。</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="actionsLoading" class="muted empty">加载动作列表中…</div>
|
||||
<div v-else-if="!filteredActions.length" class="muted empty">
|
||||
{{ methodWhitelistActive
|
||||
? '当前白名单未匹配到任何动作。可在「配置中心 → 运营维护(优先)」或「地图监控配置(回退)」放宽白名单。'
|
||||
: '当前对象无可调用动作(MethodMember)。' }}
|
||||
</div>
|
||||
<div v-else class="action-list">
|
||||
<el-button
|
||||
v-for="m in filteredActions"
|
||||
:key="m.methodName"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
class="action-btn"
|
||||
:loading="executing === m.methodName"
|
||||
@click="onExecute(m)">
|
||||
{{ m.label || m.methodName }}
|
||||
<el-tooltip v-if="m.description" :content="m.description" placement="top">
|
||||
<el-icon class="hint-icon"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { getSelectionDetail } from '@/api/workbench'
|
||||
import { getConfig } from '@/api/config'
|
||||
import {
|
||||
reflectionApi,
|
||||
type ReflectionKv,
|
||||
type ReflectionKind,
|
||||
type ReflectionMethod
|
||||
} from '@/api/reflection'
|
||||
import type { MonitorPanelPolicy, OpsConfig } from '@/types/config'
|
||||
import type { DetailTab, SelectedObjectRef, SelectionDetail, DetailField } from '@/types/workbench'
|
||||
|
||||
const props = defineProps<{
|
||||
selection: SelectedObjectRef | null
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const activeTab = ref<DetailTab>('properties')
|
||||
const detail = ref<SelectionDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 反射方法(动作 Tab 用) —— 与 workbench detail 互补:detail.actions 只有 label 没有
|
||||
// methodName / params,无法真的调 execute;统一切到 reflection.listMethods 路径,可拿到完整签名。
|
||||
const actions = ref<ReflectionMethod[]>([])
|
||||
const actionsLoading = ref(false)
|
||||
const executing = ref<string | null>(null)
|
||||
const currentCarTypeKey = ref('')
|
||||
|
||||
const opsMonitor = ref<Record<'car' | 'site' | 'track', MonitorPanelPolicy> | null>(null)
|
||||
const opsCarActionByType = ref<Record<string, string[]>>({})
|
||||
const opsMonitorAt = ref<number>(0)
|
||||
const OPS_MONITOR_TTL_MS = 30_000
|
||||
|
||||
// kind 映射:selection.kind 在前端用 'vehicle' / 'site' / 'track' / 'car' 等,统一映成
|
||||
// reflection 后端 kind('car' / 'site' / 'track')。返回 null 表示该 kind 不在反射 / 监控
|
||||
// 配置覆盖范围(例如 'image' / 'text' / 'model'),过滤逻辑会自动跳过白名单。
|
||||
const SELECTION_KIND_TO_REFLECTION: Record<string, ReflectionKind> = {
|
||||
car: 'car',
|
||||
vehicle: 'car',
|
||||
site: 'site',
|
||||
track: 'track'
|
||||
}
|
||||
|
||||
const reflectionKind = computed<ReflectionKind | null>(() => {
|
||||
const k = props.selection?.kind?.toLowerCase()
|
||||
return k ? SELECTION_KIND_TO_REFLECTION[k] ?? null : null
|
||||
})
|
||||
|
||||
const opsPolicy = computed<MonitorPanelPolicy | null>(() => {
|
||||
const rk = reflectionKind.value
|
||||
if (!rk || !opsMonitor.value) return null
|
||||
if (rk === 'car' || rk === 'site' || rk === 'track') {
|
||||
return opsMonitor.value[rk]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const activeFieldWhitelist = computed<string[]>(() => {
|
||||
return []
|
||||
})
|
||||
const activeStatusWhitelist = computed<string[]>(() => {
|
||||
return []
|
||||
})
|
||||
const activeMethodWhitelist = computed(() => {
|
||||
if (reflectionKind.value === 'car') {
|
||||
const exact = currentCarTypeKey.value ? opsCarActionByType.value[currentCarTypeKey.value] ?? [] : []
|
||||
if (exact.length > 0) return exact
|
||||
const short = detail.value?.typeName ?? ''
|
||||
const fallback = short ? opsCarActionByType.value[short] ?? [] : []
|
||||
if (fallback.length > 0) return fallback
|
||||
return []
|
||||
}
|
||||
return opsPolicy.value?.actionKeys ?? []
|
||||
})
|
||||
|
||||
const fieldWhitelistActive = computed(() => activeFieldWhitelist.value.length > 0)
|
||||
const statusWhitelistActive = computed(() => activeStatusWhitelist.value.length > 0)
|
||||
const methodWhitelistActive = computed(() => activeMethodWhitelist.value.length > 0)
|
||||
|
||||
function applyFieldFilter<T extends DetailField>(rows: T[] | undefined): T[] {
|
||||
if (!rows?.length) return []
|
||||
if (!fieldWhitelistActive.value) return rows
|
||||
const allow = new Set(activeFieldWhitelist.value)
|
||||
return rows.filter((r) => allow.has(r.key))
|
||||
}
|
||||
|
||||
const filteredSummary = computed(() => applyFieldFilter(detail.value?.summary))
|
||||
const filteredMemberFields = computed(() => applyFieldFilter(detail.value?.memberFields))
|
||||
const filteredCustomFields = computed(() => applyFieldFilter(detail.value?.customFields))
|
||||
|
||||
const filteredStatusFields = computed(() => {
|
||||
if (!detail.value) return [] as DetailField[]
|
||||
// status Tab 原逻辑:summary + statusLines 合并展示。保留此语义,但分别按白名单过滤。
|
||||
const combined: DetailField[] = [
|
||||
...(detail.value.summary ?? []),
|
||||
...(detail.value.statusLines ?? [])
|
||||
]
|
||||
if (!statusWhitelistActive.value) return combined
|
||||
const allow = new Set(activeStatusWhitelist.value)
|
||||
return combined.filter((r) => allow.has(r.key))
|
||||
})
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
if (!actions.value.length) return []
|
||||
if (!methodWhitelistActive.value) return actions.value
|
||||
const allow = new Set(activeMethodWhitelist.value)
|
||||
return actions.value.filter((m) => allow.has(m.methodName))
|
||||
})
|
||||
|
||||
async function ensureOpsMonitorConfig() {
|
||||
const now = Date.now()
|
||||
if (opsMonitor.value && now - opsMonitorAt.value < OPS_MONITOR_TTL_MS) return
|
||||
try {
|
||||
const env = await getConfig<OpsConfig>('ops')
|
||||
const monitor = env.payload?.monitor
|
||||
opsMonitor.value = {
|
||||
car: monitor?.car ?? { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
site: monitor?.site ?? { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
track: monitor?.track ?? { propertyKeys: [], statusKeys: [], actionKeys: [] }
|
||||
}
|
||||
opsCarActionByType.value = monitor?.carActionByType ?? {}
|
||||
opsMonitorAt.value = now
|
||||
} catch {
|
||||
if (!opsMonitor.value) {
|
||||
opsMonitor.value = {
|
||||
car: { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
site: { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
track: { propertyKeys: [], statusKeys: [], actionKeys: [] }
|
||||
}
|
||||
opsCarActionByType.value = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.selection) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
const rk = reflectionKind.value
|
||||
const idNum = Number(props.selection.id)
|
||||
loading.value = true
|
||||
try {
|
||||
if (rk && Number.isFinite(idNum)) {
|
||||
if (activeTab.value === 'properties') {
|
||||
const bundle = await reflectionApi.getBundle(rk, idNum)
|
||||
if (rk === 'car') {
|
||||
currentCarTypeKey.value = ('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName
|
||||
}
|
||||
const list = bundle.fieldList ?? []
|
||||
const typed = list.filter((x) => x.source !== 'dynamic')
|
||||
const dynamic = list.filter((x) => x.source === 'dynamic')
|
||||
detail.value = {
|
||||
objectKind: rk,
|
||||
objectId: String(idNum),
|
||||
name: bundle.summary?.name ?? props.selection.name ?? String(idNum),
|
||||
typeName: bundle.typeName ?? bundle.summary?.typeName ?? rk,
|
||||
layer: bundle.summary?.layer ?? undefined,
|
||||
tab: activeTab.value,
|
||||
summary: toDetailFields([
|
||||
{ key: 'ID', value: String(bundle.id) },
|
||||
{ key: '类型', value: bundle.typeName ?? rk },
|
||||
...(bundle.summary?.layer ? [{ key: '图层', value: bundle.summary.layer }] : [])
|
||||
]),
|
||||
memberFields: toDetailFields(typed),
|
||||
customFields: toDetailFields(dynamic),
|
||||
statusLines: [],
|
||||
actions: []
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'status') {
|
||||
const rows = await reflectionApi.getStatus(rk, idNum)
|
||||
detail.value = {
|
||||
objectKind: rk,
|
||||
objectId: String(idNum),
|
||||
name: detail.value?.name ?? props.selection.name ?? String(idNum),
|
||||
typeName: detail.value?.typeName ?? rk,
|
||||
layer: detail.value?.layer,
|
||||
tab: activeTab.value,
|
||||
summary: detail.value?.summary ?? [],
|
||||
memberFields: [],
|
||||
customFields: [],
|
||||
statusLines: toDetailFields(rows),
|
||||
actions: []
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
detail.value = await getSelectionDetail(
|
||||
props.selection.kind,
|
||||
props.selection.id,
|
||||
activeTab.value
|
||||
)
|
||||
} catch (e) {
|
||||
detail.value = null
|
||||
ElMessage.error(`加载选中信息失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActions() {
|
||||
// 只在 reflection 能覆盖的 kind 才拉;其他 kind(image / text / model)保留旧行为:
|
||||
// 走 workbench.detail.actions 兜底(label only)—— 通过把 ReflectionMethod 的 methodName
|
||||
// 设为空、按钮点击给 ElMessage.info 提示。
|
||||
if (!props.selection) {
|
||||
actions.value = []
|
||||
return
|
||||
}
|
||||
const rk = reflectionKind.value
|
||||
if (!rk) {
|
||||
actions.value = (detail.value?.actions ?? []).map((a) => ({
|
||||
methodName: '',
|
||||
label: a.label,
|
||||
description: null,
|
||||
hint: null,
|
||||
returnType: 'void',
|
||||
hasParams: false,
|
||||
params: []
|
||||
}))
|
||||
return
|
||||
}
|
||||
const idNum = Number(props.selection.id)
|
||||
if (!Number.isFinite(idNum)) {
|
||||
actions.value = []
|
||||
return
|
||||
}
|
||||
actionsLoading.value = true
|
||||
try {
|
||||
// 先拉一次 bundle,确保 detail.typeName 对车种动作过滤可用(按车型白名单)。
|
||||
if (rk === 'car' && (!detail.value?.typeName || detail.value.typeName === 'car')) {
|
||||
try {
|
||||
const bundle = await reflectionApi.getBundle(rk, idNum)
|
||||
currentCarTypeKey.value = ('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName
|
||||
detail.value = {
|
||||
objectKind: rk,
|
||||
objectId: String(idNum),
|
||||
name: detail.value?.name ?? bundle.summary?.name ?? props.selection.name ?? String(idNum),
|
||||
typeName: bundle.typeName ?? detail.value?.typeName ?? 'car',
|
||||
layer: detail.value?.layer ?? bundle.summary?.layer ?? undefined,
|
||||
tab: detail.value?.tab ?? activeTab.value,
|
||||
summary: detail.value?.summary ?? [],
|
||||
memberFields: detail.value?.memberFields ?? [],
|
||||
customFields: detail.value?.customFields ?? [],
|
||||
statusLines: detail.value?.statusLines ?? [],
|
||||
actions: detail.value?.actions ?? []
|
||||
}
|
||||
} catch {
|
||||
// 不阻断动作拉取主流程
|
||||
}
|
||||
}
|
||||
actions.value = await reflectionApi.listMethods(rk, idNum)
|
||||
} catch (e) {
|
||||
actions.value = []
|
||||
ElMessage.warning(`加载动作列表失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
actionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onExecute(m: ReflectionMethod) {
|
||||
// 无 methodName = workbench 兜底 action,保留旧的"提示"语义,避免误导用户以为已执行。
|
||||
if (!m.methodName) {
|
||||
ElMessage.info(`动作「${m.label || m.methodName}」需在 SimpleLite 工作台执行(当前对象类型未接入反射 execute)。`)
|
||||
return
|
||||
}
|
||||
const rk = reflectionKind.value
|
||||
if (!rk || !props.selection) return
|
||||
const idNum = Number(props.selection.id)
|
||||
if (!Number.isFinite(idNum)) {
|
||||
ElMessage.warning('选中对象 id 非数字,无法 execute')
|
||||
return
|
||||
}
|
||||
|
||||
// 收集参数:单参数 / 多参数都串行 prompt。多参数 UI 比较糙但不需要新组件;后续若用得
|
||||
// 重再升级到 el-dialog 表单。中途取消任意一个 → 整体取消,不执行。
|
||||
const params: Record<string, string> = {}
|
||||
for (const p of m.params) {
|
||||
try {
|
||||
const promptDefault = p.hasDefault && p.defaultValue != null ? p.defaultValue : ''
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`参数:${p.name}(${p.typeName})${p.hasDefault ? `,缺省 ${p.defaultValue ?? '""'}` : ''}`,
|
||||
`执行 ${m.label || m.methodName}`,
|
||||
{
|
||||
inputValue: promptDefault,
|
||||
confirmButtonText: '下一步',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: m.hint ?? `请输入 ${p.name}`
|
||||
}
|
||||
)
|
||||
params[p.name] = value ?? ''
|
||||
} catch {
|
||||
// ElMessageBox 取消会抛 'cancel';静默退出整个 execute 流程。
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
executing.value = m.methodName
|
||||
try {
|
||||
const r = await reflectionApi.execute(rk, idNum, m.methodName, params)
|
||||
ElMessage.success(
|
||||
r.returnValue ? `执行成功:${r.returnValue}` : `已执行 ${m.label || m.methodName}`
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(`执行失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
executing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshForSelection() {
|
||||
activeTab.value = 'properties'
|
||||
await ensureOpsMonitorConfig()
|
||||
// detail + actions 并行拉,提速 ~RTT/2。actions 不影响 properties Tab 渲染;先回的先到位。
|
||||
await Promise.all([loadDetail(), loadActions()])
|
||||
}
|
||||
|
||||
watch(() => props.selection, () => { void refreshForSelection() }, { deep: true })
|
||||
watch(() => props.refreshKey, () => { void refreshForSelection() })
|
||||
watch(activeTab, () => { void loadDetail() })
|
||||
|
||||
onMounted(() => { void refreshForSelection() })
|
||||
|
||||
function toDetailFields(rows: Array<ReflectionKv | { key: string; value: string }>): DetailField[] {
|
||||
return rows.map((r) => ({ key: r.key, value: r.value, locked: 'locked' in r ? r.locked : false }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selection-detail { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.name { font-weight: 600; font-size: 13px; color: #fff; }
|
||||
.detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.detail-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
|
||||
.detail-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
|
||||
.detail-body { flex: 1; min-height: 120px; overflow: auto; color: #fff; }
|
||||
.detail-body :deep(.el-descriptions__label) { color: rgba(255, 255, 255, 0.88) !important; }
|
||||
.detail-body :deep(.el-descriptions__content),
|
||||
.detail-body :deep(.el-table .cell) {
|
||||
color: #fff !important;
|
||||
}
|
||||
.section { margin-top: 10px; }
|
||||
.section-title { font-size: 12px; color: #fff; margin-bottom: 6px; }
|
||||
.action-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.action-btn {
|
||||
justify-content: flex-start;
|
||||
border-color: rgba(167, 139, 250, 0.65);
|
||||
background: rgba(76, 29, 149, 0.45);
|
||||
color: #fff !important;
|
||||
}
|
||||
.action-btn:hover {
|
||||
border-color: rgba(196, 181, 253, 0.95);
|
||||
background: rgba(109, 40, 217, 0.75);
|
||||
}
|
||||
.action-btn :deep(span) {
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.action-btn .hint-icon { margin-left: 6px; font-size: 13px; opacity: 0.8; }
|
||||
.empty { font-size: 12px; padding: 8px 0; color: #fff; }
|
||||
.muted { color: rgba(255, 255, 255, 0.78); }
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="420px"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="site-pick-dialog"
|
||||
@closed="onClosed">
|
||||
<p class="hint">请选择车辆要前往的目标站点。</p>
|
||||
<el-input
|
||||
v-model="filter"
|
||||
clearable
|
||||
placeholder="搜索站点 ID / 名称"
|
||||
class="filter-input"
|
||||
/>
|
||||
<el-scrollbar max-height="280px" class="site-scroll">
|
||||
<button
|
||||
v-for="s in filteredSites"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="site-row"
|
||||
:class="{ active: selectedId === s.id }"
|
||||
@click="selectedId = s.id">
|
||||
<span class="sid">#{{ s.id }}</span>
|
||||
<span class="sname">{{ s.name || '未命名' }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredSites.length" class="empty">无匹配站点</div>
|
||||
</el-scrollbar>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button :loading="mapPicking" @click="emitPickOnMap">在地图上拾取</el-button>
|
||||
<el-button type="primary" :disabled="selectedId == null" @click="confirm">确认前往</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { SitePickOption } from '@/utils/carActionExecute'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
sites: SitePickOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean]
|
||||
confirm: [siteId: number]
|
||||
pickOnMap: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const filter = ref('')
|
||||
const selectedId = ref<number | null>(null)
|
||||
const mapPicking = ref(false)
|
||||
|
||||
const filteredSites = computed(() => {
|
||||
const q = filter.value.trim().toLowerCase()
|
||||
if (!q) return props.sites
|
||||
return props.sites.filter(
|
||||
(s) =>
|
||||
String(s.id).includes(q) ||
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.label.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
filter.value = ''
|
||||
selectedId.value = props.sites[0]?.id ?? null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function confirm() {
|
||||
if (selectedId.value == null) return
|
||||
emit('confirm', selectedId.value)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function emitPickOnMap() {
|
||||
mapPicking.value = true
|
||||
emit('pickOnMap')
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
mapPicking.value = false
|
||||
}
|
||||
|
||||
defineExpose({ close: () => { visible.value = false }, endMapPick: () => { mapPicking.value = false } })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { margin: 0 0 10px; font-size: 13px; color: rgba(255, 255, 255, 0.78); }
|
||||
.filter-input { margin-bottom: 8px; }
|
||||
.site-scroll { border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; }
|
||||
.site-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.site-row:hover { background: rgba(167, 139, 250, 0.15); }
|
||||
.site-row.active {
|
||||
background: rgba(109, 40, 217, 0.45);
|
||||
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95);
|
||||
}
|
||||
.sid { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: rgba(220, 180, 250, 0.9); flex: none; }
|
||||
.sname { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.55); font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="vehicle-monitor">
|
||||
<div class="overview-row">
|
||||
<div class="overview-item">
|
||||
<div class="overview-num">{{ cars.length }}</div>
|
||||
<div class="overview-label">总数</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num online">{{ onlineCount }}</div>
|
||||
<div class="overview-label">在线</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num running">{{ runningCount }}</div>
|
||||
<div class="overview-label">运行</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num charging">{{ chargingCount }}</div>
|
||||
<div class="overview-label">充电</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-num fault">{{ faultCount }}</div>
|
||||
<div class="overview-label">故障</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="search"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索 ID / 名称 / 任务"
|
||||
class="search"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterState"
|
||||
size="small"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
class="filter"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in stateOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-list">
|
||||
<div
|
||||
v-for="car in filteredCars"
|
||||
:key="car.id"
|
||||
class="vehicle-row"
|
||||
:class="{ 'is-selected': car.id === selectedId }"
|
||||
@click="onSelect(car)"
|
||||
>
|
||||
<div class="vehicle-head">
|
||||
<div class="vehicle-id-name">
|
||||
<span class="vid">{{ car.id }}</span>
|
||||
<span class="vname">{{ car.name }}</span>
|
||||
</div>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="stateTagType(car.state)"
|
||||
class="state-tag"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="dot" :class="`dot-${car.state}`" />
|
||||
{{ stateLabel(car.state) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-meta muted">
|
||||
<span v-if="car.typeName" class="meta-item">{{ shortType(car.typeName) }}</span>
|
||||
<span v-if="car.group" class="meta-item">{{ car.group }}</span>
|
||||
<span v-if="car.ip" class="meta-item">{{ car.ip }}</span>
|
||||
</div>
|
||||
|
||||
<div class="battery-row">
|
||||
<span class="battery-label">电量</span>
|
||||
<el-progress
|
||||
:percentage="batteryPct(car)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
:color="batteryColor(batteryPct(car))"
|
||||
class="battery-bar"
|
||||
/>
|
||||
<span class="battery-text" :class="batteryLevel(batteryPct(car))">
|
||||
{{ batteryPct(car) }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mission-row">
|
||||
<span class="mission-label">任务</span>
|
||||
<template v-if="missionForCar(car)">
|
||||
<span class="mission-name" :title="missionForCar(car)!.name">
|
||||
{{ missionForCar(car)!.name }}
|
||||
</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="missionStatusTagType(missionForCar(car)!.status)"
|
||||
effect="plain"
|
||||
class="mission-tag"
|
||||
>
|
||||
{{ missionStatusLabel(missionForCar(car)!.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="mission-name muted">无任务</span>
|
||||
<el-tag size="small" type="info" effect="plain" class="mission-tag">空闲</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!filteredCars.length" class="empty muted">
|
||||
无匹配车辆
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Car, CarState } from '@/types/car'
|
||||
import type { Mission, MissionStatus } from '@/types/mission'
|
||||
import type { SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
const props = defineProps<{
|
||||
cars: Car[]
|
||||
missions: Mission[]
|
||||
selectedId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [ref: SelectedObjectRef]
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
const filterState = ref<CarState | ''>('')
|
||||
|
||||
const stateOptions: { value: CarState; label: string }[] = [
|
||||
{ value: 'idle', label: '空闲' },
|
||||
{ value: 'running', label: '运行中' },
|
||||
{ value: 'charging', label: '充电中' },
|
||||
{ value: 'paused', label: '已暂停' },
|
||||
{ value: 'fault', label: '故障' },
|
||||
{ value: 'offline', label: '离线' }
|
||||
]
|
||||
|
||||
const stateLabels = stateOptions.reduce<Record<string, string>>((acc, o) => {
|
||||
acc[o.value] = o.label
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
function stateLabel(s: CarState): string {
|
||||
return stateLabels[s] ?? s
|
||||
}
|
||||
|
||||
type TagType = 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
||||
|
||||
function stateTagType(s: CarState): TagType {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'charging':
|
||||
return 'primary'
|
||||
case 'paused':
|
||||
return 'warning'
|
||||
case 'fault':
|
||||
return 'danger'
|
||||
case 'offline':
|
||||
return 'info'
|
||||
case 'idle':
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const missionStatusLabels: Record<MissionStatus, string> = {
|
||||
queued: '排队',
|
||||
assigned: '已分配',
|
||||
running: '执行中',
|
||||
paused: '暂停',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
failed: '失败'
|
||||
}
|
||||
|
||||
function missionStatusLabel(s: MissionStatus): string {
|
||||
return missionStatusLabels[s] ?? s
|
||||
}
|
||||
|
||||
function missionStatusTagType(s: MissionStatus): TagType {
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'assigned':
|
||||
return 'primary'
|
||||
case 'queued':
|
||||
return 'info'
|
||||
case 'paused':
|
||||
return 'warning'
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'cancelled':
|
||||
return 'info'
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const onlineCount = computed(() => props.cars.filter((c) => c.state !== 'offline').length)
|
||||
const runningCount = computed(() => props.cars.filter((c) => c.state === 'running').length)
|
||||
const chargingCount = computed(() => props.cars.filter((c) => c.state === 'charging').length)
|
||||
const faultCount = computed(() => props.cars.filter((c) => c.state === 'fault').length)
|
||||
|
||||
const missionByCarId = computed(() => {
|
||||
const order = (s: MissionStatus) =>
|
||||
s === 'running' ? 0 : s === 'paused' ? 1 : s === 'assigned' ? 2 : 3
|
||||
const map = new Map<string, Mission>()
|
||||
for (const m of props.missions) {
|
||||
if (!m.carId) continue
|
||||
if (m.status !== 'running' && m.status !== 'paused' && m.status !== 'assigned') continue
|
||||
const prev = map.get(m.carId)
|
||||
if (!prev || order(m.status) < order(prev.status)) {
|
||||
map.set(m.carId, m)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function missionForCar(car: Car): Mission | undefined {
|
||||
if (car.missionId) {
|
||||
const found = props.missions.find((m) => m.id === car.missionId)
|
||||
if (found) return found
|
||||
}
|
||||
return missionByCarId.value.get(car.id)
|
||||
}
|
||||
|
||||
function batteryPct(car: Car): number {
|
||||
const raw = car.batterySoc ?? 0
|
||||
const pct = raw > 1 ? raw : raw * 100
|
||||
return Math.max(0, Math.min(100, Math.round(pct)))
|
||||
}
|
||||
|
||||
function batteryColor(p: number): string {
|
||||
if (p < 20) return '#f56c6c'
|
||||
if (p < 50) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
function batteryLevel(p: number): 'low' | 'mid' | 'high' {
|
||||
if (p < 20) return 'low'
|
||||
if (p < 50) return 'mid'
|
||||
return 'high'
|
||||
}
|
||||
|
||||
function shortType(typeName: string): string {
|
||||
const idx = typeName.lastIndexOf('.')
|
||||
return idx >= 0 ? typeName.slice(idx + 1) : typeName
|
||||
}
|
||||
|
||||
const filteredCars = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
|
||||
return props.cars.filter((c) => {
|
||||
if (filterState.value && c.state !== filterState.value) return false
|
||||
if (!tokens.length) return true
|
||||
const mission = missionForCar(c)
|
||||
const hay = [
|
||||
c.id,
|
||||
c.name,
|
||||
c.typeName ?? '',
|
||||
c.group ?? '',
|
||||
c.ip ?? '',
|
||||
stateLabel(c.state),
|
||||
mission?.name ?? '',
|
||||
mission ? missionStatusLabel(mission.status) : ''
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return tokens.every((t) => hay.includes(t))
|
||||
})
|
||||
})
|
||||
|
||||
function onSelect(car: Car) {
|
||||
emit('select', { kind: 'vehicle', id: detailIdForCar(car), name: car.name })
|
||||
}
|
||||
|
||||
function detailIdForCar(car: Car): string {
|
||||
if (car.rawId != null) return String(car.rawId)
|
||||
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-monitor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overview-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.overview-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
transition: var(--mg-transition, all 0.2s ease);
|
||||
}
|
||||
.overview-item:hover {
|
||||
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.14);
|
||||
border-color: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.4);
|
||||
}
|
||||
.overview-num {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.overview-num.online { color: var(--mg-accent); text-shadow: 0 0 12px rgba(var(--mg-accent-rgb), 0.55); }
|
||||
.overview-num.running { color: #b6e7a3; text-shadow: 0 0 12px rgba(103, 194, 58, 0.45); }
|
||||
.overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); }
|
||||
.overview-num.fault { color: #ff9bb8; text-shadow: 0 0 12px rgba(245, 108, 108, 0.5); }
|
||||
.overview-label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.toolbar .search { flex: 1; min-width: 0; }
|
||||
.toolbar .filter { width: 100px; flex: none; }
|
||||
|
||||
.vehicle-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.vehicle-row {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.vehicle-row:hover {
|
||||
background: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.12);
|
||||
border-color: rgba(var(--mg-accent-rgb, 142, 200, 252), 0.35);
|
||||
}
|
||||
.vehicle-row.is-selected {
|
||||
background: rgba(109, 40, 217, 0.32);
|
||||
border-color: rgba(167, 139, 250, 0.75);
|
||||
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95);
|
||||
}
|
||||
|
||||
.vehicle-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.vehicle-id-name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.vid {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
|
||||
flex: none;
|
||||
}
|
||||
.vname {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.dot-running { color: #6fcd45; box-shadow: 0 0 8px #6fcd45; animation: pulse 1.5s infinite; }
|
||||
.dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); }
|
||||
.dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; }
|
||||
.dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; }
|
||||
.dot-offline { color: rgba(255, 255, 255, 0.35); }
|
||||
.dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.vehicle-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.meta-item { white-space: nowrap; }
|
||||
|
||||
.battery-row,
|
||||
.mission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.battery-label,
|
||||
.mission-label {
|
||||
width: 32px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
flex: none;
|
||||
}
|
||||
.battery-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.battery-bar :deep(.el-progress-bar__outer) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.battery-text {
|
||||
width: 38px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
flex: none;
|
||||
}
|
||||
.battery-text.low { color: #f56c6c; }
|
||||
.battery-text.mid { color: #e6a23c; }
|
||||
.battery-text.high { color: #67c23a; }
|
||||
|
||||
.mission-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mission-name.muted { color: rgba(255, 255, 255, 0.5); }
|
||||
.mission-tag { flex: none; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.7); }
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="workbench-panel">
|
||||
<el-tabs v-model="activeNav" class="nav-tabs" @tab-change="onNavChange">
|
||||
<el-tab-pane label="地图" name="map" />
|
||||
<el-tab-pane label="进程" name="process" />
|
||||
<el-tab-pane label="车辆" name="vehicle" />
|
||||
<el-tab-pane label="场景" name="scene" />
|
||||
<el-tab-pane label="脚本" name="script" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="toolbar-hint muted">
|
||||
{{ navHint }}
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="search"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索(多词空格分隔)"
|
||||
class="search-input"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
:data="filteredRows"
|
||||
size="small"
|
||||
stripe
|
||||
highlight-current-row
|
||||
:row-class-name="rowClassName"
|
||||
max-height="280"
|
||||
class="list-table"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="col in list?.columns ?? []"
|
||||
:key="col"
|
||||
:prop="columnProp(col)"
|
||||
:label="col"
|
||||
min-width="72"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ cellValue(row, col) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { listWorkbench } from '@/api/workbench'
|
||||
import type { WorkbenchList, WorkbenchListRow, WorkbenchNav, SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
const props = defineProps<{
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [ref: SelectedObjectRef]
|
||||
}>()
|
||||
|
||||
const activeNav = ref<WorkbenchNav>('vehicle')
|
||||
const list = ref<WorkbenchList | null>(null)
|
||||
const search = ref('')
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
const navHints: Record<WorkbenchNav, string> = {
|
||||
map: '地图/装饰对象列表(对齐 SimpleLite 工作台「地图」)',
|
||||
process: '自定义进程 Mission 列表',
|
||||
vehicle: '场景车辆列表(ID / 地址 / 名称 / 概况)',
|
||||
scene: '站点、路径、装饰物',
|
||||
script: '每辆车挂载的 CarProgram'
|
||||
}
|
||||
|
||||
const navHint = computed(() => navHints[activeNav.value])
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const rows = list.value?.rows ?? []
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return rows
|
||||
const tokens = q.split(/\s+/).filter(Boolean)
|
||||
return rows.filter((row) =>
|
||||
tokens.every((t) => rowMatches(row, t))
|
||||
)
|
||||
})
|
||||
|
||||
function rowMatches(row: WorkbenchListRow, token: string): boolean {
|
||||
const parts = [
|
||||
row.id,
|
||||
row.name,
|
||||
row.type,
|
||||
row.status,
|
||||
row.overview ?? '',
|
||||
...Object.values(row.cells ?? {})
|
||||
]
|
||||
return parts.some((p) => p.toLowerCase().includes(token))
|
||||
}
|
||||
|
||||
function columnProp(col: string): string {
|
||||
const map: Record<string, keyof WorkbenchListRow> = {
|
||||
ID: 'id',
|
||||
名称: 'name',
|
||||
类型: 'type',
|
||||
图层: 'status',
|
||||
状态: 'status',
|
||||
信息: 'status',
|
||||
概况: 'overview',
|
||||
地址: 'status',
|
||||
车辆: 'name',
|
||||
脚本名: 'type'
|
||||
}
|
||||
return (map[col] ?? 'name') as string
|
||||
}
|
||||
|
||||
function cellValue(row: WorkbenchListRow, col: string): string {
|
||||
if (row.cells?.[col]) return row.cells[col]
|
||||
const prop = columnProp(col)
|
||||
const v = row[prop as keyof WorkbenchListRow]
|
||||
if (prop === 'overview' && row.overview) return row.overview
|
||||
return v != null ? String(v) : '—'
|
||||
}
|
||||
|
||||
function rowClassName({ row }: { row: WorkbenchListRow }) {
|
||||
return row.id === selectedId.value ? 'is-selected-row' : ''
|
||||
}
|
||||
|
||||
function onRowClick(row: WorkbenchListRow) {
|
||||
selectedId.value = row.id
|
||||
const kind = activeNav.value === 'process' ? 'process'
|
||||
: activeNav.value === 'script' ? 'script'
|
||||
: activeNav.value
|
||||
emit('select', { kind, id: row.id, name: row.name })
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
list.value = await listWorkbench(activeNav.value)
|
||||
}
|
||||
|
||||
function onNavChange() {
|
||||
selectedId.value = null
|
||||
loadList()
|
||||
}
|
||||
|
||||
watch(() => props.refreshKey, loadList)
|
||||
watch(activeNav, loadList)
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workbench-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.nav-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.toolbar-hint { font-size: 11px; line-height: 1.4; }
|
||||
.search-input { flex: none; }
|
||||
.list-table { flex: 1; min-height: 0; }
|
||||
.workbench-panel,
|
||||
.workbench-panel :deep(.el-tabs__item),
|
||||
.workbench-panel :deep(.el-table .cell),
|
||||
.workbench-panel :deep(.el-input__inner) {
|
||||
color: #fff;
|
||||
}
|
||||
.toolbar-hint { color: rgba(255, 255, 255, 0.88); }
|
||||
.muted { color: rgba(255, 255, 255, 0.75); }
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
:deep(.is-selected-row) td {
|
||||
background: rgba(var(--mg-primary-hover-rgb), 0.28) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="canvas-toolbar">
|
||||
<!-- 对齐 -->
|
||||
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
|
||||
<el-button size="small" :loading="busy.align">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span class="btn-label">对齐</span>
|
||||
<el-icon class="caret"><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="toolbar-menu">
|
||||
<div v-for="opt in ALIGN_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleAlign(opt.key)">
|
||||
<el-checkbox :model-value="state?.align[opt.key] ?? false" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 选择 -->
|
||||
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
|
||||
<el-button size="small" :loading="busy.select">
|
||||
<el-icon><Pointer /></el-icon>
|
||||
<span class="btn-label">选择</span>
|
||||
<el-icon class="caret"><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="toolbar-menu">
|
||||
<div v-for="opt in SELECT_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleSelect(opt.key)">
|
||||
<el-checkbox :model-value="state?.select[opt.key] ?? false" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 右击功能 -->
|
||||
<el-dropdown trigger="click" placement="top-start">
|
||||
<el-button size="small">
|
||||
<el-icon><FirstAidKit /></el-icon>
|
||||
<span class="btn-label">右击功能</span>
|
||||
<el-icon class="caret"><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="toolbar-menu">
|
||||
<el-dropdown-item @click="onContextMenuPlaceholder">寻路</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<span class="separator" />
|
||||
|
||||
<!-- 显示图层 -->
|
||||
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
|
||||
<el-button size="small" :loading="busy.layer">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span class="btn-label">显示图层</span>
|
||||
<el-icon class="caret"><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="toolbar-menu layer-menu">
|
||||
<div v-if="!state || state.layers.length === 0" class="menu-empty">(无图层)</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="ly in state.layers"
|
||||
:key="ly.name"
|
||||
class="menu-row"
|
||||
@click.stop="toggleLayer(ly.name, !ly.visible)"
|
||||
>
|
||||
<el-checkbox :model-value="ly.visible" />
|
||||
<span>图层 {{ ly.name }}</span>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 显示内容 -->
|
||||
<el-dropdown trigger="click" :hide-on-click="false" placement="top-start">
|
||||
<el-button size="small" :loading="busy.display">
|
||||
<el-icon><View /></el-icon>
|
||||
<span class="btn-label">显示内容</span>
|
||||
<el-icon class="caret"><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="toolbar-menu">
|
||||
<div v-for="opt in DISPLAY_OPTIONS" :key="opt.key" class="menu-row" @click.stop="toggleDisplay(opt.key)">
|
||||
<el-checkbox :model-value="state?.display[opt.key] ?? false" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<span class="separator" />
|
||||
|
||||
<!-- 录制 -->
|
||||
<el-button
|
||||
size="small"
|
||||
:type="state?.recording.isRecording ? 'danger' : 'default'"
|
||||
:loading="busy.recording"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<el-icon v-if="state?.recording.isRecording"><VideoPause /></el-icon>
|
||||
<el-icon v-else><VideoCamera /></el-icon>
|
||||
<span class="btn-label">{{ recordLabel }}</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 回放 -->
|
||||
<el-button
|
||||
size="small"
|
||||
:type="state?.recording.isPlaying ? 'warning' : 'default'"
|
||||
:loading="busy.playback"
|
||||
@click="togglePlayback"
|
||||
>
|
||||
<el-icon v-if="state?.recording.isPlaying"><CircleClose /></el-icon>
|
||||
<el-icon v-else><VideoPlay /></el-icon>
|
||||
<span class="btn-label">{{ state?.recording.isPlaying ? '退出回放' : '回放' }}</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 录像管理 -->
|
||||
<el-button size="small" @click="openManageDialog">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="btn-label">录像管理</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 选择录像(回放)对话框 -->
|
||||
<el-dialog
|
||||
v-model="playDialogVisible"
|
||||
title="选择录像回放"
|
||||
width="640px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<el-empty v-if="!state || state.recording.entries.length === 0" description="(无可用录像。点 ● 录制 创建首份录像。)" />
|
||||
<el-table v-else :data="state.recording.entries" size="small" stripe>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="180" />
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ formatSize(row.fileSizeBytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="frameCount" label="帧" width="70" />
|
||||
<el-table-column label="时长" width="100">
|
||||
<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.fileWriteTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!row.headerReadable" @click="playRecording(row.fileName)">回放</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 录像管理对话框 -->
|
||||
<el-dialog
|
||||
v-model="manageDialogVisible"
|
||||
title="录像管理"
|
||||
width="780px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<p class="muted-hint">目录:{{ state?.recording.recordingsDirectory ?? '—' }}</p>
|
||||
<el-empty v-if="!state || state.recording.entries.length === 0" description="(无可用录像。)" />
|
||||
<el-table v-else :data="state.recording.entries" size="small" stripe>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="200" />
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ formatSize(row.fileSizeBytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="frameCount" label="帧" width="70" />
|
||||
<el-table-column label="时长" width="100">
|
||||
<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.fileWriteTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.headerReadable" type="success" size="small">完整</el-tag>
|
||||
<el-tooltip v-else :content="row.error" placement="top">
|
||||
<el-tag type="danger" size="small">头部不可读</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!row.headerReadable" @click="playRecording(row.fileName)">回放</el-button>
|
||||
<el-button link type="danger" @click="deleteRecording(row.fileName)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Operation,
|
||||
Pointer,
|
||||
Files,
|
||||
View,
|
||||
VideoCamera,
|
||||
VideoPause,
|
||||
VideoPlay,
|
||||
CircleClose,
|
||||
Document,
|
||||
ArrowUp,
|
||||
FirstAidKit
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
workspaceToolbarApi,
|
||||
type AlignKey,
|
||||
type DisplayKey,
|
||||
type SelectKey,
|
||||
type ToolbarState
|
||||
} from '@/api/workspaceToolbar'
|
||||
|
||||
/**
|
||||
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 所有状态都通过 `/sl/projection/toolbar/*` 走 SimpleLite 全局状态,多 tab 同源。
|
||||
* - `state` 是后端权威单一真值;本地不维护"乐观"切换:每次 toggle 都拿后端最新返回回写。
|
||||
* - 录制 / 回放期间 1Hz 轮询刷新 elapsed / 帧计数,避免页面打开后停留在初值。
|
||||
* - 错误用 ElMessage.error 提示,不阻断画布主操作。
|
||||
*/
|
||||
|
||||
const ALIGN_OPTIONS: { key: AlignKey; label: string }[] = [
|
||||
{ key: 'sites', label: '站点' },
|
||||
{ key: 'cars', label: 'AGV' },
|
||||
{ key: 'tracks', label: '路径' }
|
||||
]
|
||||
|
||||
const SELECT_OPTIONS: { key: SelectKey; label: string }[] = [
|
||||
{ key: 'tracks', label: '路径' },
|
||||
{ key: 'cars', label: 'AGV' },
|
||||
{ key: 'decor', label: '区域/装饰' },
|
||||
{ key: 'sites', label: '站点' }
|
||||
]
|
||||
|
||||
const DISPLAY_OPTIONS: { key: DisplayKey; label: string }[] = [
|
||||
{ key: 'labels', label: '场景图元标记' },
|
||||
{ key: 'primitives', label: '场景图元' },
|
||||
{ key: 'cars', label: '车辆' }
|
||||
]
|
||||
|
||||
const state = ref<ToolbarState | null>(null)
|
||||
const busy = reactive({
|
||||
align: false,
|
||||
select: false,
|
||||
display: false,
|
||||
layer: false,
|
||||
recording: false,
|
||||
playback: false
|
||||
})
|
||||
|
||||
const playDialogVisible = ref(false)
|
||||
const manageDialogVisible = ref(false)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const recordLabel = computed(() => {
|
||||
const r = state.value?.recording
|
||||
if (!r?.isRecording) return '录制'
|
||||
const sec = Math.floor((r.elapsedMs ?? 0) / 1000)
|
||||
const mm = String(Math.floor(sec / 60)).padStart(2, '0')
|
||||
const ss = String(sec % 60).padStart(2, '0')
|
||||
return `停止 ${mm}:${ss}`
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.getState()
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAlign(key: AlignKey) {
|
||||
if (!state.value) return
|
||||
busy.align = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key])
|
||||
} catch (err) {
|
||||
ElMessage.error(`切换对齐失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.align = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSelect(key: SelectKey) {
|
||||
if (!state.value) return
|
||||
busy.select = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key])
|
||||
} catch (err) {
|
||||
ElMessage.error(`切换选择过滤失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.select = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDisplay(key: DisplayKey) {
|
||||
if (!state.value) return
|
||||
busy.display = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key])
|
||||
} catch (err) {
|
||||
ElMessage.error(`切换显示内容失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.display = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLayer(name: string, visible: boolean) {
|
||||
busy.layer = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.setLayerVisibility(name, visible)
|
||||
} catch (err) {
|
||||
ElMessage.error(`切换图层失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.layer = false
|
||||
}
|
||||
}
|
||||
|
||||
function onContextMenuPlaceholder() {
|
||||
ElMessage.info('寻路右键动作尚未接入 SimpleLite;请使用 SegmentPlan.FindRoute / 工作台脚本。')
|
||||
}
|
||||
|
||||
async function toggleRecording() {
|
||||
const cur = state.value?.recording
|
||||
if (!cur) return
|
||||
if (cur.isPlaying) {
|
||||
ElMessage.warning('当前在回放中,请先停止回放。')
|
||||
return
|
||||
}
|
||||
busy.recording = true
|
||||
try {
|
||||
state.value = cur.isRecording
|
||||
? await workspaceToolbarApi.stopRecording()
|
||||
: await workspaceToolbarApi.startRecording('')
|
||||
} catch (err) {
|
||||
ElMessage.error(`录制操作失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.recording = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayback() {
|
||||
const cur = state.value?.recording
|
||||
if (!cur) return
|
||||
if (cur.isRecording) {
|
||||
ElMessage.warning('录制中无法进入回放,请先停止录制。')
|
||||
return
|
||||
}
|
||||
if (cur.isPlaying) {
|
||||
busy.playback = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.stopPlayback()
|
||||
} catch (err) {
|
||||
ElMessage.error(`停止回放失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.playback = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
playDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function playRecording(fileName: string) {
|
||||
busy.playback = true
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.startPlayback(fileName)
|
||||
playDialogVisible.value = false
|
||||
manageDialogVisible.value = false
|
||||
ElMessage.success(`已开始回放 ${fileName}`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`启动回放失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
busy.playback = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecording(fileName: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除录像 ${fileName}?此操作不可恢复。`, '删除录像', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
state.value = await workspaceToolbarApi.deleteRecording(fileName)
|
||||
ElMessage.success(`已删除 ${fileName}`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`删除失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function openManageDialog() {
|
||||
await refresh()
|
||||
manageDialogVisible.value = true
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`
|
||||
return `${(kb / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '00:00:00'
|
||||
const sec = Math.floor(ms / 1000)
|
||||
const h = String(Math.floor(sec / 3600)).padStart(2, '0')
|
||||
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
|
||||
const s = String(sec % 60).padStart(2, '0')
|
||||
return `${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
pollTimer = setInterval(() => {
|
||||
const r = state.value?.recording
|
||||
if (r?.isRecording || r?.isPlaying) void refresh()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom-left-radius: var(--mg-radius, 16px);
|
||||
border-bottom-right-radius: var(--mg-radius, 16px);
|
||||
backdrop-filter: blur(10px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(140%);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.canvas-toolbar :deep(.el-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.btn-label {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.caret {
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.toolbar-menu .menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.toolbar-menu .menu-row:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.toolbar-menu .menu-row :deep(.el-checkbox) {
|
||||
margin-right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.layer-menu {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.menu-empty {
|
||||
padding: 10px 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted-hint {
|
||||
margin: 0 0 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { BatchOp } from '@/api/mapEdit'
|
||||
|
||||
/**
|
||||
* 对齐算法:纯前端计算每个选中对象的新坐标,再以 batch ops(patch)回写到反射 API。
|
||||
* 仅支持有 (x, y) 坐标的对象(site / image / text / model 等 UIHelper 子类);
|
||||
* track 是端点连线,没有自己的坐标,alignment 不影响 track。
|
||||
*/
|
||||
|
||||
export interface AlignTarget {
|
||||
kind: string
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export type AlignMode =
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'centerH'
|
||||
| 'centerV'
|
||||
| 'center'
|
||||
| 'distributeH'
|
||||
| 'distributeV'
|
||||
| 'toFirst'
|
||||
| 'toLast'
|
||||
|
||||
/**
|
||||
* 根据对齐模式计算 patch ops。返回值为 mapEdit.batch 直接消费的 BatchOp 数组。
|
||||
* 输入要求至少 2 个对象(distribute 需要 ≥ 3 才有意义)。
|
||||
*/
|
||||
export function buildAlignOps(targets: AlignTarget[], mode: AlignMode): BatchOp[] {
|
||||
if (targets.length < 2) return []
|
||||
|
||||
const ops: BatchOp[] = []
|
||||
|
||||
const xs = targets.map((t) => t.x)
|
||||
const ys = targets.map((t) => t.y)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const avgX = xs.reduce((a, b) => a + b, 0) / xs.length
|
||||
const avgY = ys.reduce((a, b) => a + b, 0) / ys.length
|
||||
|
||||
switch (mode) {
|
||||
case 'left':
|
||||
for (const t of targets) ops.push(patch(t, { x: minX }))
|
||||
break
|
||||
case 'right':
|
||||
for (const t of targets) ops.push(patch(t, { x: maxX }))
|
||||
break
|
||||
case 'top':
|
||||
// 工程坐标 y 向上为正:top 取 max
|
||||
for (const t of targets) ops.push(patch(t, { y: maxY }))
|
||||
break
|
||||
case 'bottom':
|
||||
for (const t of targets) ops.push(patch(t, { y: minY }))
|
||||
break
|
||||
case 'centerH':
|
||||
for (const t of targets) ops.push(patch(t, { x: avgX }))
|
||||
break
|
||||
case 'centerV':
|
||||
for (const t of targets) ops.push(patch(t, { y: avgY }))
|
||||
break
|
||||
case 'center':
|
||||
for (const t of targets) ops.push(patch(t, { x: avgX, y: avgY }))
|
||||
break
|
||||
case 'distributeH': {
|
||||
const sorted = [...targets].sort((a, b) => a.x - b.x)
|
||||
const step = (sorted[sorted.length - 1]!.x - sorted[0]!.x) / (sorted.length - 1)
|
||||
sorted.forEach((t, i) => {
|
||||
const x = sorted[0]!.x + step * i
|
||||
if (Math.abs(x - t.x) > 0.5) ops.push(patch(t, { x }))
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'distributeV': {
|
||||
const sorted = [...targets].sort((a, b) => a.y - b.y)
|
||||
const step = (sorted[sorted.length - 1]!.y - sorted[0]!.y) / (sorted.length - 1)
|
||||
sorted.forEach((t, i) => {
|
||||
const y = sorted[0]!.y + step * i
|
||||
if (Math.abs(y - t.y) > 0.5) ops.push(patch(t, { y }))
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'toFirst': {
|
||||
const ref = targets[0]!
|
||||
for (const t of targets.slice(1)) ops.push(patch(t, { x: ref.x, y: ref.y }))
|
||||
break
|
||||
}
|
||||
case 'toLast': {
|
||||
const ref = targets[targets.length - 1]!
|
||||
for (const t of targets.slice(0, -1)) ops.push(patch(t, { x: ref.x, y: ref.y }))
|
||||
break
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
function patch(t: AlignTarget, data: Record<string, unknown>): BatchOp {
|
||||
return { action: 'patch', kind: t.kind, id: t.id, data }
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { BatchOp, CreateSitePayload } from '@/api/mapEdit'
|
||||
|
||||
/**
|
||||
* 站点批量生成器:横向 / 纵向 / 矩阵 / 沿路径 / 环形阵列。
|
||||
* 每个函数返回 batch ops 列表,调用方再 `mapEditApi.batch(ops)` 一次落地,
|
||||
* 同时把这条命令塞进 useHistory 栈实现整体撤销。
|
||||
*
|
||||
* 坐标单位毫米;沿路径采样用直线段近似(贝塞尔 / 弧线后续可改为参数化采样)。
|
||||
*/
|
||||
|
||||
export interface LinearGenOpts {
|
||||
count: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
}
|
||||
|
||||
/** 横向:从 (x1, y) → (x2, y) 等距生成 count 个站点(含两端)。 */
|
||||
export function genLinearH(x1: number, x2: number, y: number, opts: LinearGenOpts): BatchOp[] {
|
||||
return genLinear(x1, y, x2, y, opts)
|
||||
}
|
||||
|
||||
export function genLinearV(x: number, y1: number, y2: number, opts: LinearGenOpts): BatchOp[] {
|
||||
return genLinear(x, y1, x, y2, opts)
|
||||
}
|
||||
|
||||
function genLinear(x1: number, y1: number, x2: number, y2: number, opts: LinearGenOpts): BatchOp[] {
|
||||
const ops: BatchOp[] = []
|
||||
const n = Math.max(2, Math.floor(opts.count))
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i / (n - 1)
|
||||
const data: CreateSitePayload = {
|
||||
x: x1 + (x2 - x1) * t,
|
||||
y: y1 + (y2 - y1) * t,
|
||||
name: `${opts.namePrefix ?? 'S'}_${i + 1}`,
|
||||
layer: opts.layer
|
||||
}
|
||||
ops.push({ action: 'create', kind: 'site', data: data as unknown as Record<string, unknown> })
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
export interface MatrixGenOpts {
|
||||
rows: number
|
||||
cols: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
}
|
||||
|
||||
export function genMatrix(x1: number, y1: number, x2: number, y2: number, opts: MatrixGenOpts): BatchOp[] {
|
||||
const ops: BatchOp[] = []
|
||||
const rows = Math.max(1, Math.floor(opts.rows))
|
||||
const cols = Math.max(1, Math.floor(opts.cols))
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const tx = cols === 1 ? 0 : c / (cols - 1)
|
||||
const ty = rows === 1 ? 0 : r / (rows - 1)
|
||||
ops.push({
|
||||
action: 'create',
|
||||
kind: 'site',
|
||||
data: {
|
||||
x: x1 + (x2 - x1) * tx,
|
||||
y: y1 + (y2 - y1) * ty,
|
||||
name: `${opts.namePrefix ?? 'M'}_${r + 1}_${c + 1}`,
|
||||
layer: opts.layer
|
||||
} as unknown as Record<string, unknown>
|
||||
})
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
export interface CircularGenOpts {
|
||||
count: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
startAngleDeg?: number
|
||||
endAngleDeg?: number
|
||||
}
|
||||
|
||||
export function genCircular(cx: number, cy: number, radius: number, opts: CircularGenOpts): BatchOp[] {
|
||||
const ops: BatchOp[] = []
|
||||
const n = Math.max(1, Math.floor(opts.count))
|
||||
const start = ((opts.startAngleDeg ?? 0) * Math.PI) / 180
|
||||
const end = ((opts.endAngleDeg ?? 360) * Math.PI) / 180
|
||||
const span = end - start
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 闭合环:end-start = 2π 时不要把第 0 个与第 n 个重叠
|
||||
const denom = Math.abs(span - Math.PI * 2) < 1e-6 ? n : n - 1
|
||||
const t = denom === 0 ? 0 : i / denom
|
||||
const a = start + span * t
|
||||
ops.push({
|
||||
action: 'create',
|
||||
kind: 'site',
|
||||
data: {
|
||||
x: cx + Math.cos(a) * radius,
|
||||
y: cy + Math.sin(a) * radius,
|
||||
name: `${opts.namePrefix ?? 'C'}_${i + 1}`,
|
||||
layer: opts.layer
|
||||
} as unknown as Record<string, unknown>
|
||||
})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
export interface AlongPathGenOpts {
|
||||
/** 路径折线点数组:[x, y, x, y, ...]。 */
|
||||
polyline: number[]
|
||||
/** 站点之间间距 mm,过密会自动按总长度调整。 */
|
||||
stepMm: number
|
||||
layer?: string
|
||||
namePrefix?: string
|
||||
}
|
||||
|
||||
export function genAlongPath(opts: AlongPathGenOpts): BatchOp[] {
|
||||
const pts: number[][] = []
|
||||
const p = opts.polyline
|
||||
if (p.length < 4) return []
|
||||
for (let i = 0; i < p.length; i += 2) pts.push([p[i]!, p[i + 1]!])
|
||||
|
||||
const seg = [] as { x1: number; y1: number; x2: number; y2: number; len: number }[]
|
||||
let total = 0
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const [x1, y1] = pts[i]!
|
||||
const [x2, y2] = pts[i + 1]!
|
||||
const len = Math.hypot(x2 - x1, y2 - y1)
|
||||
seg.push({ x1: x1!, y1: y1!, x2: x2!, y2: y2!, len })
|
||||
total += len
|
||||
}
|
||||
if (total < 1) return []
|
||||
|
||||
const step = Math.max(1, opts.stepMm)
|
||||
const ops: BatchOp[] = []
|
||||
let traveled = 0
|
||||
let sIdx = 0
|
||||
let nameIdx = 1
|
||||
while (traveled <= total + 1e-3) {
|
||||
while (sIdx < seg.length && traveled > (seg[sIdx]!.len + accumulatedUpTo(seg, sIdx))) sIdx++
|
||||
if (sIdx >= seg.length) break
|
||||
const s = seg[sIdx]!
|
||||
const baseTraveled = accumulatedUpTo(seg, sIdx)
|
||||
const local = traveled - baseTraveled
|
||||
const t = s.len === 0 ? 0 : local / s.len
|
||||
ops.push({
|
||||
action: 'create',
|
||||
kind: 'site',
|
||||
data: {
|
||||
x: s.x1 + (s.x2 - s.x1) * t,
|
||||
y: s.y1 + (s.y2 - s.y1) * t,
|
||||
name: `${opts.namePrefix ?? 'P'}_${nameIdx}`,
|
||||
layer: opts.layer
|
||||
} as unknown as Record<string, unknown>
|
||||
})
|
||||
nameIdx++
|
||||
traveled += step
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
function accumulatedUpTo(seg: { len: number }[], idx: number) {
|
||||
let s = 0
|
||||
for (let i = 0; i < idx; i++) s += seg[i]!.len
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 与 Workspace3D iframe(SimpleLite WebTerminal)的 postMessage 双向桥。
|
||||
*
|
||||
* 当前 SimpleLite WebTerminal 默认会通过 window.postMessage 抛出 `workspace.pick`
|
||||
* 与 `workspace.select` 事件给父窗口(Workspace3D.vue 收到后再 emit 给业务页)。
|
||||
* 反向的 vue→iframe 通道(设置工具、应用对齐等)走 SimpleLite 反射 API(HTTP/SSE),
|
||||
* 这里的桥只承担**坐标 / 选中 / hover** 三类 iframe→vue 数据。
|
||||
*/
|
||||
|
||||
export interface CanvasPickPayload { x: number; y: number; siteId?: number }
|
||||
export interface CanvasSelectPayload { names: string[] }
|
||||
export interface CanvasMousePayload { x: number; y: number }
|
||||
|
||||
export interface CanvasBridgeOptions {
|
||||
onPick?: (p: CanvasPickPayload) => void
|
||||
onSelect?: (p: CanvasSelectPayload) => void
|
||||
onMouse?: (p: CanvasMousePayload) => void
|
||||
}
|
||||
|
||||
export function useCanvasBridge(opts: CanvasBridgeOptions = {}) {
|
||||
function onMessage(ev: MessageEvent) {
|
||||
const msg = ev?.data
|
||||
if (!msg || typeof msg !== 'object' || typeof (msg as { type?: string }).type !== 'string') return
|
||||
|
||||
const type = (msg as { type: string }).type
|
||||
const payload = (msg as { payload?: unknown }).payload
|
||||
|
||||
if (type === 'workspace.pick' && opts.onPick && payload && typeof payload === 'object' && 'x' in payload && 'y' in payload) {
|
||||
const p = payload as CanvasPickPayload
|
||||
opts.onPick({ x: Number(p.x), y: Number(p.y), siteId: typeof p.siteId === 'number' ? p.siteId : undefined })
|
||||
} else if (type === 'workspace.select' && opts.onSelect && Array.isArray(payload)) {
|
||||
opts.onSelect({ names: (payload as string[]) })
|
||||
} else if (type === 'workspace.mouse' && opts.onMouse && payload && typeof payload === 'object' && 'x' in payload && 'y' in payload) {
|
||||
const p = payload as CanvasMousePayload
|
||||
opts.onMouse({ x: Number(p.x), y: Number(p.y) })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('message', onMessage))
|
||||
onBeforeUnmount(() => window.removeEventListener('message', onMessage))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SelectionItem } from './useSelection'
|
||||
|
||||
/**
|
||||
* 编辑器剪贴板:保存最近一次「复制」操作的对象快照(含字段值),
|
||||
* 用于「粘贴 (Ctrl+V)」与「复制字段 (Copy Fields)」。
|
||||
*
|
||||
* 粘贴策略:调用方在拿到目标坐标后,用 mapEditApi.batch 创建副本(带偏移)。
|
||||
* 复制字段:调用方调 mapEditApi.copyFieldsTo 把指定字段名写到目标对象(们)。
|
||||
*
|
||||
* 注意:剪贴板里保存的是对象的"逻辑快照",不是 DOM 文本剪贴板。
|
||||
*/
|
||||
|
||||
export interface ClipboardSnapshot {
|
||||
items: Array<{
|
||||
kind: string
|
||||
sourceId: number
|
||||
typeName: string
|
||||
/** 对象的几何 / 样式字段(含 x, y 用于偏移粘贴)。 */
|
||||
fields: Record<string, string>
|
||||
}>
|
||||
fieldNames: string[]
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
const data = ref<ClipboardSnapshot | null>(null)
|
||||
|
||||
function copy(items: SelectionItem[], allFields: Record<number, Record<string, string>>) {
|
||||
if (items.length === 0) {
|
||||
data.value = null
|
||||
return
|
||||
}
|
||||
const fieldNamesSet = new Set<string>()
|
||||
const snapshot: ClipboardSnapshot = {
|
||||
items: items.map((it) => {
|
||||
const f = allFields[it.id] ?? {}
|
||||
Object.keys(f).forEach((k) => fieldNamesSet.add(k))
|
||||
return {
|
||||
kind: it.kind,
|
||||
sourceId: it.id,
|
||||
typeName: it.typeName,
|
||||
fields: f
|
||||
}
|
||||
}),
|
||||
fieldNames: []
|
||||
}
|
||||
snapshot.fieldNames = [...fieldNamesSet]
|
||||
data.value = snapshot
|
||||
}
|
||||
|
||||
function clear() {
|
||||
data.value = null
|
||||
}
|
||||
|
||||
return { data, copy, clear }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 地图编辑器当前激活工具的状态机。每次工具切换会把 EditToolRail 按钮高亮、
|
||||
* 状态栏文字与 Canvas iframe 内的鼠标行为联动。
|
||||
*
|
||||
* 工具切换有两个层面:
|
||||
* - **前端切换**:仅影响 Vue 端的按钮高亮 + 状态栏 + 下一次画布点击的本地处理(如 pick 后调反射 API 创建对象)。
|
||||
* - **后端切换**:通过反射 API `/execute/...` 触发 SimpleLite 的 CAD action(吸附 / 对齐 / 框选等)。
|
||||
*
|
||||
* 简化起见:本 composable 只负责前端状态,CAD action 由调用方决定何时触发。
|
||||
*/
|
||||
|
||||
export type EditToolId =
|
||||
// 选择组
|
||||
| 'select.single'
|
||||
| 'select.rect'
|
||||
| 'select.lasso'
|
||||
| 'select.byType.site'
|
||||
| 'select.byType.bezier'
|
||||
| 'select.byType.polyline'
|
||||
| 'select.byType.arc'
|
||||
| 'select.byType.decor'
|
||||
| 'select.byType.currentLayer'
|
||||
| 'select.invert'
|
||||
| 'select.clear'
|
||||
// 绘制组
|
||||
| 'draw.site'
|
||||
| 'draw.sites.continuous'
|
||||
| 'draw.track.polyline'
|
||||
| 'draw.track.bezier'
|
||||
| 'draw.track.arc'
|
||||
| 'draw.track.nurbs'
|
||||
| 'draw.decor.line'
|
||||
| 'draw.decor.rect'
|
||||
| 'draw.decor.circle'
|
||||
| 'draw.decor.text'
|
||||
| 'draw.image'
|
||||
| 'draw.model'
|
||||
// 变换组
|
||||
| 'transform.move'
|
||||
| 'transform.rotate'
|
||||
| 'transform.scale'
|
||||
| 'transform.duplicate'
|
||||
| 'transform.copy'
|
||||
| 'transform.paste'
|
||||
| 'transform.delete'
|
||||
| 'transform.copyFields'
|
||||
// 对齐组
|
||||
| 'align.left'
|
||||
| 'align.right'
|
||||
| 'align.top'
|
||||
| 'align.bottom'
|
||||
| 'align.centerH'
|
||||
| 'align.centerV'
|
||||
| 'align.center'
|
||||
| 'align.distributeH'
|
||||
| 'align.distributeV'
|
||||
| 'align.toFirst'
|
||||
| 'align.toLast'
|
||||
// 批量生成
|
||||
| 'batch.linearH'
|
||||
| 'batch.linearV'
|
||||
| 'batch.matrix'
|
||||
| 'batch.alongPath'
|
||||
| 'batch.circular'
|
||||
// 吸附 / 辅助
|
||||
| 'snap.grid'
|
||||
| 'snap.object'
|
||||
| 'snap.endpoint'
|
||||
| 'snap.midpoint'
|
||||
| 'show.controlPoints'
|
||||
| 'show.controlHandles'
|
||||
|
||||
export interface SnapState {
|
||||
grid: boolean
|
||||
gridSizeMm: number
|
||||
object: boolean
|
||||
endpoint: boolean
|
||||
midpoint: boolean
|
||||
toAngleDeg?: number
|
||||
}
|
||||
|
||||
export function useEditTool() {
|
||||
const activeTool = ref<EditToolId>('transform.move')
|
||||
const snap = ref<SnapState>({ grid: false, gridSizeMm: 100, object: true, endpoint: true, midpoint: false, toAngleDeg: undefined })
|
||||
const showControlPoints = ref(true)
|
||||
const showControlHandles = ref(true)
|
||||
|
||||
/** 当前画布行为类别(用于状态栏 / 鼠标光标 hint)。 */
|
||||
const toolCategory = computed<'select' | 'draw' | 'transform' | 'align' | 'batch' | 'snap' | 'show'>(() => {
|
||||
const id = activeTool.value
|
||||
if (id.startsWith('select.')) return 'select'
|
||||
if (id.startsWith('draw.')) return 'draw'
|
||||
if (id.startsWith('transform.')) return 'transform'
|
||||
if (id.startsWith('align.')) return 'align'
|
||||
if (id.startsWith('batch.')) return 'batch'
|
||||
if (id.startsWith('snap.')) return 'snap'
|
||||
return 'show'
|
||||
})
|
||||
|
||||
/** 当前工具的人类可读名称(状态栏展示)。 */
|
||||
const toolLabel = computed(() => {
|
||||
const map: Record<EditToolId, string> = {
|
||||
'select.single': '单选',
|
||||
'select.rect': '矩形框选',
|
||||
'select.lasso': '圈选',
|
||||
'select.byType.site': '按类型选 - 站点',
|
||||
'select.byType.bezier': '按类型选 - 贝塞尔',
|
||||
'select.byType.polyline': '按类型选 - 折线',
|
||||
'select.byType.arc': '按类型选 - 弧',
|
||||
'select.byType.decor': '按类型选 - 装饰物',
|
||||
'select.byType.currentLayer': '按类型选 - 当前图层',
|
||||
'select.invert': '反选',
|
||||
'select.clear': '清空选中',
|
||||
'draw.site': '添加站点',
|
||||
'draw.sites.continuous': '连续添加站点',
|
||||
'draw.track.polyline': '添加折线路径',
|
||||
'draw.track.bezier': '添加贝塞尔路径',
|
||||
'draw.track.arc': '添加弧形路径',
|
||||
'draw.track.nurbs': '添加 NURBS 路径',
|
||||
'draw.decor.line': '添加线段',
|
||||
'draw.decor.rect': '添加矩形',
|
||||
'draw.decor.circle': '添加圆',
|
||||
'draw.decor.text': '添加文本',
|
||||
'draw.image': '插入图片',
|
||||
'draw.model': '插入 3D 模型',
|
||||
'transform.move': '移动',
|
||||
'transform.rotate': '旋转',
|
||||
'transform.scale': '缩放',
|
||||
'transform.duplicate': '复制副本',
|
||||
'transform.copy': '复制',
|
||||
'transform.paste': '粘贴',
|
||||
'transform.delete': '删除',
|
||||
'transform.copyFields': '复制字段',
|
||||
'align.left': '左对齐',
|
||||
'align.right': '右对齐',
|
||||
'align.top': '上对齐',
|
||||
'align.bottom': '下对齐',
|
||||
'align.centerH': '水平居中',
|
||||
'align.centerV': '垂直居中',
|
||||
'align.center': '整体居中',
|
||||
'align.distributeH': '水平等距',
|
||||
'align.distributeV': '垂直等距',
|
||||
'align.toFirst': '对齐到第一个',
|
||||
'align.toLast': '对齐到最后一个',
|
||||
'batch.linearH': '横向间隔生成',
|
||||
'batch.linearV': '纵向间隔生成',
|
||||
'batch.matrix': '矩阵生成',
|
||||
'batch.alongPath': '沿路径采样生成',
|
||||
'batch.circular': '环形阵列',
|
||||
'snap.grid': '网格吸附',
|
||||
'snap.object': '对象吸附',
|
||||
'snap.endpoint': '端点吸附',
|
||||
'snap.midpoint': '中点吸附',
|
||||
'show.controlPoints': '显示控制点',
|
||||
'show.controlHandles': '显示控制柄'
|
||||
}
|
||||
return map[activeTool.value] ?? activeTool.value
|
||||
})
|
||||
|
||||
function setTool(id: EditToolId) {
|
||||
activeTool.value = id
|
||||
}
|
||||
|
||||
function toggleSnap(key: 'grid' | 'object' | 'endpoint' | 'midpoint') {
|
||||
snap.value = { ...snap.value, [key]: !snap.value[key] }
|
||||
}
|
||||
|
||||
return {
|
||||
activeTool,
|
||||
snap,
|
||||
showControlPoints,
|
||||
showControlHandles,
|
||||
toolCategory,
|
||||
toolLabel,
|
||||
setTool,
|
||||
toggleSnap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 编辑器命令栈:所有反射 API 写操作都包装为 `EditCommand`,做 / 撤销 双向。
|
||||
*
|
||||
* 设计要点:
|
||||
* - `apply` / `revert` 都是异步,让命令内部 await 反射 API。
|
||||
* - `apply` 内部可以记录新建对象的 id(如 createSite 返回 id),在 closure 里保存供 `revert` 使用。
|
||||
* - 栈深度默认 20(撤销最多 20 步),覆盖创建站点 / 配置站点字段 / 创建路径 / 删除 / 对齐 /
|
||||
* 字段复制 / 批量生成 / AI 落地 等所有走 `run()` 的写操作;超出旧命令直接丢弃。
|
||||
* - 批处理(如「矩阵生成」一次调用产生 N 条 create)会被包装为单个 `CompositeCommand`,
|
||||
* 一次撤销整体回退。
|
||||
*
|
||||
* 注意:本栈只跟踪通过 `do(...)` 提交的命令;如果直接调反射 API(不通过这里),栈不感知。
|
||||
* 推荐所有编辑器写操作通过此 composable 暴露的命令工厂触发。
|
||||
*/
|
||||
|
||||
export interface EditCommand {
|
||||
/** 命令的人类可读描述(用于撤销提示气泡)。 */
|
||||
label: string
|
||||
/** 正向执行。可能产生副作用(创建/删除/修改对象,调反射 API)。 */
|
||||
apply: () => Promise<void> | void
|
||||
/** 反向撤销。要保证幂等:多次 revert 不应崩。 */
|
||||
revert: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export interface UseHistoryOptions {
|
||||
maxDepth?: number
|
||||
onChange?: () => void
|
||||
}
|
||||
|
||||
export function useHistory(opts: UseHistoryOptions = {}) {
|
||||
const maxDepth = opts.maxDepth ?? 20
|
||||
|
||||
const undoStack = ref<EditCommand[]>([])
|
||||
const redoStack = ref<EditCommand[]>([])
|
||||
const busy = ref(false)
|
||||
|
||||
const canUndo = computed(() => undoStack.value.length > 0 && !busy.value)
|
||||
const canRedo = computed(() => redoStack.value.length > 0 && !busy.value)
|
||||
|
||||
async function run(cmd: EditCommand) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await cmd.apply()
|
||||
undoStack.value = [...undoStack.value, cmd].slice(-maxDepth)
|
||||
redoStack.value = []
|
||||
opts.onChange?.()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
if (!canUndo.value) return
|
||||
const cmd = undoStack.value[undoStack.value.length - 1]!
|
||||
busy.value = true
|
||||
try {
|
||||
await cmd.revert()
|
||||
undoStack.value = undoStack.value.slice(0, -1)
|
||||
redoStack.value = [...redoStack.value, cmd].slice(-maxDepth)
|
||||
opts.onChange?.()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
if (!canRedo.value) return
|
||||
const cmd = redoStack.value[redoStack.value.length - 1]!
|
||||
busy.value = true
|
||||
try {
|
||||
await cmd.apply()
|
||||
redoStack.value = redoStack.value.slice(0, -1)
|
||||
undoStack.value = [...undoStack.value, cmd].slice(-maxDepth)
|
||||
opts.onChange?.()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
undoStack.value = []
|
||||
redoStack.value = []
|
||||
opts.onChange?.()
|
||||
}
|
||||
|
||||
/** 组合命令:把一组 sub-commands 当作整体撤销 / 重做。 */
|
||||
function composite(label: string, subs: EditCommand[]): EditCommand {
|
||||
return {
|
||||
label,
|
||||
apply: async () => { for (const s of subs) await s.apply() },
|
||||
revert: async () => {
|
||||
// 反向逐条 revert,确保依赖序无误(例如先删 track 再删 site)
|
||||
for (let i = subs.length - 1; i >= 0; i--) await subs[i]!.revert()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { undoStack, redoStack, busy, canUndo, canRedo, run, undo, redo, clear, composite }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useProjectionStream, type StreamEvent } from './useProjectionStream'
|
||||
|
||||
/**
|
||||
* 编辑器 / 监控页对 SimpleLite SSE 的事件订阅封装,把以下 5 类事件转给业务回调:
|
||||
*
|
||||
* - `alarm` (来自 AlarmStreamService):报警新增/更新/解除
|
||||
* - `object-created` (来自 MapEditApiController):对象创建后
|
||||
* - `object-deleted` (来自 MapEditApiController):对象删除后
|
||||
* - `object-patched` (来自 MapEditApiController):对象字段被 patch 后
|
||||
* - `object-batch-changed`(来自 batch / ai 调用):一组对象变化,建议直接整页重拉
|
||||
* - `pick-result` (来自 /pick):拾取会话完成
|
||||
*
|
||||
* 业务页只用关心自己感兴趣的事件回调,其它走默认忽略。
|
||||
*/
|
||||
|
||||
export interface AlarmEvent {
|
||||
action: 'raise' | 'update' | 'clear'
|
||||
carId: number
|
||||
carName: string
|
||||
info?: string
|
||||
x?: number
|
||||
y?: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface ObjectChangeEvent {
|
||||
kind: string
|
||||
id: number
|
||||
typeName?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface PickResultEvent {
|
||||
x: number
|
||||
y: number
|
||||
siteId: number
|
||||
}
|
||||
|
||||
export interface SelectionDetailEvent {
|
||||
/** 主选中 kind (site|track|car|null);当多选时取首个非空集合的 kind。 */
|
||||
kind: 'site' | 'track' | 'car' | null
|
||||
/** 主选中 id;多选时取首个。 */
|
||||
id: number
|
||||
siteIds: number[]
|
||||
trackIds: number[]
|
||||
carIds: number[]
|
||||
/** 完整对象名列表,例如 ["UISite-12", "UITrack-7", "Car-3"]。 */
|
||||
names: string[]
|
||||
}
|
||||
|
||||
export interface MapEditStreamHandlers {
|
||||
onAlarm?: (e: AlarmEvent) => void
|
||||
onObjectCreated?: (e: ObjectChangeEvent) => void
|
||||
onObjectDeleted?: (e: ObjectChangeEvent) => void
|
||||
onObjectPatched?: (e: ObjectChangeEvent) => void
|
||||
onObjectBatchChanged?: (e: { count: number; source?: string }) => void
|
||||
onPickResult?: (e: PickResultEvent) => void
|
||||
onSelectionDetail?: (e: SelectionDetailEvent) => void
|
||||
}
|
||||
|
||||
export function useMapEditStream(handlers: MapEditStreamHandlers) {
|
||||
const stream = useProjectionStream({ autoConnect: false })
|
||||
|
||||
function dispatch(ev: StreamEvent) {
|
||||
const payload = ev?.payload as Record<string, unknown> | undefined
|
||||
if (!payload) return
|
||||
switch (ev.kind) {
|
||||
case 'alarm':
|
||||
handlers.onAlarm?.(payload as unknown as AlarmEvent)
|
||||
break
|
||||
case 'object-created':
|
||||
handlers.onObjectCreated?.(payload as unknown as ObjectChangeEvent)
|
||||
break
|
||||
case 'object-deleted':
|
||||
handlers.onObjectDeleted?.(payload as unknown as ObjectChangeEvent)
|
||||
break
|
||||
case 'object-patched':
|
||||
handlers.onObjectPatched?.(payload as unknown as ObjectChangeEvent)
|
||||
break
|
||||
case 'object-batch-changed':
|
||||
handlers.onObjectBatchChanged?.(payload as unknown as { count: number; source?: string })
|
||||
break
|
||||
case 'pick-result':
|
||||
handlers.onPickResult?.(payload as unknown as PickResultEvent)
|
||||
break
|
||||
case 'selection-detail':
|
||||
handlers.onSelectionDetail?.(payload as unknown as SelectionDetailEvent)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
stream.on(dispatch)
|
||||
stream.connect()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
stream.off(dispatch)
|
||||
stream.disconnect()
|
||||
})
|
||||
|
||||
return { connected: stream.connected }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 与 SimpleLite `/projection/stream`(SSE)通信的轻量封装。
|
||||
*
|
||||
* 服务器以 `text/event-stream` 推送 JSON 行:
|
||||
* ```
|
||||
* event: car-state
|
||||
* data: { "kind": "car-state", "tick": 123, "payload": { ... } }
|
||||
* ```
|
||||
*
|
||||
* 离线 / 未启用时返回的 `connected = false`,调用方可继续用 3s 轮询兜底。
|
||||
*/
|
||||
|
||||
export interface StreamEvent {
|
||||
kind: string
|
||||
tick?: number
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
export interface ProjectionStreamOptions {
|
||||
/** SSE URL。默认走 axios baseURL `/api` + `/sl/projection/stream`。 */
|
||||
url?: string
|
||||
/** 挂载时自动 connect。默认 true。 */
|
||||
autoConnect?: boolean
|
||||
/** 断线后重连延时(ms)。默认 3000。 */
|
||||
reconnectDelayMs?: number
|
||||
/**
|
||||
* 额外要订阅的命名事件。这些会和 {@link KNOWN_EVENT_KINDS} 合并后注册到 EventSource。
|
||||
* 当后端新增 broadcast 事件名时,可不改动 composable,直接通过此参数补充。
|
||||
*/
|
||||
extraEventKinds?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* SimpleLite 当前后端会广播的全部 SSE 事件名。新增后端事件时务必同步追加,
|
||||
* 否则 EventSource 拿不到该事件 —— 这是历史上 alarm / object-* 被静默丢弃的根因。
|
||||
*/
|
||||
export const KNOWN_EVENT_KINDS = [
|
||||
'snapshot-tick',
|
||||
'selection-detail',
|
||||
'alarm',
|
||||
'object-created',
|
||||
'object-deleted',
|
||||
'object-patched',
|
||||
'object-batch-changed',
|
||||
'pick-result',
|
||||
'project-saved',
|
||||
'project-loaded',
|
||||
'car-state',
|
||||
'mission-status',
|
||||
'monitor-config-updated'
|
||||
] as const
|
||||
|
||||
type Listener = (event: StreamEvent) => void
|
||||
|
||||
export function useProjectionStream(opts: ProjectionStreamOptions = {}) {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
const defaultUrl = `${apiBase}/sl/projection/stream`
|
||||
const url = opts.url ?? defaultUrl
|
||||
const reconnectDelay = opts.reconnectDelayMs ?? 3000
|
||||
// 与 api/* 模块统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。
|
||||
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
const connected = ref(false)
|
||||
const lastEvent: Ref<StreamEvent | null> = ref(null)
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
let es: EventSource | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let mockTimer: ReturnType<typeof setInterval> | null = null
|
||||
let mockTick = 0
|
||||
let stopped = false
|
||||
|
||||
function connect() {
|
||||
if (stopped) return
|
||||
|
||||
// Mock 模式:不发 EventSource(无后端可连),改用本地心跳广播
|
||||
// `snapshot-tick`,让订阅方的"实时刷新"路径同样可被验证。
|
||||
if (isMock) {
|
||||
if (mockTimer) return
|
||||
connected.value = true
|
||||
mockTimer = setInterval(() => {
|
||||
mockTick++
|
||||
const event: StreamEvent = {
|
||||
kind: 'snapshot-tick',
|
||||
tick: mockTick,
|
||||
payload: { reason: 'mock', tick: mockTick }
|
||||
}
|
||||
lastEvent.value = event
|
||||
listeners.forEach((cb) => {
|
||||
try { cb(event) } catch { /* swallow */ }
|
||||
})
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
if (es) return
|
||||
try {
|
||||
es = new EventSource(url)
|
||||
} catch {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
es.onopen = () => {
|
||||
connected.value = true
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
connected.value = false
|
||||
closeSocket()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
handleEventLine(undefined, msg.data)
|
||||
}
|
||||
|
||||
// 命名事件:EventSource 默认只把"无 event: 头"的消息派发给 onmessage,
|
||||
// 任何 `event: foo` 都必须通过 addEventListener('foo', ...) 才能收到。
|
||||
// 必须覆盖 SimpleLite 后端可能广播的所有事件名 —— 漏一个就等于该事件被丢弃。
|
||||
// 当前后端事件源:
|
||||
// - ProjectionStreamModule: snapshot-tick (1Hz 心跳/计数)
|
||||
// - SimpleUI / ReflectionApiController: selection-detail
|
||||
// - AlarmStreamService: alarm
|
||||
// - MapEditApiController: object-created / object-deleted / object-patched
|
||||
// / object-batch-changed / pick-result
|
||||
// / project-saved / project-loaded
|
||||
// - 保留 car-state / mission-status 占位以兼容未来事件型推送
|
||||
const allKinds = new Set<string>([...KNOWN_EVENT_KINDS, ...(opts.extraEventKinds ?? [])])
|
||||
for (const kind of allKinds) {
|
||||
es.addEventListener(kind, (msg: MessageEvent) => {
|
||||
handleEventLine(kind, msg.data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventLine(kindHint: string | undefined, raw: unknown) {
|
||||
if (typeof raw !== 'string' || !raw) return
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
const event: StreamEvent =
|
||||
typeof parsed === 'object' && parsed !== null && 'kind' in parsed
|
||||
? (parsed as StreamEvent)
|
||||
: { kind: kindHint ?? 'message', payload: parsed }
|
||||
lastEvent.value = event
|
||||
listeners.forEach((cb) => {
|
||||
try { cb(event) } catch { /* swallow */ }
|
||||
})
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
}
|
||||
}
|
||||
|
||||
function closeSocket() {
|
||||
if (es) {
|
||||
try { es.close() } catch { /* ignore */ }
|
||||
es = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (stopped) return
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, reconnectDelay)
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
stopped = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (mockTimer) {
|
||||
clearInterval(mockTimer)
|
||||
mockTimer = null
|
||||
}
|
||||
closeSocket()
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function on(cb: Listener) { listeners.add(cb) }
|
||||
function off(cb: Listener) { listeners.delete(cb) }
|
||||
|
||||
if (opts.autoConnect !== false) connect()
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
listeners.clear()
|
||||
})
|
||||
|
||||
return { connected, lastEvent, connect, disconnect, on, off }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ReflectionObject, ReflectionKind } from '@/api/reflection'
|
||||
|
||||
/**
|
||||
* 编辑器内的多选集合 + 类型过滤工具。
|
||||
* 选中集是 `(kind, id)` 二元组集合:用 `${kind}:${id}` 作 key 去重。
|
||||
*
|
||||
* 与 SimpleLite 后端的同步:单选时把 selection 同步给 ReflectionApi(高亮 3D 场景),
|
||||
* 多选不同步(高亮不展开是因为反射 API 当前只支持单选;如需多选高亮以后再扩展)。
|
||||
*/
|
||||
|
||||
export interface SelectionItem extends ReflectionObject {
|
||||
kind: ReflectionKind
|
||||
}
|
||||
|
||||
function keyOf(kind: string, id: number) {
|
||||
return `${kind}:${id}`
|
||||
}
|
||||
|
||||
export function useSelection() {
|
||||
const items = ref<SelectionItem[]>([])
|
||||
const lastClickedKey = ref<string | null>(null)
|
||||
|
||||
const ids = computed(() => items.value.map((x) => x.id))
|
||||
const count = computed(() => items.value.length)
|
||||
|
||||
function has(kind: string, id: number) {
|
||||
const k = keyOf(kind, id)
|
||||
return items.value.some((x) => keyOf(x.kind, x.id) === k)
|
||||
}
|
||||
|
||||
function add(item: SelectionItem) {
|
||||
if (has(item.kind, item.id)) return
|
||||
items.value = [...items.value, item]
|
||||
lastClickedKey.value = keyOf(item.kind, item.id)
|
||||
}
|
||||
|
||||
function remove(kind: string, id: number) {
|
||||
const k = keyOf(kind, id)
|
||||
items.value = items.value.filter((x) => keyOf(x.kind, x.id) !== k)
|
||||
if (lastClickedKey.value === k) lastClickedKey.value = null
|
||||
}
|
||||
|
||||
function toggle(item: SelectionItem) {
|
||||
if (has(item.kind, item.id)) remove(item.kind, item.id)
|
||||
else add(item)
|
||||
}
|
||||
|
||||
function set(list: SelectionItem[]) {
|
||||
items.value = list
|
||||
lastClickedKey.value = list.length > 0 ? keyOf(list[list.length - 1]!.kind, list[list.length - 1]!.id) : null
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items.value = []
|
||||
lastClickedKey.value = null
|
||||
}
|
||||
|
||||
/** 按类型过滤当前选中:用于「按类型选」工具,例如只保留 site 子集。 */
|
||||
function filterByKind(kind: string) {
|
||||
items.value = items.value.filter((x) => x.kind === kind)
|
||||
}
|
||||
|
||||
/** 单选 - 替换;适合 single-pick 工具点击时使用。 */
|
||||
function selectSingle(item: SelectionItem) {
|
||||
items.value = [item]
|
||||
lastClickedKey.value = keyOf(item.kind, item.id)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ids,
|
||||
count,
|
||||
lastClickedKey,
|
||||
has,
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
set,
|
||||
clear,
|
||||
filterByKind,
|
||||
selectSingle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
<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 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">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-up" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</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')
|
||||
}
|
||||
})
|
||||
|
||||
interface MenuItem { path: string; label: string; icon?: unknown; children?: MenuItem[] }
|
||||
|
||||
const ADMIN_MENU: MenuItem[] = [
|
||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram },
|
||||
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation },
|
||||
{
|
||||
path: '/admin/design', label: '设计与编排', icon: Tools,
|
||||
children: [
|
||||
{ path: '/admin/map-editor', label: '地图编辑' },
|
||||
{ path: '/admin/project-properties', label: '项目属性' },
|
||||
{ path: '/admin/tracks', label: '场景管理' },
|
||||
{ path: '/admin/cars', label: '车辆管理' },
|
||||
{ path: '/admin/processes', label: '进程管理' },
|
||||
{ path: '/admin/scripts', label: '脚本管理' },
|
||||
{ path: '/admin/missions', label: '任务编排' }
|
||||
]
|
||||
},
|
||||
{ path: '/admin/playback', label: '调度回放', icon: Notebook },
|
||||
{
|
||||
path: '/admin/config', label: '平台配置中心', icon: Setting,
|
||||
children: [
|
||||
{ path: '/admin/config/system', label: '系统级配置' },
|
||||
{ path: '/admin/config/integrations', label: '外部系统对接' },
|
||||
{ path: '/admin/config/routing', label: '路径规划' },
|
||||
{ path: '/admin/config/vehicle', label: '车辆维护' },
|
||||
{ path: '/admin/config/charge', label: '充电策略' },
|
||||
{ path: '/admin/config/task', label: '任务分配' },
|
||||
{ path: '/admin/config/traffic', label: '交通管制' },
|
||||
{ path: '/admin/config/auth', label: '权限与角色' },
|
||||
{ path: '/admin/config/device', label: '设备接入' },
|
||||
{ path: '/admin/config/fleet', label: '车队生命周期' },
|
||||
{ path: '/admin/config/scenario', label: '场景模板' },
|
||||
{ path: '/admin/config/location', label: '库位管理' },
|
||||
{ path: '/admin/config/ops', label: '运营维护' },
|
||||
{ path: '/admin/config/widget', label: '自定义控件' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const MONITOR_MENU: MenuItem[] = [
|
||||
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor },
|
||||
{ path: '/monitor/map', label: '地图监控', icon: MapLocation },
|
||||
{ path: '/monitor/ops', label: '运维操作', icon: Promotion },
|
||||
{ path: '/monitor/notes', label: '运营备注', icon: Notebook }
|
||||
]
|
||||
|
||||
const currentMenu = computed<MenuItem[]>(() => (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')
|
||||
}
|
||||
}
|
||||
</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; }
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 路由过渡动画 */
|
||||
.fade-up-enter-active,
|
||||
.fade-up-leave-active {
|
||||
transition: opacity .25s cubic-bezier(.25, .8, .25, 1), transform .25s cubic-bezier(.25, .8, .25, 1);
|
||||
}
|
||||
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="blank-layout">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blank-layout {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, var(--mg-bg-app-1) 0%, var(--mg-bg-app-2) 50%, var(--mg-primary) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import './styles/theme.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
// 启动时恢复用户上次选择的主题色板(默认工业紫色)
|
||||
useUiStore(pinia).applyCurrentTheme()
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Car } from '@/types/car'
|
||||
|
||||
const NOW = new Date().toISOString()
|
||||
|
||||
export const CARS: Car[] = [
|
||||
{ id: 'C01', name: 'AGV-001', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1200, y: 2100, theta: 0, batterySoc: 0.86, state: 'running', missionId: 'M01', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.11' },
|
||||
{ id: 'C02', name: 'AGV-002', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 2900, y: 2100, theta: 90, batterySoc: 0.42, state: 'running', missionId: 'M02', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.12' },
|
||||
{ id: 'C03', name: 'AGV-003', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 1000, y: 4900, theta: 180, batterySoc: 1.0, state: 'charging', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.13' },
|
||||
{ id: 'C04', name: 'AGV-004', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7000, y: 2050, theta: 0, batterySoc: 0.71, state: 'idle', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.14' },
|
||||
{ id: 'C05', name: 'AGV-005', typeName: 'SimpleLite.RCS.CarTypes.GhostCar', x: 7050, y: 4000, theta: 270, batterySoc: 0.18, state: 'fault', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.15' },
|
||||
{ id: 'C06', name: 'AGV-006', typeName: 'SimpleLite.RCS.CarTypes.DummyCar', x: 9000, y: 2000, theta: 0, batterySoc: 0.95, state: 'offline', lastUpdate: NOW, group: '维护', ip: '10.0.1.16' }
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
SystemConfig,
|
||||
ExternalIntegrations,
|
||||
RoutingPolicy,
|
||||
VehicleMaintenancePolicy,
|
||||
ChargePolicy,
|
||||
TaskAllocationPolicy,
|
||||
TrafficRule,
|
||||
DeviceManagementConfig,
|
||||
FleetLifecycleConfig,
|
||||
ScenarioTemplateConfig,
|
||||
LocationManagement,
|
||||
OpsConfig,
|
||||
AuthRoleConfig,
|
||||
CustomWidget,
|
||||
ConfigSection
|
||||
} from '@/types/config'
|
||||
|
||||
export const DEFAULT_SYSTEM: SystemConfig = {
|
||||
dispatchLoopHz: 50,
|
||||
log: { level: 'info', rollDays: 7, maxSizeMB: 256 },
|
||||
security: { jwtExpireMin: 1440, enableSwagger: false, corsWhitelist: ['http://localhost:5173', 'https://*.intra'] }
|
||||
}
|
||||
|
||||
export const DEFAULT_INTEGRATIONS: ExternalIntegrations = {
|
||||
mes: [{ id: 'mes-1', name: 'MES 主线', url: 'http://mes.lan/api', enabled: true }],
|
||||
wms: [{ id: 'wms-1', name: 'WMS 仓储', url: 'http://wms.lan/api', enabled: true }],
|
||||
rcs: []
|
||||
}
|
||||
|
||||
export const DEFAULT_ROUTING: RoutingPolicy = {
|
||||
algorithm: 'astar',
|
||||
weights: { distance: 1, congestion: 0.5, turnPenalty: 0.2 },
|
||||
avoidance: [{ id: 'AV1', zoneId: 'Z-NORTH', rule: 'no-entry-while-loading' }],
|
||||
zoneSpeedLimits: [{ zoneId: 'Z-NARROW', maxSpeedMps: 0.5 }]
|
||||
}
|
||||
|
||||
export const DEFAULT_VEHICLE: VehicleMaintenancePolicy = {
|
||||
lowBatteryThreshold: 0.3,
|
||||
criticalBatteryThreshold: 0.15,
|
||||
faultReport: { enabled: true, emailTo: ['ops@example.com'] },
|
||||
autoRepair: { enabled: false, cooldownSec: 600 }
|
||||
}
|
||||
|
||||
export const DEFAULT_CHARGE: ChargePolicy = {
|
||||
allowMidTaskCharge: false,
|
||||
idleChargeAfterSec: 300,
|
||||
priority: [
|
||||
{ id: 'CP1', condition: 'soc<0.2', weight: 100 },
|
||||
{ id: 'CP2', condition: 'idle>5min', weight: 30 }
|
||||
]
|
||||
}
|
||||
|
||||
export const DEFAULT_TASK: TaskAllocationPolicy = {
|
||||
mode: 'leastLoad',
|
||||
loadBalance: true,
|
||||
maxQueuePerCar: 3
|
||||
}
|
||||
|
||||
export const DEFAULT_TRAFFIC: TrafficRule = {
|
||||
intersections: [{ id: 'IX1', siteIds: ['S006', 'S007'], mode: 'mutex' }],
|
||||
mutex: [{ id: 'MZ1', zoneIds: ['Z-CROSS'] }],
|
||||
yields: [{ id: 'YD1', from: 'A 区', to: 'B 区', condition: 'priority<peer' }]
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTH: AuthRoleConfig = {
|
||||
roles: [
|
||||
{
|
||||
id: 'role-admin', name: '管理员', scope: 'Platform',
|
||||
permissions: ['*'],
|
||||
widgetGrants: []
|
||||
},
|
||||
{
|
||||
id: 'role-ops', name: '运营', scope: 'RCSMonitor',
|
||||
permissions: [
|
||||
'ops.car.pause', 'ops.car.resume', 'ops.car.gohome',
|
||||
'ops.task.pause', 'ops.task.cancel', 'ops.task.reassign',
|
||||
'ops.task.boostPriority', 'monitor.note.write'
|
||||
],
|
||||
widgetGrants: [
|
||||
{ widgetId: 'MapEditor', visibility: 'readonly' },
|
||||
{ widgetId: 'CadToolbar', visibility: 'hidden' }
|
||||
]
|
||||
}
|
||||
],
|
||||
users: [
|
||||
{ id: 'u-admin', username: 'admin', roles: ['role-admin'], enabled: true },
|
||||
{ id: 'u-ops', username: 'ops', roles: ['role-ops'], enabled: true }
|
||||
]
|
||||
}
|
||||
|
||||
export const DEFAULT_DEVICE: DeviceManagementConfig = {
|
||||
drivers: [
|
||||
{ id: 'drv-elev', deviceType: '电梯', driverName: 'OpcUaElevatorDriver', version: '1.2.0' },
|
||||
{ id: 'drv-chrg', deviceType: '充电桩', driverName: 'ModbusChargerDriver', version: '1.0.5' },
|
||||
{ id: 'drv-cam', deviceType: '摄像头', driverName: 'OnvifCameraDriver', version: '2.1.0' }
|
||||
],
|
||||
devices: [
|
||||
{ id: 'dev-elev-1', name: '#1 电梯', deviceType: '电梯', protocol: 'opc-ua', address: 'opc.tcp://10.0.2.20:4840', driverId: 'drv-elev', enabled: true },
|
||||
{ id: 'dev-chrg-1', name: '充电桩-A1', deviceType: '充电桩', protocol: 'modbus-tcp', address: '10.0.2.30:502', driverId: 'drv-chrg', enabled: true }
|
||||
],
|
||||
healthPolicy: { heartbeatSec: 5, offlineSec: 30 },
|
||||
alarmPolicy: {
|
||||
enabled: true,
|
||||
rules: [
|
||||
{ level: 'warn', condition: 'offline>30s' },
|
||||
{ level: 'error', condition: 'driverException' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_FLEET: FleetLifecycleConfig = {
|
||||
groups: [
|
||||
{ id: 'G-A', name: 'A 区车队', floor: 'F1', region: 'A', carIds: ['C01', 'C02', 'C03'] },
|
||||
{ id: 'G-B', name: 'B 区车队', floor: 'F1', region: 'B', carIds: ['C04', 'C05'] }
|
||||
],
|
||||
ota: { enabled: true, batchSize: 2, rollbackOnFail: true },
|
||||
batchOps: { confirmationRequired: true, maxBatch: 10 },
|
||||
networkDiag: { rttThresholdMs: 80, packetLossThreshold: 0.02 }
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENARIO: ScenarioTemplateConfig = {
|
||||
templates: [
|
||||
{ id: 'tpl-sps', name: 'SPS 物料配送', category: 'SPS', version: '1.0.0', baselineJson: '{}' },
|
||||
{ id: 'tpl-pack', name: '电池 Pack 自动化产线', category: 'BatteryPack', version: '1.0.0', baselineJson: '{}' },
|
||||
{ id: 'tpl-loop', name: '环线运行', category: 'Loop', version: '1.0.0', baselineJson: '{}' },
|
||||
{ id: 'tpl-p2p', name: '点对点柔性搬运', category: 'P2P', version: '1.0.0', baselineJson: '{}' }
|
||||
],
|
||||
dslPolicy: { enabled: true, schemaVersion: '1' },
|
||||
lowCode: { enabled: false, editor: 'json' },
|
||||
versionPolicy: { keepVersions: 10, allowRollback: true }
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCATION: LocationManagement = {
|
||||
locations: [
|
||||
{ id: 'L01', code: 'A-01', name: 'A 区货架 1', siteId: 'S001', capacity: 20, occupied: 12 },
|
||||
{ id: 'L02', code: 'A-02', name: 'A 区货架 2', siteId: 'S002', capacity: 20, occupied: 7 },
|
||||
{ id: 'L03', code: 'B-01', name: 'B 区缓存', siteId: 'S003', capacity: 30, occupied: 25 }
|
||||
],
|
||||
inventoryRules: [{ id: 'IR1', itemType: 'PalletA', minQty: 5, maxQty: 30 }]
|
||||
}
|
||||
|
||||
export const DEFAULT_OPS: OpsConfig = {
|
||||
playback: { retentionDays: 30, samplingHz: 5 },
|
||||
logRetention: { hotDays: 7, coldDays: 180 },
|
||||
version: { keepReleases: 5 },
|
||||
monitor: {
|
||||
car: { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
site: { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
track: { propertyKeys: [], statusKeys: [], actionKeys: [] },
|
||||
carActionByType: {}
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_WIDGETS: { items: CustomWidget[] } = {
|
||||
items: [
|
||||
{
|
||||
id: 'widget-call-button', name: '呼叫按钮', schemaJson: '{"fields":[{"name":"siteId"}]}',
|
||||
layoutJson: '{"x":0,"y":0,"w":2,"h":1}', bindToScopes: ['RCSMonitor']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const CONFIG_DEFAULTS: Record<ConfigSection, unknown> = {
|
||||
system: DEFAULT_SYSTEM,
|
||||
integrations: DEFAULT_INTEGRATIONS,
|
||||
routing: DEFAULT_ROUTING,
|
||||
vehicle: DEFAULT_VEHICLE,
|
||||
charge: DEFAULT_CHARGE,
|
||||
task: DEFAULT_TASK,
|
||||
traffic: DEFAULT_TRAFFIC,
|
||||
auth: DEFAULT_AUTH,
|
||||
device: DEFAULT_DEVICE,
|
||||
fleet: DEFAULT_FLEET,
|
||||
scenario: DEFAULT_SCENARIO,
|
||||
location: DEFAULT_LOCATION,
|
||||
ops: DEFAULT_OPS,
|
||||
widget: DEFAULT_WIDGETS
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Mission } from '@/types/mission'
|
||||
|
||||
const NOW = new Date().toISOString()
|
||||
|
||||
export const MISSIONS: Mission[] = [
|
||||
{
|
||||
id: 'M01', name: 'A 区送料 #1', typeName: 'SimpleLite.RCS.Missions.MoveMission',
|
||||
priority: 50, status: 'running', carId: 'C01', createdAt: NOW,
|
||||
steps: [
|
||||
{ id: 'S1', action: 'pickup', targetSiteId: 'S001', status: 'completed' },
|
||||
{ id: 'S2', action: 'transport', targetSiteId: 'S002', status: 'running' },
|
||||
{ id: 'S3', action: 'dropoff', targetSiteId: 'S002', status: 'queued' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'M02', name: 'A→B 缓存搬运', typeName: 'SimpleLite.RCS.Missions.MoveMission',
|
||||
priority: 60, status: 'running', carId: 'C02', createdAt: NOW,
|
||||
steps: [
|
||||
{ id: 'S1', action: 'pickup', targetSiteId: 'S002', status: 'completed' },
|
||||
{ id: 'S2', action: 'transport', targetSiteId: 'S003', status: 'running' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'M03', name: 'B 区工位 W1 投料', typeName: 'SimpleLite.RCS.Missions.MoveMission',
|
||||
priority: 70, status: 'queued', createdAt: NOW,
|
||||
steps: [
|
||||
{ id: 'S1', action: 'pickup', targetSiteId: 'S003', status: 'queued' },
|
||||
{ id: 'S2', action: 'transport', targetSiteId: 'S008', status: 'queued' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'M04', name: 'W2→维护检修', typeName: 'SimpleLite.RCS.Missions.MaintenanceMission',
|
||||
priority: 30, status: 'paused', carId: 'C05', createdAt: NOW,
|
||||
steps: [
|
||||
{ id: 'S1', action: 'goto', targetSiteId: 'S010', status: 'paused' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,317 @@
|
||||
import type {
|
||||
ReflectionAssembly,
|
||||
ReflectionKind,
|
||||
ReflectionKindMeta,
|
||||
ReflectionKv,
|
||||
ReflectionMethod,
|
||||
ReflectionObject,
|
||||
ReflectionTypeMethods
|
||||
} from '@/api/reflection'
|
||||
import { CARS } from './cars'
|
||||
import { MISSIONS } from './missions'
|
||||
import { SITES } from './sites'
|
||||
import { TRACKS } from './tracks'
|
||||
|
||||
const baseMethods = (typeName: string): ReflectionMethod[] => [
|
||||
{
|
||||
methodName: 'Reset',
|
||||
label: '复位',
|
||||
description: `重置 ${typeName} 内部状态`,
|
||||
returnType: 'void',
|
||||
hasParams: false,
|
||||
params: []
|
||||
},
|
||||
{
|
||||
methodName: 'SetEnabled',
|
||||
label: '启用 / 禁用',
|
||||
returnType: 'void',
|
||||
hasParams: true,
|
||||
params: [
|
||||
{ name: 'enabled', typeName: 'Boolean', hasDefault: true, defaultValue: 'true' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const carMethods: ReflectionMethod[] = [
|
||||
...baseMethods('Car'),
|
||||
{
|
||||
methodName: 'GoToCharge',
|
||||
label: '前往充电',
|
||||
returnType: 'void',
|
||||
hasParams: true,
|
||||
params: [
|
||||
{ name: 'siteId', typeName: 'Int32', hasDefault: false, defaultValue: null }
|
||||
]
|
||||
},
|
||||
{
|
||||
methodName: 'ForceStop',
|
||||
label: '强制停止',
|
||||
description: '紧急停车,挂起当前任务',
|
||||
returnType: 'void',
|
||||
hasParams: false,
|
||||
params: []
|
||||
}
|
||||
]
|
||||
|
||||
const missionMethods: ReflectionMethod[] = [
|
||||
...baseMethods('Mission'),
|
||||
{
|
||||
methodName: 'Cancel',
|
||||
label: '取消任务',
|
||||
returnType: 'void',
|
||||
hasParams: false,
|
||||
params: []
|
||||
},
|
||||
{
|
||||
methodName: 'Reassign',
|
||||
label: '重新分配',
|
||||
returnType: 'void',
|
||||
hasParams: true,
|
||||
params: [
|
||||
{ name: 'carId', typeName: 'Int32', hasDefault: false, defaultValue: null }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function objectsFor(kind: ReflectionKind): ReflectionObject[] {
|
||||
switch (kind) {
|
||||
case 'car':
|
||||
case 'vehicle':
|
||||
case 'script':
|
||||
return CARS.map((c, i) => ({
|
||||
id: i + 1,
|
||||
name: c.name,
|
||||
typeName: c.typeName.split('.').pop() ?? c.typeName,
|
||||
layer: c.group ?? 'g',
|
||||
status: c.state,
|
||||
summary: `(${c.x}, ${c.y})`
|
||||
}))
|
||||
case 'mission':
|
||||
case 'process':
|
||||
return MISSIONS.map((m, i) => ({
|
||||
id: i + 1,
|
||||
name: m.name,
|
||||
typeName: m.typeName.split('.').pop() ?? m.typeName,
|
||||
status: m.status,
|
||||
summary: `priority=${m.priority}`
|
||||
}))
|
||||
case 'site':
|
||||
return SITES.map((s, i) => ({
|
||||
id: i + 1,
|
||||
name: s.name,
|
||||
typeName: 'UISite',
|
||||
layer: s.layerId ?? 'g',
|
||||
summary: `(${s.x}, ${s.y})`
|
||||
}))
|
||||
case 'track':
|
||||
return TRACKS.map((t, i) => ({
|
||||
id: i + 1,
|
||||
name: `${t.fromSiteId} → ${t.toSiteId}`,
|
||||
typeName: 'UITrack',
|
||||
layer: 'g',
|
||||
summary: t.kind
|
||||
}))
|
||||
case 'map':
|
||||
return [
|
||||
{ id: 1, name: 'factory_floor', typeName: 'Map', layer: 'g' },
|
||||
{ id: 2, name: 'grid_overlay', typeName: 'Map', layer: 'overlay' }
|
||||
]
|
||||
case 'special':
|
||||
return [
|
||||
{ id: 11, name: 'UIHelper #11', typeName: 'UIHelper', layer: 'helper' }
|
||||
]
|
||||
case 'scene':
|
||||
// 合成 kind:site + track + special 三者合并,每行带 subKind 字段。
|
||||
return [
|
||||
...objectsFor('site').map<ReflectionObject>((o) => ({ ...o, subKind: 'site' })),
|
||||
...objectsFor('track').map<ReflectionObject>((o) => ({ ...o, subKind: 'track' })),
|
||||
...objectsFor('special').map<ReflectionObject>((o) => ({ ...o, subKind: 'special' }))
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function methodsFor(kind: ReflectionKind): ReflectionMethod[] {
|
||||
if (kind === 'car' || kind === 'vehicle' || kind === 'script') return carMethods
|
||||
if (kind === 'mission' || kind === 'process') return missionMethods
|
||||
return baseMethods(kind)
|
||||
}
|
||||
|
||||
function statusFor(kind: ReflectionKind, id: number): ReflectionKv[] {
|
||||
if (kind === 'car' || kind === 'vehicle') {
|
||||
const car = CARS[id - 1]
|
||||
if (!car) return []
|
||||
return [
|
||||
{ key: '车体_state', value: car.state },
|
||||
{ key: '车体_batterySoc', value: `${Math.round(car.batterySoc * 100)}%` },
|
||||
{ key: 'address', value: car.ip ?? '-' },
|
||||
{ key: 'speed', value: '0.00' }
|
||||
]
|
||||
}
|
||||
if (kind === 'mission' || kind === 'process') {
|
||||
const m = MISSIONS[id - 1]
|
||||
if (!m) return []
|
||||
return [
|
||||
{ key: 'status', value: m.status },
|
||||
{ key: 'priority', value: `${m.priority}` },
|
||||
{ key: 'carId', value: m.carId ?? '—' }
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function baseFieldsFor(kind: ReflectionKind, id: number): ReflectionKv[] {
|
||||
if (kind === 'car' || kind === 'vehicle') {
|
||||
const car = CARS[id - 1]
|
||||
if (!car) return []
|
||||
return [
|
||||
{ key: 'name', value: car.name, locked: false },
|
||||
{ key: 'group', value: car.group ?? '', locked: false },
|
||||
{ key: 'batterySoc', value: `${car.batterySoc}`, locked: false }
|
||||
]
|
||||
}
|
||||
return [{ key: 'createdAt', value: new Date().toISOString(), locked: true }]
|
||||
}
|
||||
|
||||
// (kind, id) -> { field: value | null(null 表示被删除) }
|
||||
// 用于演示 mock 模式下 setField / deleteField / execute 的可见效果,避免页面看起来没反应。
|
||||
const overrideStore = new Map<string, Map<string, string | null>>()
|
||||
const executionLog = new Map<string, Array<{ method: string; params: Record<string, string>; at: string }>>()
|
||||
|
||||
function keyOf(kind: ReflectionKind, id: number) { return `${kind}#${id}` }
|
||||
|
||||
function fieldsFor(kind: ReflectionKind, id: number): ReflectionKv[] {
|
||||
const base = baseFieldsFor(kind, id)
|
||||
const overrides = overrideStore.get(keyOf(kind, id))
|
||||
if (!overrides) return base
|
||||
|
||||
const merged: ReflectionKv[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const b of base) {
|
||||
seen.add(b.key)
|
||||
if (overrides.has(b.key)) {
|
||||
const v = overrides.get(b.key)
|
||||
if (v === null) continue
|
||||
merged.push({ key: b.key, value: v ?? '', locked: b.locked })
|
||||
} else {
|
||||
merged.push(b)
|
||||
}
|
||||
}
|
||||
for (const [k, v] of overrides) {
|
||||
if (seen.has(k)) continue
|
||||
if (v === null) continue
|
||||
merged.push({ key: k, value: v ?? '', locked: false })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mockReflectionSetField(kind: ReflectionKind, id: number, field: string, value: string) {
|
||||
const k = keyOf(kind, id)
|
||||
let m = overrideStore.get(k)
|
||||
if (!m) { m = new Map(); overrideStore.set(k, m) }
|
||||
m.set(field, value)
|
||||
return { kind, id, field, value }
|
||||
}
|
||||
|
||||
export function mockReflectionDeleteField(kind: ReflectionKind, id: number, field: string) {
|
||||
const k = keyOf(kind, id)
|
||||
let m = overrideStore.get(k)
|
||||
if (!m) { m = new Map(); overrideStore.set(k, m) }
|
||||
m.set(field, null)
|
||||
return { kind, id, field }
|
||||
}
|
||||
|
||||
export function mockReflectionExecute(kind: ReflectionKind, id: number, method: string, params: Record<string, string>) {
|
||||
const k = keyOf(kind, id)
|
||||
const log = executionLog.get(k) ?? []
|
||||
log.unshift({ method, params, at: new Date().toISOString() })
|
||||
if (log.length > 20) log.length = 20
|
||||
executionLog.set(k, log)
|
||||
|
||||
const ps = Object.keys(params).length ? ` ${JSON.stringify(params)}` : ''
|
||||
return { returnValue: `mock(${method})${ps} ok` }
|
||||
}
|
||||
|
||||
export function mockReflectionKinds(): { kinds: ReflectionKindMeta[]; subKinds: Array<ReflectionKindMeta & { parent: string }> } {
|
||||
return {
|
||||
kinds: [
|
||||
{ kind: 'map', count: 2, label: '地图', group: 'primary' },
|
||||
{ kind: 'car', count: CARS.length, label: '车辆', group: 'primary' },
|
||||
{ kind: 'process', count: MISSIONS.length, label: '进程', group: 'primary' },
|
||||
{ kind: 'scene', count: SITES.length + TRACKS.length + 1, label: '场景', group: 'primary' },
|
||||
{ kind: 'script', count: CARS.length, label: '脚本', group: 'primary' }
|
||||
],
|
||||
subKinds: [
|
||||
{ kind: 'site', count: SITES.length, label: '站点', parent: 'scene' },
|
||||
{ kind: 'track', count: TRACKS.length, label: '路径', parent: 'scene' },
|
||||
{ kind: 'special', count: 1, label: '装饰物', parent: 'scene' },
|
||||
{ kind: 'mission', count: MISSIONS.length, label: '任务', parent: 'process' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function mockReflectionAssemblies(): ReflectionAssembly[] {
|
||||
return [
|
||||
{ name: 'SimpleCore', version: '1.0.0' },
|
||||
{ name: 'SimpleLite', version: '1.0.0' },
|
||||
{ name: 'StandardScene', version: '1.4.0', location: 'plugins/StandardScene.dll' },
|
||||
{ name: 'CustomFlightDeck', version: '0.1.0', location: 'plugins/CustomFlightDeck.dll' }
|
||||
]
|
||||
}
|
||||
|
||||
export function mockReflectionObjects(kind: ReflectionKind) {
|
||||
return objectsFor(kind)
|
||||
}
|
||||
|
||||
export function mockReflectionMethods(kind: ReflectionKind) {
|
||||
return methodsFor(kind)
|
||||
}
|
||||
|
||||
export function mockReflectionMethodsByType(kind: ReflectionKind): ReflectionTypeMethods[] {
|
||||
const ms = methodsFor(kind)
|
||||
if (kind === 'car' || kind === 'vehicle' || kind === 'script') {
|
||||
return [
|
||||
{ typeName: 'GhostCar', assemblyName: 'StandardScene', methods: ms },
|
||||
{ typeName: 'DummyCar', assemblyName: 'SimpleLite', methods: ms.slice(0, 2) }
|
||||
]
|
||||
}
|
||||
if (kind === 'mission' || kind === 'process') {
|
||||
return [
|
||||
{ typeName: 'DeliveryMission', assemblyName: 'StandardScene', methods: ms },
|
||||
{ typeName: 'PatrolMission', assemblyName: 'CustomFlightDeck', methods: ms.slice(0, 2) }
|
||||
]
|
||||
}
|
||||
return [{ typeName: kind, assemblyName: 'SimpleLite', methods: ms }]
|
||||
}
|
||||
|
||||
export function mockReflectionStatus(kind: ReflectionKind, id: number) {
|
||||
return statusFor(kind, id)
|
||||
}
|
||||
|
||||
export function mockReflectionFields(kind: ReflectionKind, id: number) {
|
||||
return fieldsFor(kind, id)
|
||||
}
|
||||
|
||||
export function mockReflectionBundle(kind: ReflectionKind, id: number) {
|
||||
const obj = objectsFor(kind).find((o) => o.id === id) ?? {
|
||||
id,
|
||||
name: `${kind}#${id}`,
|
||||
typeName: kind
|
||||
}
|
||||
const kvs = fieldsFor(kind, id)
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
typeName: obj.typeName,
|
||||
assembly: 'mock',
|
||||
summary: obj,
|
||||
methods: methodsFor(kind),
|
||||
status: statusFor(kind, id),
|
||||
fields: Object.fromEntries(kvs.map((f) => [f.key, f.value])),
|
||||
// Mock 数据全部当 dynamic(mock 模式下没有真实 [FieldMember])。新版面板期望这个字段。
|
||||
fieldList: kvs.map<ReflectionKv>((f) => ({
|
||||
key: f.key, value: f.value, source: 'dynamic', typeName: 'String'
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Site } from '@/types/map'
|
||||
|
||||
export const SITES: Site[] = [
|
||||
{ id: 'S001', name: 'A 区-入库点', x: 1000, y: 2000, layerId: 'L1', fields: { type: 'pickup' } },
|
||||
{ id: 'S002', name: 'A 区-出库点', x: 3000, y: 2000, layerId: 'L1', fields: { type: 'dropoff' } },
|
||||
{ id: 'S003', name: 'B 区-缓存区', x: 5000, y: 2000, layerId: 'L1', fields: { type: 'buffer' } },
|
||||
{ id: 'S004', name: '充电桩-1', x: 1000, y: 5000, layerId: 'L1', fields: { type: 'charger' } },
|
||||
{ id: 'S005', name: '充电桩-2', x: 3000, y: 5000, layerId: 'L1', fields: { type: 'charger' } },
|
||||
{ id: 'S006', name: '路口-N', x: 2000, y: 3500, layerId: 'L1', fields: { type: 'intersection' } },
|
||||
{ id: 'S007', name: '路口-S', x: 4000, y: 3500, layerId: 'L1', fields: { type: 'intersection' } },
|
||||
{ id: 'S008', name: '工位-W1', x: 7000, y: 2000, layerId: 'L2', fields: { type: 'workstation' } },
|
||||
{ id: 'S009', name: '工位-W2', x: 7000, y: 4000, layerId: 'L2', fields: { type: 'workstation' } },
|
||||
{ id: 'S010', name: '维护区', x: 9000, y: 2000, layerId: 'L2', fields: { type: 'maintenance' } }
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Track } from '@/types/map'
|
||||
|
||||
export const TRACKS: Track[] = [
|
||||
{ id: 'T001', name: 'A 区入→出', kind: 'line', fromSiteId: 'S001', toSiteId: 'S002', lengthM: 2.0, bidirectional: true },
|
||||
{ id: 'T002', name: 'A→B 主干', kind: 'line', fromSiteId: 'S002', toSiteId: 'S003', lengthM: 2.0, bidirectional: true },
|
||||
{ id: 'T003', name: '入库→充电 1', kind: 'arc', fromSiteId: 'S001', toSiteId: 'S004', lengthM: 3.2, ctrlPts: [{ x: 1500, y: 3500 }] },
|
||||
{ id: 'T004', name: '出库→充电 2', kind: 'arc', fromSiteId: 'S002', toSiteId: 'S005', lengthM: 3.2, ctrlPts: [{ x: 3500, y: 3500 }] },
|
||||
{ id: 'T005', name: '路口连接 N', kind: 'line', fromSiteId: 'S002', toSiteId: 'S006', lengthM: 1.8 },
|
||||
{ id: 'T006', name: '路口连接 S', kind: 'line', fromSiteId: 'S006', toSiteId: 'S007', lengthM: 2.0 },
|
||||
{ id: 'T007', name: '路口→工位 W1', kind: 'bezier', fromSiteId: 'S007', toSiteId: 'S008', lengthM: 4.0, ctrlPts: [{ x: 5500, y: 2500 }, { x: 6500, y: 2200 }] },
|
||||
{ id: 'T008', name: '工位 W1↔W2', kind: 'line', fromSiteId: 'S008', toSiteId: 'S009', lengthM: 2.0, bidirectional: true },
|
||||
{ id: 'T009', name: 'W2→维护', kind: 'nurbs', fromSiteId: 'S009', toSiteId: 'S010', lengthM: 3.5, ctrlPts: [{ x: 8000, y: 3000 }] }
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { WorkbenchList, SelectionDetail } from '@/types/workbench'
|
||||
import { CARS } from './cars'
|
||||
import { MISSIONS } from './missions'
|
||||
import { SITES } from './sites'
|
||||
import { TRACKS } from './tracks'
|
||||
|
||||
export function mockWorkbenchList(nav: string): WorkbenchList {
|
||||
switch (nav) {
|
||||
case 'map':
|
||||
return {
|
||||
nav: 'map',
|
||||
columns: ['ID', '名称', '类型', '图层'],
|
||||
rows: [
|
||||
{ id: '1', name: 'factory_floor', type: 'Map', status: 'g', cells: { 图层: 'g' } },
|
||||
{ id: '2', name: 'grid_overlay', type: 'Map', status: 'overlay', cells: { 图层: 'overlay' } }
|
||||
]
|
||||
}
|
||||
case 'process':
|
||||
return {
|
||||
nav: 'process',
|
||||
columns: ['名称', '状态', '类型'],
|
||||
rows: MISSIONS.map((m) => ({
|
||||
id: m.id.replace('M', ''),
|
||||
name: m.name,
|
||||
type: m.typeName.split('.').pop() ?? m.typeName,
|
||||
status: m.status,
|
||||
cells: { autoStart: 'true' }
|
||||
}))
|
||||
}
|
||||
case 'vehicle':
|
||||
return {
|
||||
nav: 'vehicle',
|
||||
columns: ['ID', '地址', '名称', '概况'],
|
||||
rows: CARS.map((c, i) => ({
|
||||
id: String(i + 1),
|
||||
name: c.name,
|
||||
type: c.typeName.split('.').pop() ?? c.typeName,
|
||||
status: c.state,
|
||||
overview: c.state,
|
||||
cells: { 地址: c.ip ?? '-', 图层: c.group ?? 'g' }
|
||||
}))
|
||||
}
|
||||
case 'script':
|
||||
return {
|
||||
nav: 'script',
|
||||
columns: ['车辆', '脚本名', '状态'],
|
||||
rows: CARS.map((c, i) => ({
|
||||
id: String(i + 1),
|
||||
name: `${c.name} (#${i + 1})`,
|
||||
type: c.missionId ? 'MoveProgram' : '(无)',
|
||||
status: c.state === 'running' ? 'Running' : '-',
|
||||
cells: { 脚本: c.missionId ? 'MoveProgram' : '(无)', 状态: c.state }
|
||||
}))
|
||||
}
|
||||
case 'scene':
|
||||
return {
|
||||
nav: 'scene',
|
||||
columns: ['ID', '名称', '类型', '信息'],
|
||||
rows: [
|
||||
...SITES.map((s) => ({
|
||||
id: s.id.replace('S', ''),
|
||||
name: s.name,
|
||||
type: 'UISite',
|
||||
status: `(${s.x}, ${s.y})`,
|
||||
cells: { 坐标: `(${s.x}, ${s.y})` }
|
||||
})),
|
||||
...TRACKS.map((t) => ({
|
||||
id: t.id.replace('T', ''),
|
||||
name: t.id,
|
||||
type: t.kind,
|
||||
status: `${t.fromSiteId} → ${t.toSiteId}`,
|
||||
cells: { 方向: 'Forward' }
|
||||
}))
|
||||
]
|
||||
}
|
||||
default:
|
||||
return { nav: nav as WorkbenchList['nav'], columns: [], rows: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function mockSelectionDetail(kind: string, id: string, tab: string): SelectionDetail {
|
||||
const car = CARS.find((_, i) => String(i + 1) === id || _.id === `C${id.padStart(2, '0')}`)
|
||||
const mission = MISSIONS.find((m) => m.id.replace('M', '') === id || m.id === `M${id.padStart(2, '0')}`)
|
||||
const base = {
|
||||
objectKind: kind,
|
||||
objectId: id,
|
||||
tab: tab as SelectionDetail['tab'],
|
||||
memberFields: [] as SelectionDetail['memberFields'],
|
||||
customFields: [] as SelectionDetail['customFields'],
|
||||
statusLines: [] as SelectionDetail['statusLines'],
|
||||
actions: [] as SelectionDetail['actions']
|
||||
}
|
||||
|
||||
if ((kind === 'vehicle' || kind === 'car' || kind === 'script') && car) {
|
||||
if (tab === 'status') {
|
||||
return {
|
||||
...base,
|
||||
name: car.name,
|
||||
typeName: car.typeName,
|
||||
layer: car.group,
|
||||
summary: [{ key: 'ID', value: car.id }, { key: '类型', value: car.typeName }],
|
||||
statusLines: [
|
||||
{ key: '地址', value: car.ip ?? '-' },
|
||||
{ key: '概况', value: car.state },
|
||||
{ key: '电量', value: `${Math.round(car.batterySoc * 100)}%` },
|
||||
{ key: '位姿', value: `(${car.x}, ${car.y}, ${car.theta}°)` }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (tab === 'action') {
|
||||
return {
|
||||
...base,
|
||||
name: car.name,
|
||||
typeName: car.typeName,
|
||||
summary: [{ key: 'ID', value: car.id }],
|
||||
actions: [
|
||||
{ id: 'pause', label: '暂停' },
|
||||
{ id: 'resume', label: '恢复' },
|
||||
{ id: 'gohome', label: '回充' }
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
name: car.name,
|
||||
typeName: car.typeName,
|
||||
layer: car.group,
|
||||
summary: [
|
||||
{ key: '站点', value: '—' },
|
||||
{ key: '概况', value: car.state },
|
||||
{ key: '速度', value: '0.80' },
|
||||
{ key: '位姿', value: `(${car.x}, ${car.y}, ${car.theta}°)` }
|
||||
],
|
||||
memberFields: [
|
||||
{ key: 'name', value: car.name, locked: false },
|
||||
{ key: 'address', value: car.ip ?? '', locked: false }
|
||||
],
|
||||
customFields: [{ key: 'group', value: car.group ?? '', locked: false }]
|
||||
}
|
||||
}
|
||||
|
||||
if ((kind === 'process' || kind === 'mission') && mission) {
|
||||
return {
|
||||
...base,
|
||||
name: mission.name,
|
||||
typeName: mission.typeName,
|
||||
summary: [
|
||||
{ key: '显示名', value: mission.typeName.split('.').pop() ?? mission.typeName },
|
||||
{ key: 'autoStart', value: 'true' },
|
||||
{ key: '状态', value: mission.status }
|
||||
],
|
||||
statusLines: tab === 'status'
|
||||
? [{ key: '状态', value: mission.status }, { key: '优先级', value: `${mission.priority}` }]
|
||||
: [],
|
||||
actions: tab === 'action' ? [{ id: 'start', label: '启动' }, { id: 'stop', label: '停止' }] : []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: id,
|
||||
typeName: kind,
|
||||
summary: [{ key: '提示', value: '未找到对象或 Mock 数据不完整' }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { LoginRequest, LoginResponse, EffectivePermissions } from '@/types/auth'
|
||||
import type { Site, Track } from '@/types/map'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
import type { OpsAuditEntry } from '@/types/ops'
|
||||
import type { ConfigEnvelope, ConfigSection } from '@/types/config'
|
||||
import { SITES } from './data/sites'
|
||||
import { TRACKS } from './data/tracks'
|
||||
import { CARS } from './data/cars'
|
||||
import { MISSIONS } from './data/missions'
|
||||
import { CONFIG_DEFAULTS } from './data/configs'
|
||||
|
||||
const PLATFORM_OPS = ['*']
|
||||
|
||||
const RCS_OPS = [
|
||||
'ops.car.pause', 'ops.car.resume', 'ops.car.gohome', 'ops.car.resetSession',
|
||||
'ops.car.manualCharge', 'ops.task.pause', 'ops.task.cancel', 'ops.task.reassign',
|
||||
'ops.task.boostPriority', 'monitor.note.write'
|
||||
]
|
||||
|
||||
function nowIso() { return new Date().toISOString() }
|
||||
|
||||
function buildPerm(scope: 'Platform' | 'RCSMonitor', username: string): EffectivePermissions {
|
||||
if (scope === 'Platform') {
|
||||
return {
|
||||
userId: `u-${username}`,
|
||||
version: 1,
|
||||
allowedOps: PLATFORM_OPS,
|
||||
visibleWidgets: [
|
||||
{ widgetId: 'MapEditor', visibility: 'interactive' },
|
||||
{ widgetId: 'CadToolbar', visibility: 'interactive' },
|
||||
{ widgetId: 'CarPanel', visibility: 'interactive' },
|
||||
{ widgetId: 'MissionEditor', visibility: 'interactive' },
|
||||
{ widgetId: 'OpsActionPanel', visibility: 'interactive' },
|
||||
{ widgetId: 'ConfigCenter', visibility: 'interactive' }
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
userId: `u-${username}`,
|
||||
version: 1,
|
||||
allowedOps: RCS_OPS,
|
||||
visibleWidgets: [
|
||||
{ widgetId: 'MapEditor', visibility: 'readonly' },
|
||||
{ widgetId: 'CadToolbar', visibility: 'hidden' },
|
||||
{ widgetId: 'CarPanel', visibility: 'readonly' },
|
||||
{ widgetId: 'MissionEditor', visibility: 'readonly' },
|
||||
{ widgetId: 'OpsActionPanel', visibility: 'interactive' },
|
||||
{ widgetId: 'ConfigCenter', visibility: 'hidden' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockLogin(req: LoginRequest): Promise<LoginResponse> {
|
||||
await delay(150)
|
||||
if (!req.username) throw new Error('用户名不能为空')
|
||||
const token = `mock-jwt.${req.scope}.${req.username}.${Date.now()}`
|
||||
// mock 服务器无法真正拉起 SimpleLite,只把 LaunchMode 翻译为 RunMode 让前端 UI 自洽。
|
||||
// - 历史前端不传 launchMode → 退到 DesktopAndWeb → runMode=WebEnabled(与改造前一致)。
|
||||
// - launchMode=WebOnly → runMode=WebOnly。
|
||||
const runMode: LoginResponse['runMode'] = req.launchMode === 'WebOnly' ? 'WebOnly' : 'WebEnabled'
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: `u-${req.username}`,
|
||||
username: req.username,
|
||||
displayName: req.username === 'admin' ? '系统管理员' : (req.username === 'ops' ? '运营人员' : req.username),
|
||||
roles: req.scope === 'Platform' ? ['role-admin'] : ['role-ops']
|
||||
},
|
||||
scope: req.scope,
|
||||
effectivePermissions: buildPerm(req.scope, req.username),
|
||||
runMode
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockSites(): Promise<Site[]> { await delay(80); return [...SITES] }
|
||||
export async function mockTracks(): Promise<Track[]> { await delay(80); return [...TRACKS] }
|
||||
export async function mockCars(): Promise<Car[]> { await delay(80); return [...CARS] }
|
||||
export async function mockMissions(): Promise<Mission[]> { await delay(80); return [...MISSIONS] }
|
||||
|
||||
const MOCK_CONFIG_STORAGE_KEY = 'simple-platform-mock-config'
|
||||
|
||||
const memConfig = new Map<ConfigSection, ConfigEnvelope>()
|
||||
|
||||
function loadPersistedConfig(): Partial<Record<ConfigSection, ConfigEnvelope>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(MOCK_CONFIG_STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw) as Partial<Record<ConfigSection, ConfigEnvelope>>
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function persistAllConfig() {
|
||||
try {
|
||||
const obj: Partial<Record<ConfigSection, ConfigEnvelope>> = {}
|
||||
for (const [k, v] of memConfig.entries()) obj[k] = v
|
||||
localStorage.setItem(MOCK_CONFIG_STORAGE_KEY, JSON.stringify(obj))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function ensureSection(section: ConfigSection): ConfigEnvelope {
|
||||
let cur = memConfig.get(section)
|
||||
if (!cur) {
|
||||
const persisted = loadPersistedConfig()[section]
|
||||
cur = persisted ?? {
|
||||
section,
|
||||
version: 1,
|
||||
updatedAt: nowIso(),
|
||||
payload: CONFIG_DEFAULTS[section]
|
||||
}
|
||||
memConfig.set(section, cur)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// 启动时从 localStorage 恢复,避免刷新丢配置
|
||||
for (const section of Object.keys(CONFIG_DEFAULTS) as ConfigSection[]) {
|
||||
ensureSection(section)
|
||||
}
|
||||
|
||||
export async function mockGetConfig<T>(section: ConfigSection): Promise<ConfigEnvelope<T>> {
|
||||
await delay(60)
|
||||
return ensureSection(section) as ConfigEnvelope<T>
|
||||
}
|
||||
|
||||
export async function mockPutConfig<T>(section: ConfigSection, payload: T): Promise<ConfigEnvelope<T>> {
|
||||
await delay(80)
|
||||
const cur = ensureSection(section)
|
||||
const next: ConfigEnvelope<T> = { section, version: cur.version + 1, updatedAt: nowIso(), payload }
|
||||
memConfig.set(section, next as ConfigEnvelope)
|
||||
persistAllConfig()
|
||||
return next
|
||||
}
|
||||
|
||||
const audits: OpsAuditEntry[] = [
|
||||
{ id: 'A001', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.car.pause', target: 'C01', result: 'ok' },
|
||||
{ id: 'A002', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.task.cancel', target: 'M03', result: 'ok' }
|
||||
]
|
||||
|
||||
export async function mockOpsExecute(req: { opCode: string; targetId: string; reason?: string; idempotencyKey?: string }) {
|
||||
await delay(120)
|
||||
const id = `A${String(audits.length + 1).padStart(3, '0')}`
|
||||
audits.unshift({ id, ts: nowIso(), user: 'mock-user', scope: 'mock', opCode: req.opCode, target: req.targetId, result: 'ok', message: req.reason })
|
||||
return { ok: true, auditId: id }
|
||||
}
|
||||
|
||||
export async function mockOpsAudits(): Promise<OpsAuditEntry[]> { await delay(60); return [...audits] }
|
||||
|
||||
function delay(ms: number) { return new Promise((r) => setTimeout(r, ms)) }
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { layout: 'blank', public: true, title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/status',
|
||||
name: 'status',
|
||||
component: () => import('@/views/ServiceStatusView.vue'),
|
||||
meta: { layout: 'blank', public: true, title: '服务状态' }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AppShell.vue'),
|
||||
meta: { scope: 'Platform' },
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } },
|
||||
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
|
||||
{ path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } },
|
||||
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
|
||||
{ path: 'cars', name: 'admin-cars', component: () => import('@/views/admin/CarPanelView.vue'), meta: { title: '车辆管理' } },
|
||||
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
||||
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
||||
{ path: 'missions', name: 'admin-missions', component: () => import('@/views/admin/MissionEditorView.vue'), meta: { title: '任务编排' } },
|
||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||
{ path: 'playback', name: 'admin-playback', component: () => import('@/views/admin/PlaybackView.vue'), meta: { title: '调度回放' } },
|
||||
{ path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统级配置' } },
|
||||
{ path: 'config/integrations', name: 'admin-config-integrations', component: () => import('@/views/admin/config/ExternalIntegrationView.vue'), meta: { title: '外部系统对接' } },
|
||||
{ path: 'config/routing', name: 'admin-config-routing', component: () => import('@/views/admin/config/RoutingPolicyView.vue'), meta: { title: '路径规划策略' } },
|
||||
{ path: 'config/vehicle', name: 'admin-config-vehicle', component: () => import('@/views/admin/config/VehicleMaintenanceView.vue'), meta: { title: '车辆维护策略' } },
|
||||
{ path: 'config/charge', name: 'admin-config-charge', component: () => import('@/views/admin/config/ChargePolicyView.vue'), meta: { title: '充电逻辑' } },
|
||||
{ path: 'config/task', name: 'admin-config-task', component: () => import('@/views/admin/config/TaskAllocationView.vue'), meta: { title: '任务分配' } },
|
||||
{ path: 'config/traffic', name: 'admin-config-traffic', component: () => import('@/views/admin/config/TrafficRuleView.vue'), meta: { title: '交通管制' } },
|
||||
{ path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限角色' } },
|
||||
{ path: 'config/device', name: 'admin-config-device', component: () => import('@/views/admin/config/DeviceHubView.vue'), meta: { title: '设备接入' } },
|
||||
{ path: 'config/fleet', name: 'admin-config-fleet', component: () => import('@/views/admin/config/FleetLifecycleView.vue'), meta: { title: '车队生命周期' } },
|
||||
{ path: 'config/scenario', name: 'admin-config-scenario', component: () => import('@/views/admin/config/ScenarioTemplateView.vue'), meta: { title: '场景模板' } },
|
||||
{ path: 'config/location', name: 'admin-config-location', component: () => import('@/views/admin/config/LocationView.vue'), meta: { title: '库位管理' } },
|
||||
{ path: 'config/ops', name: 'admin-config-ops', component: () => import('@/views/admin/config/OpsConfigView.vue'), meta: { title: '运营维护' } },
|
||||
{ path: 'config/widget', name: 'admin-config-widget', component: () => import('@/views/admin/config/CustomWidgetView.vue'), meta: { title: '自定义控件' } },
|
||||
{ path: 'config/map-monitor', name: 'admin-config-map-monitor', component: () => import('@/views/admin/config/MapMonitorConfigView.vue'), meta: { title: '地图监控配置' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/monitor',
|
||||
component: () => import('@/layouts/AppShell.vue'),
|
||||
meta: { scope: 'RCSMonitor' },
|
||||
redirect: '/monitor/map',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'monitor-dashboard', component: () => import('@/views/monitor/MonitorDashboardView.vue'), meta: { title: '运营总览' } },
|
||||
{ path: 'map', name: 'monitor-map', component: () => import('@/views/monitor/MonitorMapView.vue'), meta: { title: '地图监控' } },
|
||||
{ path: 'ops', name: 'monitor-ops', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维操作' } },
|
||||
{ path: 'notes', name: 'monitor-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运营备注' } }
|
||||
]
|
||||
},
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/login' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.public) return true
|
||||
if (!auth.isAuthed) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
// 关键守卫:localStorage 里只要有 token 字符串 isAuthed 就为 true,但服务端可能已经
|
||||
// 不认这个 token(最常见:Platform.Server 用占位 Jwt:Secret 启动,每次重启 secret 重生 →
|
||||
// 历史 token 全部失效)。如果进入受保护路由后页面恰好没立刻发 API,旧 401 拦截路径不会
|
||||
// 触发,用户就能「看到」框架页 → 看起来是「跳过了登录页」。
|
||||
//
|
||||
// 解决:本浏览器会话内首次进入受保护路由前 await 一次 validate(GET /api/auth/me),
|
||||
// 由后端 [Authorize] 判定 token 是否仍有效。失败 → store 已清空、跳 /login 并带 redirect。
|
||||
if (!auth.validated) {
|
||||
try {
|
||||
await auth.validate()
|
||||
} catch {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
// 会话 45 AR-6:switchScope 改为后端发起 ——
|
||||
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
||||
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
||||
try {
|
||||
if (to.path.startsWith('/admin') && auth.scope !== 'Platform') {
|
||||
await auth.switchScope('Platform')
|
||||
} else if (to.path.startsWith('/monitor') && auth.scope !== 'RCSMonitor') {
|
||||
await auth.switchScope('RCSMonitor')
|
||||
}
|
||||
} catch (_) { /* 服务端拒绝切换:维持原 scope,UI 会按现有 perms 渲染(多为 readonly) */ }
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const base = '迷毂'
|
||||
document.title = to.meta.title ? `${to.meta.title} · ${base}` : base
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,164 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AuthUser, EffectivePermissions, LoginRequest, RunMode, Scope } from '@/types/auth'
|
||||
import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
switchScope as apiSwitchScope,
|
||||
getMe as apiGetMe
|
||||
} from '@/api/auth'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: AuthUser | null
|
||||
scope: Scope | null
|
||||
runMode: RunMode | null
|
||||
effectivePermissions: EffectivePermissions | null
|
||||
/**
|
||||
* 本次浏览器会话内是否向后端实校过 token。
|
||||
* 仅内存态,不写 localStorage —— 页面刷新后必须重新 validate,确保 Platform.Server
|
||||
* 在用户离开期间重启(JWT secret 重生)的场景下能立刻被发现并跳登录。
|
||||
*/
|
||||
validated: boolean
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'simple.auth.token'
|
||||
const USER_KEY = 'simple.auth.user'
|
||||
const SCOPE_KEY = 'simple.auth.scope'
|
||||
const RUN_MODE_KEY = 'simple.auth.runMode'
|
||||
const PERM_KEY = 'simple.auth.perm'
|
||||
|
||||
// 会话 45 HC-2:每个字段独立 try/catch,避免一个 corrupt 字段把整个 state 干净化。
|
||||
function safeJsonParse<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw == null ? null : (JSON.parse(raw) as T)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadString(key: string): string | null {
|
||||
try { return localStorage.getItem(key) } catch { return null }
|
||||
}
|
||||
|
||||
function loadState(): AuthState {
|
||||
return {
|
||||
token: safeReadString(TOKEN_KEY),
|
||||
user: safeJsonParse<AuthUser>(USER_KEY),
|
||||
scope: safeReadString(SCOPE_KEY) as Scope | null,
|
||||
runMode: safeReadString(RUN_MODE_KEY) as RunMode | null,
|
||||
effectivePermissions: safeJsonParse<EffectivePermissions>(PERM_KEY),
|
||||
// 刷新页面后默认未校验:路由守卫会在受保护路由首次进入前 await validate()。
|
||||
validated: false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空登录态(内存 + localStorage)。validate 失败 / logout / 401 拦截共用,避免 5 行重复。 */
|
||||
function clearLocalAuth(target: AuthState) {
|
||||
target.token = null
|
||||
target.user = null
|
||||
target.scope = null
|
||||
target.runMode = null
|
||||
target.effectivePermissions = null
|
||||
target.validated = false
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(SCOPE_KEY)
|
||||
localStorage.removeItem(RUN_MODE_KEY)
|
||||
localStorage.removeItem(PERM_KEY)
|
||||
} catch { /* 隐私模式 / iframe 沙箱可能禁用 localStorage */ }
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => loadState(),
|
||||
getters: {
|
||||
isAuthed: (s) => !!s.token,
|
||||
hasOp:
|
||||
(s) =>
|
||||
(code: string): boolean => {
|
||||
const ops = s.effectivePermissions?.allowedOps ?? []
|
||||
return ops.includes('*') || ops.includes(code)
|
||||
},
|
||||
widgetOf:
|
||||
(s) =>
|
||||
(widgetId: string): 'hidden' | 'readonly' | 'interactive' => {
|
||||
const grant = s.effectivePermissions?.visibleWidgets.find((w) => w.widgetId === widgetId)
|
||||
return grant?.visibility ?? 'interactive'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async login(req: LoginRequest) {
|
||||
const resp = await apiLogin(req)
|
||||
this.token = resp.token
|
||||
this.user = resp.user
|
||||
this.scope = resp.scope
|
||||
this.runMode = resp.runMode
|
||||
this.effectivePermissions = resp.effectivePermissions
|
||||
// 登录响应本身就是后端的身份背书,等同于一次成功的 /me;省一次往返。
|
||||
this.validated = true
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(resp.user))
|
||||
localStorage.setItem(SCOPE_KEY, resp.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, resp.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(resp.effectivePermissions))
|
||||
return resp
|
||||
},
|
||||
async logout() {
|
||||
try { await apiLogout() } catch { /* ignore */ }
|
||||
clearLocalAuth(this)
|
||||
},
|
||||
/**
|
||||
* 用本地 token 让后端实校一次身份。
|
||||
* - 成功:刷新 user/scope/runMode/perm 并标记 validated=true。
|
||||
* - 失败(401/403/网络/etc):清空登录态并抛出错误,由调用方(路由守卫)跳 /login。
|
||||
* - mock 模式下 apiGetMe 主动抛 'mock-mode-skip-me-validation',视为「无须实校」直接放行。
|
||||
*/
|
||||
async validate() {
|
||||
if (!this.token) {
|
||||
// 没 token 直接清干净,避免有半残数据导致 isAuthed 抖动。
|
||||
clearLocalAuth(this)
|
||||
throw new Error('no-token')
|
||||
}
|
||||
try {
|
||||
const me = await apiGetMe()
|
||||
this.user = me.user
|
||||
this.scope = me.scope
|
||||
this.runMode = me.runMode
|
||||
this.effectivePermissions = me.effectivePermissions
|
||||
this.validated = true
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(me.user))
|
||||
localStorage.setItem(SCOPE_KEY, me.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, me.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(me.effectivePermissions))
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'mock-mode-skip-me-validation') {
|
||||
this.validated = true
|
||||
return
|
||||
}
|
||||
clearLocalAuth(this)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
// AR-6 (会话 45):原实现客户端直接改 `allowedOps = ['*']` —— 是伪权限,
|
||||
// 既不安全(后端不再校验时立刻翻车)也容易和后端 ops list 漂移。
|
||||
// 改为请求 /api/auth/switch-scope 让服务端按账号实际允许的 scope 重发 token + perms。
|
||||
// 后端若不允许该 scope(如 ops 账号尝试切到 Platform),会返回 403 → 由调用方 toast 提示。
|
||||
async switchScope(target: Scope) {
|
||||
const resp = await apiSwitchScope(target)
|
||||
this.token = resp.token
|
||||
this.user = resp.user
|
||||
this.scope = resp.scope
|
||||
this.runMode = resp.runMode
|
||||
this.effectivePermissions = resp.effectivePermissions
|
||||
// SwitchScope 后端重发了 token + perm,等同于一次成功的 /me,保持 validated 为 true。
|
||||
this.validated = true
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(resp.user))
|
||||
localStorage.setItem(SCOPE_KEY, resp.scope)
|
||||
localStorage.setItem(RUN_MODE_KEY, resp.runMode)
|
||||
localStorage.setItem(PERM_KEY, JSON.stringify(resp.effectivePermissions))
|
||||
return resp
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ConfigEnvelope, ConfigSection, OpsConfig } from '@/types/config'
|
||||
import { getConfig, putConfig } from '@/api/config'
|
||||
import { normalizeOpsConfig } from '@/utils/opsMonitorConfig'
|
||||
|
||||
interface ConfigState {
|
||||
cache: Partial<Record<ConfigSection, ConfigEnvelope>>
|
||||
loading: Partial<Record<ConfigSection, boolean>>
|
||||
}
|
||||
|
||||
export const useConfigStore = defineStore('config', {
|
||||
state: (): ConfigState => ({ cache: {}, loading: {} }),
|
||||
actions: {
|
||||
async load<T>(section: ConfigSection, force = false): Promise<ConfigEnvelope<T>> {
|
||||
if (!force && this.cache[section]) return this.cache[section] as ConfigEnvelope<T>
|
||||
this.loading[section] = true
|
||||
try {
|
||||
const env = await getConfig<T>(section)
|
||||
if (section === 'ops' && env.payload) {
|
||||
env.payload = normalizeOpsConfig(env.payload as unknown as OpsConfig) as T
|
||||
}
|
||||
this.cache[section] = env as ConfigEnvelope
|
||||
return env
|
||||
} finally {
|
||||
this.loading[section] = false
|
||||
}
|
||||
},
|
||||
async save<T>(section: ConfigSection, payload: T): Promise<ConfigEnvelope<T>> {
|
||||
this.loading[section] = true
|
||||
try {
|
||||
const body = section === 'ops'
|
||||
? (normalizeOpsConfig(payload as unknown as OpsConfig) as T)
|
||||
: payload
|
||||
const env = await putConfig<T>(section, body)
|
||||
this.cache[section] = env as ConfigEnvelope
|
||||
return env
|
||||
} finally {
|
||||
this.loading[section] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
DEFAULT_THEME_ID,
|
||||
applyThemeVars,
|
||||
findTheme,
|
||||
THEMES,
|
||||
type ThemePreset
|
||||
} from '@/styles/themes'
|
||||
import {
|
||||
mergeThemePreset,
|
||||
type ThemeColorKey,
|
||||
type ThemeColorOverrides,
|
||||
type ThemeOverridesMap
|
||||
} from '@/styles/themeCustomize'
|
||||
|
||||
interface UiState {
|
||||
sidebarCollapsed: boolean
|
||||
/**
|
||||
* @deprecated 仅保留兼容字段;实际外观由 themeId 决定,可于下一个迁移窗口删除。
|
||||
* 旧 localStorage 里可能仍残留 'light' / 'dark',反序列化时容错读取。
|
||||
*/
|
||||
theme: 'light' | 'dark'
|
||||
/** 主题色板 ID(对应 themes.ts 的 ThemePreset.id) */
|
||||
themeId: string
|
||||
/** 各主题取色盘自定义(仅存三项品牌色,其余由 derive 推导) */
|
||||
themeOverrides: ThemeOverridesMap
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'simple.ui.state'
|
||||
|
||||
const DEFAULT_STATE: UiState = {
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
themeId: DEFAULT_THEME_ID,
|
||||
themeOverrides: {}
|
||||
}
|
||||
|
||||
function loadInitial(): UiState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULT_STATE }
|
||||
const parsed = JSON.parse(raw) as Partial<UiState>
|
||||
return {
|
||||
sidebarCollapsed: parsed.sidebarCollapsed ?? DEFAULT_STATE.sidebarCollapsed,
|
||||
theme: parsed.theme === 'dark' ? 'dark' : 'light',
|
||||
themeId: parsed.themeId ?? DEFAULT_STATE.themeId,
|
||||
themeOverrides: parsed.themeOverrides ?? {}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ui.store] localStorage 中的 UI 状态解析失败,已回退默认:', err)
|
||||
return { ...DEFAULT_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', {
|
||||
state: (): UiState => loadInitial(),
|
||||
getters: {
|
||||
/** 当前生效的主题预设(含自定义配色) */
|
||||
activeTheme(state): ThemePreset {
|
||||
return mergeThemePreset(findTheme(state.themeId), state.themeOverrides[state.themeId])
|
||||
},
|
||||
/** 所有可选主题列表 */
|
||||
availableThemes(): ThemePreset[] {
|
||||
return THEMES
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
toggleSidebar() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed
|
||||
this.persist()
|
||||
},
|
||||
|
||||
setTheme(t: 'light' | 'dark') {
|
||||
this.theme = t
|
||||
document.documentElement.classList.toggle('dark', t === 'dark')
|
||||
this.persist()
|
||||
},
|
||||
|
||||
getMergedTheme(preset: ThemePreset): ThemePreset {
|
||||
return mergeThemePreset(preset, this.themeOverrides[preset.id])
|
||||
},
|
||||
|
||||
getThemeColor(themeId: string, key: ThemeColorKey): string {
|
||||
const preset = findTheme(themeId)
|
||||
return this.themeOverrides[themeId]?.[key] ?? preset.vars[key]
|
||||
},
|
||||
|
||||
setThemeColor(themeId: string, key: ThemeColorKey, hex: string) {
|
||||
if (!this.themeOverrides[themeId]) this.themeOverrides[themeId] = {}
|
||||
this.themeOverrides[themeId][key] = hex
|
||||
if (this.themeId === themeId) {
|
||||
applyThemeVars(this.getMergedTheme(findTheme(themeId)))
|
||||
}
|
||||
this.persist()
|
||||
},
|
||||
|
||||
clearThemeOverrides(themeId: string) {
|
||||
delete this.themeOverrides[themeId]
|
||||
if (this.themeId === themeId) applyThemeVars(findTheme(themeId))
|
||||
this.persist()
|
||||
},
|
||||
|
||||
clearAllThemeOverrides() {
|
||||
this.themeOverrides = {}
|
||||
applyThemeVars(this.activeTheme)
|
||||
this.persist()
|
||||
},
|
||||
|
||||
/** 切换品牌主题色板 */
|
||||
setThemeId(id: string) {
|
||||
const preset = findTheme(id)
|
||||
this.themeId = preset.id
|
||||
applyThemeVars(this.getMergedTheme(preset))
|
||||
this.persist()
|
||||
},
|
||||
|
||||
/** 应用当前已保存的主题(启动时调用) */
|
||||
applyCurrentTheme() {
|
||||
applyThemeVars(this.activeTheme)
|
||||
},
|
||||
|
||||
/** 仅持久化稳定字段,避免把瞬态/敏感字段误写入 localStorage。 */
|
||||
persist() {
|
||||
const snapshot: UiState = {
|
||||
sidebarCollapsed: this.sidebarCollapsed,
|
||||
theme: this.theme,
|
||||
themeId: this.themeId,
|
||||
themeOverrides: this.themeOverrides
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
|
||||
} catch (err) {
|
||||
console.warn('[ui.store] 持久化 UI 状态失败:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
import type { ThemePreset } from './themes'
|
||||
|
||||
/** 可在系统配置中取色盘调整的品牌色变量 */
|
||||
export const THEME_COLOR_FIELDS = [
|
||||
{ key: '--mg-primary', label: '主色(背景基色)', hint: '全页背景、左侧导航、卡片底色、按钮' },
|
||||
{ key: '--mg-primary-hover', label: '悬停/辉光', hint: '背景光晕、菜单高亮、悬停态' },
|
||||
{ key: '--mg-accent', label: '强调/高光', hint: '点缀、边框高光、Logo 光晕' }
|
||||
] as const
|
||||
|
||||
export type ThemeColorKey = (typeof THEME_COLOR_FIELDS)[number]['key']
|
||||
|
||||
export type ThemeColorOverrides = Partial<Record<ThemeColorKey, string>>
|
||||
|
||||
export type ThemeOverridesMap = Record<string, ThemeColorOverrides>
|
||||
|
||||
function parseHex(hex: string): [number, number, number] | null {
|
||||
const h = hex.trim().replace(/^#/, '')
|
||||
if (!/^[0-9a-f]{6}$/i.test(h)) return null
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
export function hexToRgbString(hex: string): string {
|
||||
const c = parseHex(hex)
|
||||
return c ? `${c[0]}, ${c[1]}, ${c[2]}` : '0, 0, 0'
|
||||
}
|
||||
|
||||
function mixHex(a: string, b: string, t: number): string {
|
||||
const ca = parseHex(a)
|
||||
const cb = parseHex(b)
|
||||
if (!ca || !cb) return a
|
||||
const ch = (i: number) => Math.round(ca[i] + (cb[i] - ca[i]) * t)
|
||||
const to = (n: number) => n.toString(16).padStart(2, '0')
|
||||
return `#${to(ch(0))}${to(ch(1))}${to(ch(2))}`
|
||||
}
|
||||
|
||||
function lighten(hex: string, amount: number): string {
|
||||
return mixHex(hex, '#ffffff', amount)
|
||||
}
|
||||
|
||||
function darken(hex: string, amount: number): string {
|
||||
return mixHex(hex, '#000000', amount)
|
||||
}
|
||||
|
||||
/** 根据三项取色结果推导相关 CSS 变量与预览色 */
|
||||
export function deriveThemeVars(
|
||||
base: ThemePreset,
|
||||
overrides: ThemeColorOverrides
|
||||
): Record<string, string> {
|
||||
const vars = { ...base.vars }
|
||||
const primary = overrides['--mg-primary'] ?? vars['--mg-primary']
|
||||
const hover = overrides['--mg-primary-hover'] ?? vars['--mg-primary-hover']
|
||||
const accent = overrides['--mg-accent'] ?? vars['--mg-accent']
|
||||
|
||||
if (overrides['--mg-primary']) {
|
||||
const bgDeep = darken(primary, 0.72)
|
||||
const bgMid = darken(primary, 0.48)
|
||||
const bgShallow = darken(primary, 0.58)
|
||||
const asideMain = darken(primary, 0.54)
|
||||
const asideDeep = darken(primary, 0.7)
|
||||
const cardBase = darken(primary, 0.38)
|
||||
const cardHi = darken(primary, 0.28)
|
||||
const cardDark = darken(primary, 0.62)
|
||||
|
||||
vars['--mg-primary'] = primary
|
||||
vars['--mg-primary-rgb'] = hexToRgbString(primary)
|
||||
vars['--mg-primary-active'] = darken(primary, 0.35)
|
||||
vars['--mg-primary-dark-2'] = darken(primary, 0.35)
|
||||
vars['--mg-primary-light-3'] = lighten(primary, 0.22)
|
||||
vars['--mg-primary-light-5'] = lighten(primary, 0.35)
|
||||
vars['--mg-primary-light-7'] = mixHex(primary, '#888888', 0.45)
|
||||
vars['--mg-primary-light-8'] = mixHex(primary, '#666666', 0.55)
|
||||
vars['--mg-primary-light-9'] = mixHex(primary, '#444444', 0.65)
|
||||
|
||||
vars['--mg-bg-app-1'] = bgShallow
|
||||
vars['--mg-bg-app-2'] = bgMid
|
||||
vars['--mg-bg-app-3'] = bgDeep
|
||||
vars['--mg-bg-app-deep-rgb'] = hexToRgbString(bgDeep)
|
||||
vars['--mg-bg-aside-1'] = asideMain
|
||||
vars['--mg-bg-aside-2'] = asideDeep
|
||||
vars['--mg-bg-aside-rgb'] = hexToRgbString(asideMain)
|
||||
vars['--mg-bg-card-rgb'] = hexToRgbString(cardBase)
|
||||
vars['--mg-bg-card-hi-rgb'] = hexToRgbString(cardHi)
|
||||
vars['--mg-bg-card-darker-rgb'] = hexToRgbString(cardDark)
|
||||
vars['--mg-shadow-rgb'] = hexToRgbString(darken(primary, 0.78))
|
||||
}
|
||||
|
||||
if (overrides['--mg-primary-hover']) {
|
||||
vars['--mg-primary-hover'] = hover
|
||||
vars['--mg-primary-hover-rgb'] = hexToRgbString(hover)
|
||||
}
|
||||
|
||||
if (overrides['--mg-accent']) {
|
||||
vars['--mg-accent'] = accent
|
||||
vars['--mg-accent-rgb'] = hexToRgbString(accent)
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
export function mergeThemePreset(
|
||||
preset: ThemePreset,
|
||||
overrides: ThemeColorOverrides | undefined
|
||||
): ThemePreset {
|
||||
if (!overrides || Object.keys(overrides).length === 0) return preset
|
||||
const vars = deriveThemeVars(preset, overrides)
|
||||
return {
|
||||
...preset,
|
||||
preview: overrides['--mg-primary'] ?? preset.preview,
|
||||
previewAccent: overrides['--mg-accent'] ?? preset.previewAccent,
|
||||
vars
|
||||
}
|
||||
}
|
||||
|
||||
export function getOverrideOrDefault(
|
||||
preset: ThemePreset,
|
||||
overrides: ThemeColorOverrides | undefined,
|
||||
key: ThemeColorKey
|
||||
): string {
|
||||
return overrides?.[key] ?? preset.vars[key]
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 主题色板预设
|
||||
*
|
||||
* 设计原则:
|
||||
* - 每个主题提供一组 CSS 变量,运行时通过 document.documentElement.style.setProperty 注入
|
||||
* - 命名遵循 theme.css 中既有的 --mg-* 体系
|
||||
* - 工业紫色 (industrial-purple) 为默认主题:紫蓝渐变,深蓝底 + 天蓝高光
|
||||
*/
|
||||
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
/** 用于 UI 预览的代表色 */
|
||||
preview: string
|
||||
/** 强调高光,用于 UI 预览的次代表色 */
|
||||
previewAccent: string
|
||||
vars: Record<string, string>
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_ID = 'fame-lavender'
|
||||
|
||||
export const THEMES: ThemePreset[] = [
|
||||
{
|
||||
id: 'fame-lavender',
|
||||
name: '迷毂浅紫',
|
||||
description: '参考 FAME 系统:深紫侧栏 + 浅紫白主区,柔和耐看,长时间使用不刺眼',
|
||||
preview: '#7c3aed',
|
||||
previewAccent: '#a855f7',
|
||||
vars: {
|
||||
// 主色保留紫色(与 FAME 一致),但 hover/accent 使用更明亮的 violet/purple
|
||||
'--mg-primary': '#7c3aed',
|
||||
'--mg-primary-rgb': '124, 58, 237',
|
||||
'--mg-primary-hover': '#8b5cf6',
|
||||
'--mg-primary-hover-rgb': '139, 92, 246',
|
||||
'--mg-primary-active': '#5b21b6',
|
||||
'--mg-accent': '#a855f7',
|
||||
'--mg-accent-rgb': '168, 85, 247',
|
||||
|
||||
'--mg-primary-light-3': '#a78bfa',
|
||||
'--mg-primary-light-5': '#c4b5fd',
|
||||
'--mg-primary-light-7': '#ddd6fe',
|
||||
'--mg-primary-light-8': '#ede9fe',
|
||||
'--mg-primary-light-9': '#f5f3ff',
|
||||
'--mg-primary-dark-2': '#6d28d9',
|
||||
|
||||
// 主区背景:浅紫白色(FAME content #f0eaf6 风格)
|
||||
'--mg-bg-app-1': '#f5f0fb',
|
||||
'--mg-bg-app-2': '#f0eaf6',
|
||||
'--mg-bg-app-3': '#faf7fd',
|
||||
'--mg-bg-app-deep-rgb': '240, 234, 246',
|
||||
|
||||
// 侧栏:FAME 深紫渐变 #2d1b69 -> #1a1040
|
||||
'--mg-bg-aside-1': '#2d1b69',
|
||||
'--mg-bg-aside-2': '#1a1040',
|
||||
'--mg-bg-aside-rgb': '45, 27, 105',
|
||||
|
||||
// 卡片:白底(在 fame-lavender 专属规则中走纯 #fff)
|
||||
'--mg-bg-card-rgb': '255, 255, 255',
|
||||
'--mg-bg-card-hi-rgb': '249, 246, 255',
|
||||
'--mg-bg-card-darker-rgb':'245, 240, 251',
|
||||
'--mg-shadow-rgb': '124, 58, 237',
|
||||
|
||||
// 文字:深紫标题 #2d1b69, 中灰正文 #606266
|
||||
'--mg-text-tint-rgb': '45, 27, 105',
|
||||
'--mg-text-hi-rgb': '96, 98, 102',
|
||||
|
||||
// 关闭装饰性 orb
|
||||
'--mg-orb-a-opacity': '0',
|
||||
'--mg-orb-b-opacity': '0',
|
||||
'--mg-orb-c-opacity': '0'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'industrial-purple',
|
||||
name: '星云紫',
|
||||
description: '科幻紫主色,辅以蓝紫渐变,磨砂玻璃 · 霓虹辉光',
|
||||
preview: '#7c3aed',
|
||||
previewAccent: '#c4b5fd',
|
||||
vars: {
|
||||
// ── 主色:明亮 violet(紫),主导整个画面 ──
|
||||
'--mg-primary': '#7c3aed',
|
||||
'--mg-primary-rgb': '124, 58, 237',
|
||||
// hover/辉光:偏蓝紫 indigo,带来氛围流动感
|
||||
'--mg-primary-hover': '#8b5cf6',
|
||||
'--mg-primary-hover-rgb': '139, 92, 246',
|
||||
'--mg-primary-active': '#5b21b6',
|
||||
// accent:浅紫高光,边缘霓虹与点缀
|
||||
'--mg-accent': '#c4b5fd',
|
||||
'--mg-accent-rgb': '196, 181, 253',
|
||||
|
||||
'--mg-primary-light-3': '#9d6cf2',
|
||||
'--mg-primary-light-5': '#b594f7',
|
||||
'--mg-primary-light-7': '#6f4eab',
|
||||
'--mg-primary-light-8': '#4c357a',
|
||||
'--mg-primary-light-9': '#2d1f55',
|
||||
'--mg-primary-dark-2': '#4c1d95',
|
||||
|
||||
// ── 背景:深紫黑底,带 indigo 余晖 ──
|
||||
'--mg-bg-app-1': '#0e0a24',
|
||||
'--mg-bg-app-2': '#1f1147',
|
||||
'--mg-bg-app-3': '#06031a',
|
||||
'--mg-bg-app-deep-rgb': '6, 3, 26',
|
||||
|
||||
'--mg-bg-aside-1': '#0c0820',
|
||||
'--mg-bg-aside-2': '#03020e',
|
||||
'--mg-bg-aside-rgb': '10, 6, 30',
|
||||
|
||||
'--mg-bg-card-rgb': '38, 24, 78',
|
||||
'--mg-bg-card-hi-rgb': '58, 38, 110',
|
||||
'--mg-bg-card-darker-rgb': '20, 12, 48',
|
||||
'--mg-shadow-rgb': '8, 2, 24',
|
||||
|
||||
'--mg-text-tint-rgb': '242, 232, 255',
|
||||
'--mg-text-hi-rgb': '220, 200, 252',
|
||||
|
||||
'--mg-orb-a-opacity': '0.55',
|
||||
'--mg-orb-b-opacity': '0.50',
|
||||
'--mg-orb-c-opacity': '0.40'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'deep-azure',
|
||||
name: '深邃蓝',
|
||||
description: '科技感深蓝,沉静稳重',
|
||||
preview: '#1565c0',
|
||||
previewAccent: '#82b1ff',
|
||||
vars: {
|
||||
'--mg-primary': '#1565c0',
|
||||
'--mg-primary-rgb': '21, 101, 192',
|
||||
'--mg-primary-hover': '#1976d2',
|
||||
'--mg-primary-hover-rgb': '25, 118, 210',
|
||||
'--mg-primary-active': '#0d47a1',
|
||||
'--mg-accent': '#82b1ff',
|
||||
'--mg-accent-rgb': '130, 177, 255',
|
||||
|
||||
'--mg-primary-light-3': '#5a8fce',
|
||||
'--mg-primary-light-5': '#83aedb',
|
||||
'--mg-primary-light-7': '#adcde7',
|
||||
'--mg-primary-light-8': '#c4dbed',
|
||||
'--mg-primary-light-9': '#e3eef8',
|
||||
'--mg-primary-dark-2': '#0d47a1',
|
||||
|
||||
'--mg-bg-app-1': '#051a3a',
|
||||
'--mg-bg-app-2': '#0a2f6b',
|
||||
'--mg-bg-app-3': '#020a1f',
|
||||
'--mg-bg-app-deep-rgb': '2, 8, 26',
|
||||
|
||||
'--mg-bg-aside-1': '#06122a',
|
||||
'--mg-bg-aside-2': '#02060f',
|
||||
'--mg-bg-aside-rgb': '8, 18, 42',
|
||||
|
||||
'--mg-bg-card-rgb': '12, 36, 80',
|
||||
'--mg-bg-card-hi-rgb': '18, 50, 110',
|
||||
'--mg-bg-card-darker-rgb': '6, 18, 44',
|
||||
'--mg-shadow-rgb': '2, 4, 14',
|
||||
|
||||
'--mg-text-tint-rgb': '218, 234, 252',
|
||||
'--mg-text-hi-rgb': '170, 200, 240'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'emerald-forge',
|
||||
name: '翡翠绿',
|
||||
description: '机械翡翠绿,沉稳活力',
|
||||
preview: '#00796b',
|
||||
previewAccent: '#80cbc4',
|
||||
vars: {
|
||||
'--mg-primary': '#00796b',
|
||||
'--mg-primary-rgb': '0, 121, 107',
|
||||
'--mg-primary-hover': '#00897b',
|
||||
'--mg-primary-hover-rgb': '0, 137, 123',
|
||||
'--mg-primary-active': '#004d40',
|
||||
'--mg-accent': '#80cbc4',
|
||||
'--mg-accent-rgb': '128, 203, 196',
|
||||
|
||||
'--mg-primary-light-3': '#4ca094',
|
||||
'--mg-primary-light-5': '#79b9b0',
|
||||
'--mg-primary-light-7': '#a6d2cc',
|
||||
'--mg-primary-light-8': '#bfdfdb',
|
||||
'--mg-primary-light-9': '#e1f0ee',
|
||||
'--mg-primary-dark-2': '#004d40',
|
||||
|
||||
'--mg-bg-app-1': '#06241f',
|
||||
'--mg-bg-app-2': '#0a4538',
|
||||
'--mg-bg-app-3': '#020f0c',
|
||||
'--mg-bg-app-deep-rgb': '2, 12, 10',
|
||||
|
||||
'--mg-bg-aside-1': '#061a16',
|
||||
'--mg-bg-aside-2': '#020a08',
|
||||
'--mg-bg-aside-rgb': '8, 26, 22',
|
||||
|
||||
'--mg-bg-card-rgb': '10, 50, 42',
|
||||
'--mg-bg-card-hi-rgb': '14, 66, 56',
|
||||
'--mg-bg-card-darker-rgb': '6, 28, 24',
|
||||
'--mg-shadow-rgb': '2, 10, 8',
|
||||
|
||||
'--mg-text-tint-rgb': '220, 244, 240',
|
||||
'--mg-text-hi-rgb': '170, 220, 210'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'crimson-iron',
|
||||
name: '熔铁赤',
|
||||
description: '熔铁炉火般的暗红工业风',
|
||||
preview: '#b71c1c',
|
||||
previewAccent: '#ff8a80',
|
||||
vars: {
|
||||
'--mg-primary': '#b71c1c',
|
||||
'--mg-primary-rgb': '183, 28, 28',
|
||||
'--mg-primary-hover': '#d32f2f',
|
||||
'--mg-primary-hover-rgb': '211, 47, 47',
|
||||
'--mg-primary-active': '#8a0c0c',
|
||||
'--mg-accent': '#ff8a80',
|
||||
'--mg-accent-rgb': '255, 138, 128',
|
||||
|
||||
'--mg-primary-light-3': '#c95a5a',
|
||||
'--mg-primary-light-5': '#d68585',
|
||||
'--mg-primary-light-7': '#e4afaf',
|
||||
'--mg-primary-light-8': '#edc8c8',
|
||||
'--mg-primary-light-9': '#f7e6e6',
|
||||
'--mg-primary-dark-2': '#8a0c0c',
|
||||
|
||||
'--mg-bg-app-1': '#2a0606',
|
||||
'--mg-bg-app-2': '#4a0c0c',
|
||||
'--mg-bg-app-3': '#150303',
|
||||
'--mg-bg-app-deep-rgb': '20, 4, 4',
|
||||
|
||||
'--mg-bg-aside-1': '#200505',
|
||||
'--mg-bg-aside-2': '#0c0202',
|
||||
'--mg-bg-aside-rgb': '36, 6, 6',
|
||||
|
||||
'--mg-bg-card-rgb': '60, 10, 10',
|
||||
'--mg-bg-card-hi-rgb': '80, 14, 14',
|
||||
'--mg-bg-card-darker-rgb': '34, 5, 5',
|
||||
'--mg-shadow-rgb': '10, 1, 1',
|
||||
|
||||
'--mg-text-tint-rgb': '252, 220, 220',
|
||||
'--mg-text-hi-rgb': '240, 175, 175'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'graphite-steel',
|
||||
name: '石墨钢',
|
||||
description: '中性冷灰,纯粹工业',
|
||||
preview: '#455a64',
|
||||
previewAccent: '#b0bec5',
|
||||
vars: {
|
||||
'--mg-primary': '#455a64',
|
||||
'--mg-primary-rgb': '69, 90, 100',
|
||||
'--mg-primary-hover': '#607d8b',
|
||||
'--mg-primary-hover-rgb': '96, 125, 139',
|
||||
'--mg-primary-active': '#263238',
|
||||
'--mg-accent': '#b0bec5',
|
||||
'--mg-accent-rgb': '176, 190, 197',
|
||||
|
||||
'--mg-primary-light-3': '#7a8a92',
|
||||
'--mg-primary-light-5': '#9aa6ac',
|
||||
'--mg-primary-light-7': '#bac2c7',
|
||||
'--mg-primary-light-8': '#ccd2d6',
|
||||
'--mg-primary-light-9': '#e8ebed',
|
||||
'--mg-primary-dark-2': '#263238',
|
||||
|
||||
'--mg-bg-app-1': '#161e24',
|
||||
'--mg-bg-app-2': '#243038',
|
||||
'--mg-bg-app-3': '#0a0e12',
|
||||
'--mg-bg-app-deep-rgb': '10, 14, 18',
|
||||
|
||||
'--mg-bg-aside-1': '#11181d',
|
||||
'--mg-bg-aside-2': '#060a0d',
|
||||
'--mg-bg-aside-rgb': '18, 26, 32',
|
||||
|
||||
'--mg-bg-card-rgb': '30, 42, 50',
|
||||
'--mg-bg-card-hi-rgb': '42, 56, 66',
|
||||
'--mg-bg-card-darker-rgb': '16, 22, 28',
|
||||
'--mg-shadow-rgb': '4, 6, 10',
|
||||
|
||||
'--mg-text-tint-rgb': '226, 232, 238',
|
||||
'--mg-text-hi-rgb': '186, 198, 210'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'amber-forge',
|
||||
name: '琥珀金',
|
||||
description: '高对比的橙金调,警示与活力并存',
|
||||
preview: '#e65100',
|
||||
previewAccent: '#ffb74d',
|
||||
vars: {
|
||||
'--mg-primary': '#e65100',
|
||||
'--mg-primary-rgb': '230, 81, 0',
|
||||
'--mg-primary-hover': '#f57c00',
|
||||
'--mg-primary-hover-rgb': '245, 124, 0',
|
||||
'--mg-primary-active': '#bf360c',
|
||||
'--mg-accent': '#ffb74d',
|
||||
'--mg-accent-rgb': '255, 183, 77',
|
||||
|
||||
'--mg-primary-light-3': '#ec8344',
|
||||
'--mg-primary-light-5': '#f2a474',
|
||||
'--mg-primary-light-7': '#f7c4a3',
|
||||
'--mg-primary-light-8': '#fad6bc',
|
||||
'--mg-primary-light-9': '#fdeadd',
|
||||
'--mg-primary-dark-2': '#bf360c',
|
||||
|
||||
'--mg-bg-app-1': '#2a1404',
|
||||
'--mg-bg-app-2': '#4a2a0c',
|
||||
'--mg-bg-app-3': '#150a02',
|
||||
'--mg-bg-app-deep-rgb': '20, 10, 4',
|
||||
|
||||
'--mg-bg-aside-1': '#200f04',
|
||||
'--mg-bg-aside-2': '#0c0602',
|
||||
'--mg-bg-aside-rgb': '34, 16, 6',
|
||||
|
||||
'--mg-bg-card-rgb': '62, 32, 8',
|
||||
'--mg-bg-card-hi-rgb': '82, 44, 12',
|
||||
'--mg-bg-card-darker-rgb': '34, 16, 4',
|
||||
'--mg-shadow-rgb': '10, 4, 1',
|
||||
|
||||
'--mg-text-tint-rgb': '252, 232, 210',
|
||||
'--mg-text-hi-rgb': '240, 196, 140'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export function findTheme(id: string | undefined | null): ThemePreset {
|
||||
if (!id) return THEMES[0]
|
||||
return THEMES.find((t) => t.id === id) ?? THEMES[0]
|
||||
}
|
||||
|
||||
/** 把主题变量写入 :root;多次调用幂等,会覆盖同名属性 */
|
||||
export function applyThemeVars(preset: ThemePreset): void {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(preset.vars)) {
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
root.setAttribute('data-theme', preset.id)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type Scope = 'Platform' | 'RCSMonitor'
|
||||
|
||||
/**
|
||||
* 启动后实际运行模式(由后端在登录响应中告知,仅作展示用)。
|
||||
* - `WebEnabled`:SimpleLite 同时启动了 LocalTerminal(本地桌面窗口)+ WebTerminal(浏览器端)。
|
||||
* - `WebOnly`:SimpleLite 只启动了 WebTerminal,没有本地桌面窗口,全程浏览器使用。
|
||||
* - `Detached`:会话 N+1 增量字段。Platform.Server 未能拉起 SimpleLite(appsettings 关闭 / exe 找不到 /
|
||||
* Process.Start 失败 / 启动异常),前端应该提示「SimpleLite 未连接」并降级 /api/sl/* 调用。
|
||||
*/
|
||||
export type RunMode = 'WebOnly' | 'WebEnabled' | 'Detached'
|
||||
|
||||
/**
|
||||
* 登录时用户在登录页选择的「启动模式」(请求字段)。
|
||||
* - `DesktopAndWeb`:希望本地桌面窗口 + Web 都启动(功能最完整)。
|
||||
* - `WebOnly`:希望只起 Web,不要弹本地桌面窗口(远程办公 / 服务器部署)。
|
||||
*
|
||||
* 与 RunMode 的区别:LaunchMode 是「期望」(前端 → 后端),RunMode 是「事实」(后端 → 前端)。
|
||||
* 正常情况下二者一一映射:DesktopAndWeb → WebEnabled,WebOnly → WebOnly。
|
||||
*/
|
||||
export type LaunchMode = 'DesktopAndWeb' | 'WebOnly'
|
||||
|
||||
export type WidgetVisibility = 'hidden' | 'readonly' | 'interactive'
|
||||
|
||||
export interface WidgetGrant {
|
||||
widgetId: string
|
||||
visibility: WidgetVisibility
|
||||
}
|
||||
|
||||
export interface EffectivePermissions {
|
||||
userId: string
|
||||
version: number
|
||||
allowedOps: string[]
|
||||
visibleWidgets: WidgetGrant[]
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email?: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
scope: Scope
|
||||
/** 会话 N+1:可选;不传时后端按 DesktopAndWeb 处理(向后兼容历史前端)。 */
|
||||
launchMode?: LaunchMode
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: AuthUser
|
||||
scope: Scope
|
||||
effectivePermissions: EffectivePermissions
|
||||
runMode: RunMode
|
||||
/**
|
||||
* 会话 N+1 增量字段:SimpleLiteLauncher.LaunchResult.Status 字符串枚举。
|
||||
* 可能值:Disabled / AlreadyRunning / ReusingExisting / PortOccupied / Ready / StartedButNotReady /
|
||||
* ExecutableNotFound / ProcessStartFailed / Exception。
|
||||
* 字段缺失说明后端没有附带该字段(兼容旧后端)。
|
||||
*/
|
||||
launchStatus?: string
|
||||
/**
|
||||
* 会话 N+1 增量字段:可选告警文本。非空时前端应该用消息条提示用户
|
||||
* (比如「检测到 SimpleLite 已经在端口上运行,本次选择的启动模式未生效」)。
|
||||
*/
|
||||
launchWarning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me 响应:用本地 token 让后端实校一次身份,并刷新 user/scope/runMode/perm。
|
||||
* 不带 token 字段(client 已有),不带 LaunchStatus/Warning(那是登录时一次性结果)。
|
||||
*/
|
||||
export interface MeResponse {
|
||||
user: AuthUser
|
||||
scope: Scope
|
||||
runMode: RunMode
|
||||
effectivePermissions: EffectivePermissions
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type CarState =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'charging'
|
||||
| 'paused'
|
||||
| 'fault'
|
||||
| 'offline'
|
||||
|
||||
export interface Car {
|
||||
id: string
|
||||
rawId?: number
|
||||
name: string
|
||||
typeName: string
|
||||
x: number
|
||||
y: number
|
||||
theta: number
|
||||
batterySoc: number
|
||||
state: CarState
|
||||
missionId?: string
|
||||
lastUpdate: string
|
||||
group?: string
|
||||
ip?: string
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 配置中心 13+1 维度强类型(与 Platform.Server/Configs 一一对齐,字段命名追随 ARCHITECTURE.md §9)。
|
||||
*/
|
||||
|
||||
export interface LogPolicy {
|
||||
level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
|
||||
rollDays: number
|
||||
maxSizeMB: number
|
||||
}
|
||||
|
||||
export interface SecurityPolicy {
|
||||
jwtExpireMin: number
|
||||
enableSwagger: boolean
|
||||
corsWhitelist: string[]
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
dispatchLoopHz: number
|
||||
log: LogPolicy
|
||||
security: SecurityPolicy
|
||||
}
|
||||
|
||||
export interface MesEndpoint { id: string; name: string; url: string; enabled: boolean }
|
||||
export interface WmsEndpoint { id: string; name: string; url: string; enabled: boolean }
|
||||
export interface RcsEndpoint { id: string; name: string; url: string; enabled: boolean }
|
||||
|
||||
export interface ExternalIntegrations {
|
||||
mes: MesEndpoint[]
|
||||
wms: WmsEndpoint[]
|
||||
rcs: RcsEndpoint[]
|
||||
}
|
||||
|
||||
export interface AvoidanceRule { id: string; zoneId: string; rule: string }
|
||||
export interface ZoneSpeedLimit { zoneId: string; maxSpeedMps: number }
|
||||
|
||||
export interface RoutingPolicy {
|
||||
algorithm: 'dijkstra' | 'astar' | 'custom'
|
||||
weights: Record<string, number>
|
||||
avoidance: AvoidanceRule[]
|
||||
zoneSpeedLimits: ZoneSpeedLimit[]
|
||||
}
|
||||
|
||||
export interface FaultReportPolicy { enabled: boolean; emailTo: string[] }
|
||||
export interface AutoRepairPolicy { enabled: boolean; cooldownSec: number }
|
||||
|
||||
export interface VehicleMaintenancePolicy {
|
||||
lowBatteryThreshold: number
|
||||
criticalBatteryThreshold: number
|
||||
faultReport: FaultReportPolicy
|
||||
autoRepair: AutoRepairPolicy
|
||||
}
|
||||
|
||||
export interface ChargePriorityRule { id: string; condition: string; weight: number }
|
||||
|
||||
export interface ChargePolicy {
|
||||
allowMidTaskCharge: boolean
|
||||
idleChargeAfterSec: number
|
||||
priority: ChargePriorityRule[]
|
||||
}
|
||||
|
||||
export type AllocationMode = 'roundRobin' | 'nearest' | 'leastLoad' | 'custom'
|
||||
|
||||
export interface TaskAllocationPolicy {
|
||||
mode: AllocationMode
|
||||
loadBalance: boolean
|
||||
maxQueuePerCar: number
|
||||
}
|
||||
|
||||
export interface IntersectionPolicy { id: string; siteIds: string[]; mode: 'mutex' | 'rotation' }
|
||||
export interface ZoneMutex { id: string; zoneIds: string[] }
|
||||
export interface DynamicYield { id: string; from: string; to: string; condition: string }
|
||||
|
||||
export interface TrafficRule {
|
||||
intersections: IntersectionPolicy[]
|
||||
mutex: ZoneMutex[]
|
||||
yields: DynamicYield[]
|
||||
}
|
||||
|
||||
export type DeviceProtocol = 'modbus-tcp' | 'opc-ua' | 'http-rest' | 'mqtt' | 'onvif' | 'tcp-priv'
|
||||
|
||||
export interface DeviceDriverBinding { id: string; deviceType: string; driverName: string; version: string }
|
||||
export interface DeviceInstance {
|
||||
id: string
|
||||
name: string
|
||||
deviceType: string
|
||||
protocol: DeviceProtocol
|
||||
address: string
|
||||
driverId: string
|
||||
enabled: boolean
|
||||
}
|
||||
export interface DeviceHealthPolicy { heartbeatSec: number; offlineSec: number }
|
||||
export interface AlarmPolicy { enabled: boolean; rules: Array<{ level: string; condition: string }> }
|
||||
|
||||
export interface DeviceManagementConfig {
|
||||
drivers: DeviceDriverBinding[]
|
||||
devices: DeviceInstance[]
|
||||
healthPolicy: DeviceHealthPolicy
|
||||
alarmPolicy: AlarmPolicy
|
||||
}
|
||||
|
||||
export interface FleetGroup { id: string; name: string; floor: string; region: string; carIds: string[] }
|
||||
export interface OtaPolicy { enabled: boolean; batchSize: number; rollbackOnFail: boolean }
|
||||
export interface BatchOpsPolicy { confirmationRequired: boolean; maxBatch: number }
|
||||
export interface NetworkDiagPolicy { rttThresholdMs: number; packetLossThreshold: number }
|
||||
|
||||
export interface FleetLifecycleConfig {
|
||||
groups: FleetGroup[]
|
||||
ota: OtaPolicy
|
||||
batchOps: BatchOpsPolicy
|
||||
networkDiag: NetworkDiagPolicy
|
||||
}
|
||||
|
||||
export interface ScenarioTemplate {
|
||||
id: string
|
||||
name: string
|
||||
category: 'SPS' | 'BatteryPack' | 'Loop' | 'P2P' | 'Custom'
|
||||
version: string
|
||||
baselineJson: string
|
||||
}
|
||||
export interface TemplateDslPolicy { enabled: boolean; schemaVersion: string }
|
||||
export interface LowCodePolicy { enabled: boolean; editor: 'visual' | 'json' }
|
||||
export interface TemplateVersionPolicy { keepVersions: number; allowRollback: boolean }
|
||||
|
||||
export interface ScenarioTemplateConfig {
|
||||
templates: ScenarioTemplate[]
|
||||
dslPolicy: TemplateDslPolicy
|
||||
lowCode: LowCodePolicy
|
||||
versionPolicy: TemplateVersionPolicy
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
siteId: string
|
||||
capacity: number
|
||||
occupied: number
|
||||
}
|
||||
export interface InventoryRule { id: string; itemType: string; minQty: number; maxQty: number }
|
||||
|
||||
export interface LocationManagement {
|
||||
locations: Location[]
|
||||
inventoryRules: InventoryRule[]
|
||||
}
|
||||
|
||||
export interface PlaybackPolicy { retentionDays: number; samplingHz: number }
|
||||
export interface LogRetention { hotDays: number; coldDays: number }
|
||||
export interface VersionPolicy { keepReleases: number }
|
||||
export interface MonitorPanelPolicy {
|
||||
propertyKeys: string[]
|
||||
statusKeys: string[]
|
||||
actionKeys: string[]
|
||||
}
|
||||
export interface MonitorOpsPolicy {
|
||||
car: MonitorPanelPolicy
|
||||
site: MonitorPanelPolicy
|
||||
track: MonitorPanelPolicy
|
||||
carActionByType: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface OpsConfig {
|
||||
playback: PlaybackPolicy
|
||||
logRetention: LogRetention
|
||||
version: VersionPolicy
|
||||
monitor: MonitorOpsPolicy
|
||||
}
|
||||
|
||||
export interface CustomWidget {
|
||||
id: string
|
||||
name: string
|
||||
schemaJson: string
|
||||
layoutJson: string
|
||||
bindToScopes: Array<'Platform' | 'RCSMonitor'>
|
||||
}
|
||||
|
||||
export interface AuthRole {
|
||||
id: string
|
||||
name: string
|
||||
scope: 'Platform' | 'RCSMonitor' | '*'
|
||||
permissions: string[]
|
||||
widgetGrants: Array<{ widgetId: string; visibility: 'hidden' | 'readonly' | 'interactive' }>
|
||||
}
|
||||
|
||||
export interface AuthRoleConfig {
|
||||
roles: AuthRole[]
|
||||
users: Array<{ id: string; username: string; roles: string[]; enabled: boolean }>
|
||||
}
|
||||
|
||||
export type ConfigSection =
|
||||
| 'system'
|
||||
| 'integrations'
|
||||
| 'routing'
|
||||
| 'vehicle'
|
||||
| 'charge'
|
||||
| 'task'
|
||||
| 'traffic'
|
||||
| 'auth'
|
||||
| 'device'
|
||||
| 'fleet'
|
||||
| 'scenario'
|
||||
| 'location'
|
||||
| 'ops'
|
||||
| 'widget'
|
||||
|
||||
export interface ConfigEnvelope<T = unknown> {
|
||||
section: ConfigSection
|
||||
version: number
|
||||
updatedAt: string
|
||||
payload: T
|
||||
}
|
||||
|
||||
export const CONFIG_SECTIONS: Array<{ key: ConfigSection; label: string; icon?: string }> = [
|
||||
{ key: 'system', label: '系统级配置' },
|
||||
{ key: 'integrations', label: '外部系统对接' },
|
||||
{ key: 'routing', label: '路径规划策略' },
|
||||
{ key: 'vehicle', label: '车辆维护策略' },
|
||||
{ key: 'charge', label: '充电逻辑' },
|
||||
{ key: 'task', label: '任务分配机制' },
|
||||
{ key: 'traffic', label: '交通管制规则' },
|
||||
{ key: 'auth', label: '权限与角色管理' },
|
||||
{ key: 'device', label: '第三方设备统一接入' },
|
||||
{ key: 'fleet', label: '机器人群组与车队全生命周期' },
|
||||
{ key: 'scenario', label: '业务场景模板化' },
|
||||
{ key: 'location', label: '库位管理' },
|
||||
{ key: 'ops', label: '运营维护' },
|
||||
{ key: 'widget', label: '自定义控件管理' }
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
z?: number
|
||||
layerId?: string
|
||||
fields?: Record<string, string>
|
||||
}
|
||||
|
||||
export type TrackKind = 'line' | 'arc' | 'bezier' | 'nurbs'
|
||||
|
||||
export interface Track {
|
||||
id: string
|
||||
name?: string
|
||||
kind: TrackKind
|
||||
fromSiteId: string
|
||||
toSiteId: string
|
||||
ctrlPts?: Array<{ x: number; y: number }>
|
||||
lengthM?: number
|
||||
bidirectional?: boolean
|
||||
}
|
||||
|
||||
export interface MapLayer {
|
||||
id: string
|
||||
name: string
|
||||
visible: boolean
|
||||
editable: boolean
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type MissionStatus =
|
||||
| 'queued'
|
||||
| 'assigned'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
export interface MissionStep {
|
||||
id: string
|
||||
action: string
|
||||
targetSiteId?: string
|
||||
params?: Record<string, unknown>
|
||||
status?: MissionStatus
|
||||
}
|
||||
|
||||
export interface Mission {
|
||||
id: string
|
||||
name: string
|
||||
typeName: string
|
||||
priority: number
|
||||
status: MissionStatus
|
||||
carId?: string
|
||||
steps: MissionStep[]
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface OpsAction {
|
||||
code: string
|
||||
label: string
|
||||
target: 'car' | 'task' | 'note'
|
||||
needConfirm: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export const OPS_WHITELIST: OpsAction[] = [
|
||||
{ code: 'ops.car.pause', label: '暂停车辆', target: 'car', needConfirm: false, description: '将单车暂停' },
|
||||
{ code: 'ops.car.resume', label: '恢复车辆', target: 'car', needConfirm: false, description: '将单车恢复运行' },
|
||||
{ code: 'ops.car.gohome', label: '回原点', target: 'car', needConfirm: true, description: '指派车辆回原点' },
|
||||
{ code: 'ops.car.resetSession', label: '重置车辆会话', target: 'car', needConfirm: true, description: '重置车辆通信会话' },
|
||||
{ code: 'ops.car.manualCharge', label: '手动充电', target: 'car', needConfirm: false, description: '触发手动充电' },
|
||||
{ code: 'ops.task.pause', label: '暂停任务', target: 'task', needConfirm: false, description: '暂停指定任务' },
|
||||
{ code: 'ops.task.cancel', label: '取消任务', target: 'task', needConfirm: true, description: '取消指定任务' },
|
||||
{ code: 'ops.task.reassign', label: '重派任务', target: 'task', needConfirm: true, description: '重新分配任务给其他车辆' },
|
||||
{ code: 'ops.task.boostPriority', label: '提升优先级', target: 'task', needConfirm: false, description: '提升任务优先级' },
|
||||
{ code: 'monitor.note.write', label: '写运营备注', target: 'note', needConfirm: false, description: '写入运营备注(标注层)' }
|
||||
]
|
||||
|
||||
export interface OpsAuditEntry {
|
||||
id: string
|
||||
ts: string
|
||||
user: string
|
||||
scope: string
|
||||
opCode: string
|
||||
target: string
|
||||
result: 'ok' | 'err'
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type WorkbenchNav = 'map' | 'process' | 'vehicle' | 'script' | 'scene'
|
||||
|
||||
export type DetailTab = 'properties' | 'status' | 'action'
|
||||
|
||||
export interface WorkbenchListRow {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
overview?: string
|
||||
cells?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface WorkbenchList {
|
||||
nav: WorkbenchNav
|
||||
columns: string[]
|
||||
rows: WorkbenchListRow[]
|
||||
}
|
||||
|
||||
export interface DetailField {
|
||||
key: string
|
||||
value: string
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface DetailAction {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SelectionDetail {
|
||||
objectKind: string
|
||||
objectId: string
|
||||
name: string
|
||||
typeName: string
|
||||
layer?: string
|
||||
tab: DetailTab
|
||||
summary: DetailField[]
|
||||
memberFields: DetailField[]
|
||||
customFields: DetailField[]
|
||||
statusLines: DetailField[]
|
||||
actions: DetailAction[]
|
||||
}
|
||||
|
||||
export interface SelectedObjectRef {
|
||||
kind: string
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { mapEditApi } from '@/api/mapEdit'
|
||||
import { reflectionApi, type ReflectionKind, type ReflectionMethod } from '@/api/reflection'
|
||||
|
||||
/** 需在 Web 端先选站点再执行的动作(后端方法内部依赖 SimpleUI.GetPoint,iframe 下不可靠)。 */
|
||||
const SITE_PICK_METHOD_NAMES = new Set(['Goto', 'GotoSite'])
|
||||
const SITE_PICK_LABEL_HINTS = ['去某地', '前往站点']
|
||||
|
||||
export function methodNeedsSitePick(m: ReflectionMethod): boolean {
|
||||
if (m.params?.length) return false
|
||||
if (SITE_PICK_METHOD_NAMES.has(m.methodName)) return true
|
||||
const label = m.label ?? ''
|
||||
return SITE_PICK_LABEL_HINTS.some((h) => label.includes(h))
|
||||
}
|
||||
|
||||
export interface SitePickOption {
|
||||
id: number
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export async function loadSitePickOptions(): Promise<SitePickOption[]> {
|
||||
const rows = await reflectionApi.listObjects('site')
|
||||
return rows
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
label: `#${s.id} ${s.name || '站点'}`
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id)
|
||||
}
|
||||
|
||||
/** 在地图上拾取目标站点(调用 SimpleLite pick 会话)。 */
|
||||
export async function pickTargetSiteOnMap(): Promise<number | null> {
|
||||
try {
|
||||
ElMessage.info({ message: '请在 3D 地图上点击目标站点附近', duration: 4000 })
|
||||
const r = await mapEditApi.pick()
|
||||
if (r.siteId == null || r.siteId < 0) {
|
||||
ElMessage.warning('未识别到附近站点,请靠近站点再点击')
|
||||
return null
|
||||
}
|
||||
return r.siteId
|
||||
} catch (e) {
|
||||
if (e instanceof Error && /取消|cancel|499/i.test(e.message)) return null
|
||||
ElMessage.error(`地图拾取失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCarGotoSite(carId: number, siteId: number): Promise<boolean> {
|
||||
try {
|
||||
const r = await reflectionApi.gotoCarSite(carId, siteId)
|
||||
ElMessage.success(r.message || `已下发前往站点 #${siteId}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
ElMessage.error(`前往站点失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeReflectionMethod(
|
||||
kind: ReflectionKind,
|
||||
objectId: number,
|
||||
m: ReflectionMethod
|
||||
): Promise<boolean> {
|
||||
const label = m.label || m.methodName
|
||||
const params: Record<string, string> = {}
|
||||
for (const p of m.params) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`参数 ${p.name}(${p.typeName})`,
|
||||
label,
|
||||
{
|
||||
inputValue: p.defaultValue ?? '',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
)
|
||||
params[p.name] = value ?? ''
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await reflectionApi.execute(kind, objectId, m.methodName, params)
|
||||
ElMessage.success(r.returnValue ? `已执行:${r.returnValue}` : `已执行 ${label}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
ElMessage.error(`执行失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { reflectionApi, emptyMonitorVisibilityMap, type MonitorVisibilityMap } from '@/api/reflection'
|
||||
import { DEFAULT_OPS } from '@/mock/data/configs'
|
||||
import type { MonitorOpsPolicy, OpsConfig } from '@/types/config'
|
||||
|
||||
export function defaultMonitorPolicy(): MonitorOpsPolicy {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_OPS.monitor)) as MonitorOpsPolicy
|
||||
}
|
||||
|
||||
export function normalizeOpsConfig(raw?: Partial<OpsConfig> | null): OpsConfig {
|
||||
const base = JSON.parse(JSON.stringify(DEFAULT_OPS)) as OpsConfig
|
||||
if (!raw) return base
|
||||
const monitor = {
|
||||
...defaultMonitorPolicy(),
|
||||
...(raw.monitor ?? {}),
|
||||
car: { ...defaultMonitorPolicy().car, ...(raw.monitor?.car ?? {}) },
|
||||
site: { ...defaultMonitorPolicy().site, ...(raw.monitor?.site ?? {}) },
|
||||
track: { ...defaultMonitorPolicy().track, ...(raw.monitor?.track ?? {}) },
|
||||
carActionByType: { ...(raw.monitor?.carActionByType ?? {}) }
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...raw,
|
||||
playback: { ...base.playback, ...(raw.playback ?? {}) },
|
||||
logRetention: { ...base.logRetention, ...(raw.logRetention ?? {}) },
|
||||
version: { ...base.version, ...(raw.version ?? {}) },
|
||||
monitor
|
||||
}
|
||||
}
|
||||
|
||||
type MonitorConfigRoot = MonitorVisibilityMap & { carActionByType?: Record<string, string[]> }
|
||||
|
||||
function pickMonitorRoot(cfg: MonitorConfigRoot) {
|
||||
return {
|
||||
carActionByType: { ...(cfg.carActionByType ?? {}) },
|
||||
siteActionKeys: [...(cfg.site?.methods ?? [])],
|
||||
trackActionKeys: [...(cfg.track?.methods ?? [])]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SimpleLite simple.json 加载地图监控配置(运行时唯一可信源)。
|
||||
* Platform ops 里的 monitor 仅作备份;以 SimpleLite 为准覆盖表单。
|
||||
*/
|
||||
export async function loadMonitorSettingsIntoOps(payload: OpsConfig): Promise<OpsConfig> {
|
||||
const next = normalizeOpsConfig(payload)
|
||||
try {
|
||||
const mc = await reflectionApi.getMonitorConfig()
|
||||
const picked = pickMonitorRoot(mc.config as MonitorConfigRoot)
|
||||
next.monitor.carActionByType = picked.carActionByType
|
||||
next.monitor.site.actionKeys = picked.siteActionKeys
|
||||
next.monitor.track.actionKeys = picked.trackActionKeys
|
||||
} catch {
|
||||
// SimpleLite 未连接:退回 Platform payload 中已有的 monitor(若有)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运营维护表单中的地图监控配置写入 SimpleLite(simple.json)。
|
||||
* 保留「地图监控配置」页面对 fields/status 的既有设置,只更新动作相关字段。
|
||||
*/
|
||||
export async function saveMonitorSettingsFromOps(payload: OpsConfig): Promise<void> {
|
||||
const monitor = normalizeOpsConfig(payload).monitor
|
||||
let current: MonitorConfigRoot = emptyMonitorVisibilityMap()
|
||||
try {
|
||||
const mc = await reflectionApi.getMonitorConfig()
|
||||
current = mc.config as MonitorConfigRoot
|
||||
} catch {
|
||||
// 无现有配置时用空壳,仍可写入动作白名单
|
||||
}
|
||||
|
||||
await reflectionApi.saveMonitorConfig({
|
||||
car: {
|
||||
fields: [...(current.car?.fields ?? [])],
|
||||
status: [...(current.car?.status ?? [])],
|
||||
methods: [...(current.car?.methods ?? [])]
|
||||
},
|
||||
site: {
|
||||
fields: [...(current.site?.fields ?? [])],
|
||||
status: [...(current.site?.status ?? [])],
|
||||
methods: [...monitor.site.actionKeys]
|
||||
},
|
||||
track: {
|
||||
fields: [...(current.track?.fields ?? [])],
|
||||
status: [...(current.track?.status ?? [])],
|
||||
methods: [...monitor.track.actionKeys]
|
||||
},
|
||||
carActionByType: { ...monitor.carActionByType }
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated 使用 loadMonitorSettingsIntoOps */
|
||||
export const mergeMonitorFromSimpleLite = loadMonitorSettingsIntoOps
|
||||
|
||||
/** @deprecated 使用 saveMonitorSettingsFromOps */
|
||||
export const syncOpsMonitorToSimpleLite = saveMonitorSettingsFromOps
|
||||
@@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="bg-image" />
|
||||
<div class="bg-overlay" />
|
||||
<div class="bg-orb bg-orb-a" />
|
||||
<div class="bg-orb bg-orb-b" />
|
||||
<div class="bg-orb bg-orb-c" />
|
||||
<div class="bg-stars" aria-hidden="true">
|
||||
<span v-for="i in 18" :key="i" :style="starStyles[i - 1]" />
|
||||
</div>
|
||||
|
||||
<div class="login-card mg-glass-lg">
|
||||
<!-- 左侧品牌 hero -->
|
||||
<div class="hero">
|
||||
<div class="hero-brand">
|
||||
<div class="hero-mark">迷</div>
|
||||
<div class="hero-name">迷毂</div>
|
||||
</div>
|
||||
<div class="hero-tagline">智 能 调 度 平 台</div>
|
||||
<div class="hero-tagline-en">INTELLIGENT · DISPATCH · PLATFORM</div>
|
||||
<div class="hero-divider" />
|
||||
<ul class="hero-points">
|
||||
<li><span class="dot" /> 复刻 ARCHITECTURE.md §3.2 启动登录窗</li>
|
||||
<li><span class="dot" /> Web-Enabled · 管理员 / 运营 双视图</li>
|
||||
<li><span class="dot" /> 嵌入 webVRender 真实 3D 场景</li>
|
||||
</ul>
|
||||
<div class="hero-footnote">
|
||||
<span class="badge">v1.7</span>
|
||||
<span>{{ ui.activeTheme.name }} · {{ ui.activeTheme.preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">登 录</div>
|
||||
<div class="panel-sub">访问 SimpleLite 的 Vue 外壳</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" class="glass-form" hide-required-asterisk>
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" size="large" placeholder="用户名" autocomplete="username" clearable>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" size="large" show-password placeholder="密码(Mock,任意非空)" autocomplete="current-password">
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="scope" class="scope-item">
|
||||
<div class="scope-wrap">
|
||||
<button type="button" class="scope-tab" :class="{ active: form.scope === 'Platform' }" @click="form.scope = 'Platform'">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<div class="scope-meta">
|
||||
<div class="scope-name">管理员</div>
|
||||
<div class="scope-desc">platform-vue · 全功能</div>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="scope-tab" :class="{ active: form.scope === 'RCSMonitor' }" @click="form.scope = 'RCSMonitor'">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<div class="scope-meta">
|
||||
<div class="scope-name">运营</div>
|
||||
<div class="scope-desc">rcsmonitor-vue · 受限</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 会话 N+1(启动反转):Platform.Server 作为主入口,登录时由用户选择 SimpleLite 的启动模式。
|
||||
选 "本地+Web" → SimpleLite 同时启 LocalTerminal + WebTerminal;选 "仅Web" → 只起 WebTerminal。-->
|
||||
<el-form-item prop="launchMode" class="scope-item launch-mode-item">
|
||||
<div class="launch-mode-head">
|
||||
<span class="launch-mode-title">SimpleLite 启动模式</span>
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div class="launch-tip">
|
||||
<div><b>本地+Web</b>:登录后由 Platform 后端拉起 SimpleLite,同时打开桌面端 ImGui 窗口与浏览器端 webVRender。</div>
|
||||
<div><b>仅 Web</b>:只起 WebTerminal,不弹本地桌面窗口,适合远程办公 / 服务器部署。</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="launch-mode-help"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="scope-wrap launch-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-tab"
|
||||
:class="{ active: form.launchMode === 'DesktopAndWeb' }"
|
||||
@click="form.launchMode = 'DesktopAndWeb'">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<div class="scope-meta">
|
||||
<div class="scope-name">本地 + Web</div>
|
||||
<div class="scope-desc">桌面窗口 & 浏览器同时启动</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-tab"
|
||||
:class="{ active: form.launchMode === 'WebOnly' }"
|
||||
@click="form.launchMode = 'WebOnly'">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<div class="scope-meta">
|
||||
<div class="scope-name">仅 Web</div>
|
||||
<div class="scope-desc">不启桌面窗口 · 浏览器使用</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="row row-between">
|
||||
<el-checkbox v-model="form.remember" size="default" class="remember">下次自动按当前 scope 进入</el-checkbox>
|
||||
<el-link underline="never" class="adv-link" @click="adv = adv.length ? [] : ['adv']">高级 ›</el-link>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="adv" class="adv-collapse">
|
||||
<el-collapse-item name="adv">
|
||||
<template #title><span class="adv-title">Web-Enabled · 端口与监听</span></template>
|
||||
<div class="adv-grid">
|
||||
<el-form-item label="监听地址">
|
||||
<el-input v-model="adv2.host" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="WebAPI">
|
||||
<el-input-number v-model="adv2.apiPort" :min="1" :max="65535" size="default" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Platform">
|
||||
<el-input-number v-model="adv2.platformPort" :min="1" :max="65535" size="default" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="webVRender">
|
||||
<el-input-number v-model="adv2.vrPort" :min="1" :max="65535" size="default" controls-position="right" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<el-button type="primary" :loading="loading" class="btn-login" size="large" @click="submit">
|
||||
登 录
|
||||
</el-button>
|
||||
|
||||
<div class="bottom-note">
|
||||
任意非空用户名 + 任意密码即可登录;scope 决定进入 admin / monitor 视图;权限按 Mock 表生成。
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-stamp">
|
||||
迷毂 · {{ year }} · ARCHITECTURE v1.6 · powered by webVRender
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Setting, Monitor, Cpu, Connection, QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import type { LaunchMode, Scope } from '@/types/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const adv = ref<string[]>([])
|
||||
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
scope: 'Platform' as Scope,
|
||||
// 会话 N+1:默认「本地+Web」与改造前 Configuration.displayMode="web+local" 一致,
|
||||
// 保证「我啥也不选」时的行为与改造前完全相同。
|
||||
launchMode: 'DesktopAndWeb' as LaunchMode,
|
||||
remember: true
|
||||
})
|
||||
|
||||
const adv2 = reactive({
|
||||
host: '0.0.0.0',
|
||||
apiPort: 7001,
|
||||
platformPort: 8080,
|
||||
vrPort: 8223
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
scope: [{ required: true, message: '请选择 scope', trigger: 'change' }],
|
||||
launchMode: [{ required: true, message: '请选择启动模式', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const year = computed(() => new Date().getFullYear())
|
||||
|
||||
const starStyles = Array.from({ length: 18 }, (_, i) => {
|
||||
const top = Math.random() * 100
|
||||
const left = Math.random() * 100
|
||||
const size = 2 + Math.random() * 3
|
||||
const delay = -Math.random() * 6
|
||||
const opacity = 0.35 + Math.random() * 0.45
|
||||
void i
|
||||
return {
|
||||
top: `${top}%`,
|
||||
left: `${left}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
opacity: `${opacity}`,
|
||||
animationDelay: `${delay}s`
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (ok) => {
|
||||
if (!ok) return
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await auth.login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
scope: form.scope,
|
||||
launchMode: form.launchMode
|
||||
})
|
||||
ElMessage.success(`欢迎,${auth.user?.displayName ?? form.username}`)
|
||||
// 会话 N+1:如果后端返回了 launchWarning(如「检测到既有 SimpleLite 在跑、本次启动模式未生效」),
|
||||
// 在登录成功的 toast 之后再追加一条警告条,确保用户感知到「实际行为」与「期望」之间的偏差。
|
||||
if (resp.launchWarning) {
|
||||
ElMessage({ message: resp.launchWarning, type: 'warning', duration: 6000, showClose: true })
|
||||
} else if (resp.runMode === 'Detached') {
|
||||
ElMessage({
|
||||
message: 'SimpleLite 未启动(Platform.Server 单独运行),/api/sl/* 相关功能将不可用。',
|
||||
type: 'warning',
|
||||
duration: 6000,
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
const target = (route.query.redirect as string | undefined) ?? (form.scope === 'Platform' ? '/admin/dashboard' : '/monitor/map')
|
||||
router.push(target)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
ElMessage.error(`登录失败:${msg}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 背景层 ===== */
|
||||
.login-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.bg-image {
|
||||
position: absolute; inset: 0;
|
||||
background: url('/login-bg.jpg') center/cover no-repeat;
|
||||
filter: saturate(0.35) brightness(0.34) hue-rotate(-12deg);
|
||||
transform: scale(1.08);
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 12% 18%, rgba(var(--mg-primary-hover-rgb), 0.65) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 85% 80%, rgba(var(--mg-bg-app-deep-rgb), 0.78) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(var(--mg-primary-rgb), 0.40) 0%, transparent 70%),
|
||||
linear-gradient(135deg, rgba(var(--mg-bg-aside-rgb), 0.85) 0%, rgba(var(--mg-primary-rgb), 0.48) 50%, rgba(var(--mg-bg-app-deep-rgb), 0.88) 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
/* 登录页:紫色网格层(更明显) */
|
||||
.bg-overlay::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(var(--mg-accent-rgb), 0.08) 1px, transparent 1px);
|
||||
background-size: 56px 56px, 56px 56px;
|
||||
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.7) 0%, transparent 70%);
|
||||
}
|
||||
.bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
animation: drift 18s ease-in-out infinite;
|
||||
}
|
||||
.bg-orb-a {
|
||||
width: 460px; height: 460px;
|
||||
top: -140px; left: -120px;
|
||||
background: radial-gradient(circle, var(--mg-accent) 0%, var(--mg-primary) 50%, transparent 80%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.bg-orb-b {
|
||||
width: 580px; height: 580px;
|
||||
bottom: -200px; right: -180px;
|
||||
background: radial-gradient(circle, var(--mg-primary-hover) 0%, var(--mg-bg-app-3) 60%, transparent 85%);
|
||||
opacity: 0.55;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
.bg-orb-c {
|
||||
width: 320px; height: 320px;
|
||||
top: 30%; right: 8%;
|
||||
background: radial-gradient(circle, var(--mg-primary-light-8) 0%, var(--mg-primary-hover) 60%, transparent 90%);
|
||||
opacity: 0.32;
|
||||
animation-delay: -12s;
|
||||
}
|
||||
@keyframes drift {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(20px, -30px) scale(1.05); }
|
||||
}
|
||||
|
||||
.bg-stars { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
|
||||
.bg-stars span {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||
50% { opacity: 1.0; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* ===== 卡片:双栏 hero ===== */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 880px;
|
||||
max-width: calc(100vw - 32px);
|
||||
min-height: 560px;
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 40px 100px rgba(0, 0, 0, 0.65),
|
||||
0 0 80px rgba(var(--mg-primary-rgb), 0.42),
|
||||
0 0 0 1px rgba(var(--mg-accent-rgb), 0.30) inset,
|
||||
0 1px 0 rgba(255, 255, 255, 0.40) inset !important;
|
||||
}
|
||||
/* 顶部霓虹高光线 */
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 12%; right: 12%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(var(--mg-accent-rgb), 0.6) 30%,
|
||||
rgba(255, 255, 255, 0.95) 50%,
|
||||
rgba(var(--mg-accent-rgb), 0.6) 70%,
|
||||
transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ===== 左侧 hero ===== */
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 40px 32px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--mg-primary-hover-rgb), 0.42) 0%, rgba(var(--mg-bg-aside-rgb), 0.35) 100%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse at 80% 20%, rgba(var(--mg-accent-rgb), 0.35), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-brand { display: flex; align-items: center; gap: 14px; }
|
||||
.hero-mark {
|
||||
width: 56px; height: 56px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--mg-accent) 0%, var(--mg-primary-hover) 50%, var(--mg-primary-active) 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 700; color: #fff;
|
||||
box-shadow:
|
||||
0 14px 32px rgba(var(--mg-primary-rgb), 0.65),
|
||||
0 0 36px rgba(var(--mg-primary-hover-rgb), 0.55),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.30) inset;
|
||||
letter-spacing: 1px;
|
||||
animation: mg-pulse-glow 3.6s ease-in-out infinite;
|
||||
}
|
||||
.hero-name {
|
||||
font-size: 34px; font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(90deg, #ffffff 0%, var(--mg-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 24px rgba(var(--mg-accent-rgb), 0.45);
|
||||
}
|
||||
.hero-tagline {
|
||||
margin-top: 32px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 8px;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.hero-tagline-en {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 3px;
|
||||
color: rgba(232, 215, 245, 0.6);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hero-divider {
|
||||
margin: 22px 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
}
|
||||
.hero-points {
|
||||
list-style: none;
|
||||
padding: 0; margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: rgba(236, 225, 245, 0.85);
|
||||
line-height: 1.9;
|
||||
flex: 1;
|
||||
}
|
||||
.hero-points li { display: flex; align-items: center; gap: 8px; }
|
||||
.hero-points .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--mg-accent);
|
||||
box-shadow: 0 0 10px rgba(var(--mg-accent-rgb), 0.85);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero-footnote {
|
||||
margin-top: auto;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 11px;
|
||||
color: rgba(232, 215, 245, 0.55);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.hero-footnote .badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 右侧表单 ===== */
|
||||
.panel {
|
||||
padding: 44px 44px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.panel-head { margin-bottom: 28px; }
|
||||
.panel-title {
|
||||
font-size: 30px; font-weight: 700;
|
||||
letter-spacing: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--mg-accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 22px rgba(var(--mg-primary-hover-rgb), 0.6);
|
||||
}
|
||||
.panel-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(232, 215, 245, 0.65);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.glass-form { flex: 1; display: flex; flex-direction: column; }
|
||||
.glass-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
|
||||
/* 输入框 透明玻璃 */
|
||||
.glass-form :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset !important;
|
||||
border-radius: 10px !important;
|
||||
transition: all .25s;
|
||||
}
|
||||
.glass-form :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(255, 255, 255, 0.13) !important;
|
||||
box-shadow: 0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
|
||||
}
|
||||
.glass-form :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(var(--mg-accent-rgb), 0.85) inset,
|
||||
0 0 20px rgba(var(--mg-primary-hover-rgb), 0.55) !important;
|
||||
}
|
||||
.glass-form :deep(.el-input__inner) {
|
||||
color: #fff !important;
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
font-size: 14px;
|
||||
height: 42px;
|
||||
}
|
||||
.glass-form :deep(.el-input__inner::placeholder) { color: rgba(255, 255, 255, 0.45); }
|
||||
.glass-form :deep(.el-input__prefix-inner > :first-child),
|
||||
.glass-form :deep(.el-input__suffix-inner) { color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
/* scope 双卡 */
|
||||
.scope-item :deep(.el-form-item__content) { width: 100%; }
|
||||
.scope-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: 100%; }
|
||||
|
||||
/* 启动模式(会话 N+1):与 scope 双卡共用 .scope-tab 样式,仅在外层做一些标题/留白调整 */
|
||||
.launch-mode-item :deep(.el-form-item__content) { display: flex; flex-direction: column; align-items: stretch; }
|
||||
.launch-mode-head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(232, 215, 245, 0.72);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.launch-mode-title { font-weight: 500; }
|
||||
.launch-mode-help {
|
||||
color: rgba(232, 215, 245, 0.55);
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
.launch-mode-help:hover { color: var(--mg-accent); }
|
||||
.launch-wrap { /* 与 scope 一致,留位置给 hover 阴影 */ margin-bottom: 4px; }
|
||||
.launch-tip { max-width: 280px; line-height: 1.6; font-size: 12px; }
|
||||
.launch-tip > div + div { margin-top: 6px; }
|
||||
.scope-tab {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: 14px 14px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
text-align: left;
|
||||
}
|
||||
.scope-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.scope-tab.active {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--mg-primary-rgb), 0.62) 0%,
|
||||
rgba(var(--mg-primary-hover-rgb), 0.42) 100%);
|
||||
border-color: rgba(var(--mg-accent-rgb), 0.85);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 0 24px rgba(var(--mg-primary-hover-rgb), 0.55),
|
||||
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset;
|
||||
}
|
||||
.scope-tab .el-icon { font-size: 20px; }
|
||||
.scope-meta { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
|
||||
.scope-name { font-size: 14px; font-weight: 600; }
|
||||
.scope-desc { font-size: 11px; opacity: 0.75; margin-top: 2px; }
|
||||
|
||||
/* 行 - 记住选择 & 高级 */
|
||||
.row { display: flex; align-items: center; }
|
||||
.row-between { justify-content: space-between; margin: -6px 0 10px; }
|
||||
.remember :deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.92) !important;
|
||||
font-size: 12.5px;
|
||||
text-shadow: 0 1px 2px rgba(22, 4, 31, 0.4);
|
||||
}
|
||||
.remember :deep(.el-checkbox__inner) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.remember :deep(.el-checkbox.is-checked .el-checkbox__inner) {
|
||||
background: var(--mg-primary);
|
||||
border-color: var(--mg-primary);
|
||||
}
|
||||
.adv-link { color: rgba(232, 215, 245, 0.82) !important; font-size: 12.5px; }
|
||||
.adv-link:hover { color: #fff !important; }
|
||||
|
||||
/* 高级折叠 */
|
||||
.adv-collapse { background: transparent !important; border: none !important; margin-bottom: 16px; }
|
||||
.adv-collapse :deep(.el-collapse-item__wrap),
|
||||
.adv-collapse :deep(.el-collapse-item__header) {
|
||||
background: transparent !important;
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: rgba(232, 215, 245, 0.82) !important;
|
||||
}
|
||||
.adv-title { font-size: 12px; letter-spacing: 1px; }
|
||||
.adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; padding-top: 6px; }
|
||||
.adv-grid :deep(.el-form-item__label) {
|
||||
color: rgba(232, 215, 245, 0.7);
|
||||
font-size: 12px;
|
||||
}
|
||||
.adv-grid :deep(.el-input-number) { width: 100%; }
|
||||
.adv-grid :deep(.el-input-number .el-input__inner) { text-align: left; }
|
||||
|
||||
/* 登录按钮:紫色霓虹 · 扫光动效 */
|
||||
.btn-login {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 46px !important;
|
||||
border-radius: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 6px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--mg-primary) 0%, var(--mg-primary-hover) 100%) !important;
|
||||
border: none !important;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(var(--mg-primary-rgb), 0.55),
|
||||
0 0 28px rgba(var(--mg-primary-hover-rgb), 0.40),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.22) inset !important;
|
||||
transition: all .28s cubic-bezier(.25, .8, .25, 1);
|
||||
}
|
||||
.btn-login::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: -120%;
|
||||
width: 80%; height: 100%;
|
||||
background: linear-gradient(110deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.0) 30%,
|
||||
rgba(255, 255, 255, 0.40) 50%,
|
||||
rgba(255, 255, 255, 0.0) 70%,
|
||||
transparent 100%);
|
||||
transition: left .6s cubic-bezier(.25, .8, .25, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(135deg, var(--mg-primary-hover) 0%, var(--mg-primary-light-3) 100%) !important;
|
||||
box-shadow:
|
||||
0 18px 38px rgba(var(--mg-primary-rgb), 0.65),
|
||||
0 0 42px rgba(var(--mg-primary-hover-rgb), 0.65),
|
||||
0 0 0 1px rgba(var(--mg-accent-rgb), 0.55) inset !important;
|
||||
}
|
||||
.btn-login:hover::after { left: 120%; }
|
||||
|
||||
.bottom-note {
|
||||
margin-top: 16px;
|
||||
font-size: 11.5px;
|
||||
color: rgba(232, 215, 245, 0.55);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 底部水印 */
|
||||
.footer-stamp {
|
||||
position: absolute;
|
||||
bottom: 18px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
color: rgba(232, 215, 245, 0.4);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.glass-form :deep(.el-form-item__error) {
|
||||
color: #ff9bb8;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* 小屏自适应 */
|
||||
@media (max-width: 720px) {
|
||||
.login-card { grid-template-columns: 1fr; min-height: auto; }
|
||||
.hero { border-right: none; border-bottom: 1px solid rgba(255, 255, 255, 0.12); padding: 28px; }
|
||||
.panel { padding: 28px; }
|
||||
.hero-points, .hero-divider { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="status-page mg-content">
|
||||
<el-card shadow="never" class="status-card">
|
||||
<template #header>
|
||||
<span>SimpleLite Service Status</span>
|
||||
<el-tag type="success" effect="dark" style="margin-left: 8px">Web-Enabled (Mock)</el-tag>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="模式">Web-Enabled</el-descriptions-item>
|
||||
<el-descriptions-item label="启动时间">{{ startTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时长">{{ uptime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="节点角色"><el-tag type="success">Active (ROSE)</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="WebAPI">http://0.0.0.0:7001 <el-tag size="small">OK</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="WebSocket">ws://0.0.0.0:7002 <el-tag size="small">OK</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="webVRender">http://0.0.0.0:8223 <el-tag size="small" type="success">OK</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="Platform.Server">:8080 (pid=12345)</el-descriptions-item>
|
||||
<el-descriptions-item label="在线 Vue 客户端">admin=3, monitor=4</el-descriptions-item>
|
||||
<el-descriptions-item label="调度循环 / 任务">50 Hz · 14 / 32</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="status-actions">
|
||||
<el-button>查看日志</el-button>
|
||||
<el-button type="warning" plain>重启 Web</el-button>
|
||||
<el-button type="danger" plain>关闭服务</el-button>
|
||||
<el-button @click="back">返回</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const startTime = new Date().toLocaleString('zh-CN')
|
||||
const uptime = ref('00:00:00')
|
||||
|
||||
let timer: number | undefined
|
||||
const t0 = Date.now()
|
||||
|
||||
function tick() {
|
||||
const ms = Date.now() - t0
|
||||
const s = Math.floor(ms / 1000)
|
||||
const h = String(Math.floor(s / 3600)).padStart(2, '0')
|
||||
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(s % 60).padStart(2, '0')
|
||||
uptime.value = `${h}:${m}:${ss}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick()
|
||||
timer = window.setInterval(tick, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
function back() { router.back() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.status-card { width: 720px; }
|
||||
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<PermissionGuard widget-id="CadToolbar">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>CAD 工具栏(cad.tool.run)</span></template>
|
||||
<el-tabs v-model="active">
|
||||
<el-tab-pane v-for="g in groups" :key="g.key" :name="g.key" :label="g.label">
|
||||
<el-row :gutter="12">
|
||||
<el-col v-for="t in g.tools" :key="t.id" :span="6" style="margin-bottom: 12px">
|
||||
<el-card shadow="hover" class="tool-card" @click="run(t)">
|
||||
<el-icon size="24" class="tool-icon"><Tools /></el-icon>
|
||||
<div class="tool-name">{{ t.label }}</div>
|
||||
<div class="tool-desc">{{ t.desc }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</PermissionGuard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Tools } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PermissionGuard from '@/components/PermissionGuard.vue'
|
||||
|
||||
interface CadTool { id: string; label: string; desc: string }
|
||||
|
||||
const active = ref('Scene')
|
||||
|
||||
const groups: Array<{ key: string; label: string; tools: CadTool[] }> = [
|
||||
{
|
||||
key: 'Scene', label: '场景 (Scene)',
|
||||
tools: [
|
||||
{ id: 'grid', label: '生成网格', desc: '按间距生成站点网格' },
|
||||
{ id: 'mirror', label: '镜像', desc: '镜像复制选中对象' },
|
||||
{ id: 'align', label: '对齐', desc: '横向/纵向对齐多选' },
|
||||
{ id: 'distribute', label: '等距分布', desc: '在两端之间均匀分布站点' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Car', label: '车辆 (Car)',
|
||||
tools: [
|
||||
{ id: 'spawn', label: '批量创建', desc: '从模板批量创建车辆' },
|
||||
{ id: 'reset', label: '回原点', desc: '将选中车辆送回原点' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Project', label: '工程 (Project)',
|
||||
tools: [
|
||||
{ id: 'validate', label: '工程校验', desc: '检查轨道连通性 / 重复站点' },
|
||||
{ id: 'snapshot', label: '快照', desc: '输出 problems/simplelite-scene-*.json' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function run(t: CadTool) {
|
||||
ElMessage.info(`占位:执行 CAD 工具 [${t.id}] ${t.label}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
|
||||
.tool-name { font-weight: 600; margin-top: 6px; }
|
||||
.tool-desc { color: rgba(220, 200, 252, 0.55); font-size: 12px; margin-top: 4px; }
|
||||
.tool-icon {
|
||||
color: var(--mg-accent);
|
||||
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
|
||||
}
|
||||
.tool-card:hover .tool-icon {
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="car-page">
|
||||
<el-tabs v-model="tab" type="border-card" class="car-tabs admin-tabs" @tab-change="onTabChange">
|
||||
<el-tab-pane label="车辆列表" name="list">
|
||||
<ReflectionManagerPanel
|
||||
v-if="tab === 'list'"
|
||||
kind="car"
|
||||
kind-label="车辆"
|
||||
title="车辆管理(Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
|
||||
empty-text="当前没有车辆;点击右上角「新建车辆」从已加载的 CarType 中选一个实例化。"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="车型样式 / 报警颜色" name="style">
|
||||
<CarStyleEditor v-if="tab === 'style'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
||||
import CarStyleEditor from './CarStyleEditor.vue'
|
||||
|
||||
const tab = ref<'list' | 'style'>('list')
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
tab.value = name as typeof tab.value
|
||||
sessionStorage.setItem('admin.cars.tab', tab.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = sessionStorage.getItem('admin.cars.tab')
|
||||
if (cached === 'list' || cached === 'style') tab.value = cached
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.car-page {
|
||||
height: calc(100vh - 56px);
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.car-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.car-tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.car-tabs :deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="cse-root" v-loading="loading">
|
||||
<!-- 报警调色板 ─────────────────────────────────────────────── -->
|
||||
<el-card shadow="never" class="cse-card">
|
||||
<template #header>
|
||||
<div class="cse-card-header">
|
||||
<span class="cse-card-title">报警 / 状态调色板</span>
|
||||
<el-tag size="small" type="info" effect="plain">7 种默认状态</el-tag>
|
||||
<span class="cse-card-spacer" />
|
||||
<el-button size="small" :loading="resettingPalette" @click="resetPaletteToDefault">恢复默认</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!paletteDirty" :loading="savingPalette" @click="savePalette">
|
||||
保存调色板(写入 simple.json)
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="cse-desc">
|
||||
车体颜色的判定优先级:
|
||||
<el-tag size="small" effect="plain">1. AlarmInfo 非空 → 「报警」</el-tag>
|
||||
<el-tag size="small" effect="plain">2. lstatus 包含关键字 → 对应键</el-tag>
|
||||
<el-tag size="small" effect="plain">3. 兜底 → 上下线默认色</el-tag>
|
||||
修改下方颜色 + 「保存调色板」即时生效。
|
||||
</p>
|
||||
|
||||
<div class="cse-palette">
|
||||
<div v-for="entry in paletteEntries" :key="entry.key" class="cse-palette-item">
|
||||
<div class="cse-palette-label">
|
||||
<span class="cse-palette-key">{{ entry.key }}</span>
|
||||
<el-tag v-if="!entry.isDefault" size="small" type="warning" effect="plain">自定义</el-tag>
|
||||
</div>
|
||||
<div class="cse-palette-pickers">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(getPaletteColor(entry.key))"
|
||||
show-alpha
|
||||
size="small"
|
||||
@update:model-value="(v: string | null) => setPaletteColor(entry.key, v)"
|
||||
/>
|
||||
<code class="cse-color-code">#{{ argbToHex(getPaletteColor(entry.key)).toUpperCase() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 车型样式 ─────────────────────────────────────────────── -->
|
||||
<el-card shadow="never" class="cse-card cse-types-card">
|
||||
<template #header>
|
||||
<div class="cse-card-header">
|
||||
<span class="cse-card-title">车型样式</span>
|
||||
<el-tag size="small" type="info" effect="plain">{{ types.length }} 个已加载车型</el-tag>
|
||||
<span class="cse-card-spacer" />
|
||||
<el-button size="small" @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span class="cse-btn-text">刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="cse-desc">
|
||||
点击车型行展开编辑:尺寸(米)、车体 / 描边 / 标签颜色、是否显示名称、3D 模型文件路径。
|
||||
每辆车按 .NET 类型 (`assemblyName::shortName`) 索引样式。修改后点行内「保存」即时写入并生效。
|
||||
</p>
|
||||
|
||||
<el-collapse v-model="activeKey" accordion>
|
||||
<el-collapse-item v-for="row in types" :key="row.typeName" :name="row.typeName">
|
||||
<template #title>
|
||||
<div class="cse-row-title">
|
||||
<span class="cse-row-name">{{ row.label }}</span>
|
||||
<code class="cse-row-fqn">{{ row.typeName }}</code>
|
||||
<el-tag v-if="row.hasOverride" size="small" type="success" effect="plain">已覆盖</el-tag>
|
||||
<el-tag v-else size="small" type="info" effect="plain">默认</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CarStyleEditorForm
|
||||
v-if="forms[row.typeName]"
|
||||
:type-full-name="row.typeName"
|
||||
:model-value="forms[row.typeName]!"
|
||||
:has-override="row.hasOverride"
|
||||
:saving="savingTypeName === row.typeName"
|
||||
@update:model-value="(v: CarStyleDto) => forms[row.typeName] = v"
|
||||
@save="onSaveType(row)"
|
||||
@reset="onResetType(row)"
|
||||
/>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<el-empty v-if="!loading && types.length === 0" description="未发现任何 Car 子类(请确认 SimpleLite 已加载相应插件 dll)" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import CarStyleEditorForm from './CarStyleEditorForm.vue'
|
||||
import {
|
||||
reflectionApi,
|
||||
type AlarmColorEntry,
|
||||
type CarStyleDto,
|
||||
type CarStyleTypeRow
|
||||
} from '@/api/reflection'
|
||||
|
||||
const loading = ref(false)
|
||||
const savingPalette = ref(false)
|
||||
const resettingPalette = ref(false)
|
||||
const savingTypeName = ref<string | null>(null)
|
||||
|
||||
const types = ref<CarStyleTypeRow[]>([])
|
||||
const forms = reactive<Record<string, CarStyleDto>>({})
|
||||
const activeKey = ref<string | undefined>(undefined)
|
||||
|
||||
const defaultKeys = ref<string[]>([])
|
||||
const paletteEntries = ref<AlarmColorEntry[]>([])
|
||||
/** 当前正在编辑、尚未持久化的颜色映射 */
|
||||
const paletteDraft = reactive<Record<string, number>>({})
|
||||
/** refresh 时记录服务器返回的快照,用于 diff 判定 dirty */
|
||||
const paletteSnapshot = reactive<Record<string, number>>({})
|
||||
|
||||
const paletteDirty = computed(() => {
|
||||
const keys = new Set([...Object.keys(paletteDraft), ...Object.keys(paletteSnapshot)])
|
||||
for (const k of keys) if (paletteDraft[k] !== paletteSnapshot[k]) return true
|
||||
return false
|
||||
})
|
||||
|
||||
function getPaletteColor(key: string): number {
|
||||
return paletteDraft[key] ?? 0
|
||||
}
|
||||
function setPaletteColor(key: string, hex: string | null) {
|
||||
paletteDraft[key] = hex ? hexToArgb(hex) : 0
|
||||
}
|
||||
|
||||
/** Element Plus el-color-picker 用 `#RRGGBBAA`(show-alpha)。 */
|
||||
function argbToHex(argb: number): string {
|
||||
const a = (argb >>> 24) & 0xff
|
||||
const r = (argb >>> 16) & 0xff
|
||||
const g = (argb >>> 8) & 0xff
|
||||
const b = argb & 0xff
|
||||
const hex = (n: number) => n.toString(16).padStart(2, '0')
|
||||
return `${hex(r)}${hex(g)}${hex(b)}${hex(a)}`
|
||||
}
|
||||
function hexToArgb(hex: string): number {
|
||||
const m = hex.replace(/^#/, '')
|
||||
if (m.length === 6) {
|
||||
const r = parseInt(m.slice(0, 2), 16)
|
||||
const g = parseInt(m.slice(2, 4), 16)
|
||||
const b = parseInt(m.slice(4, 6), 16)
|
||||
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0
|
||||
}
|
||||
if (m.length === 8) {
|
||||
const r = parseInt(m.slice(0, 2), 16)
|
||||
const g = parseInt(m.slice(2, 4), 16)
|
||||
const b = parseInt(m.slice(4, 6), 16)
|
||||
const a = parseInt(m.slice(6, 8), 16)
|
||||
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 会话 N+2:car-types 与 alarm-colors 独立 await,避免一边失败把另一边的成功也吞掉。
|
||||
// 任意一边 SimpleLite 未启动 / 返回了非预期结构(YARP 502 / null payload),都不要让整个面板白屏崩。
|
||||
const carTypesResp = await reflectionApi.getCarStyleTypes().catch((err) => {
|
||||
console.warn('[CarStyleEditor] getCarStyleTypes failed:', err)
|
||||
return null
|
||||
})
|
||||
const paletteResp = await reflectionApi.getAlarmColors().catch((err) => {
|
||||
console.warn('[CarStyleEditor] getAlarmColors failed:', err)
|
||||
return null
|
||||
})
|
||||
|
||||
// 防御 1:car-types 可能为 null / 缺 types 字段 / types 非数组。
|
||||
const carTypesList = Array.isArray(carTypesResp?.types) ? carTypesResp!.types : []
|
||||
types.value = carTypesList
|
||||
for (const row of carTypesList) {
|
||||
forms[row.typeName] = { ...row.style }
|
||||
}
|
||||
for (const k of Object.keys(forms)) {
|
||||
if (!carTypesList.find((r) => r.typeName === k)) delete forms[k]
|
||||
}
|
||||
|
||||
// 防御 2:palette 可能为 null / 缺 entries 字段 / entries 非数组(用户截图正是此处崩)。
|
||||
const safeEntries = Array.isArray(paletteResp?.entries) ? paletteResp!.entries : []
|
||||
const safeDefaultKeys = Array.isArray(paletteResp?.defaultKeys) ? paletteResp!.defaultKeys : []
|
||||
defaultKeys.value = safeDefaultKeys
|
||||
paletteEntries.value = safeEntries
|
||||
for (const k of Object.keys(paletteDraft)) delete paletteDraft[k]
|
||||
for (const k of Object.keys(paletteSnapshot)) delete paletteSnapshot[k]
|
||||
for (const e of safeEntries) {
|
||||
if (!e?.key) continue
|
||||
const argb = Number(e.colorArgb) >>> 0
|
||||
paletteDraft[e.key] = argb
|
||||
paletteSnapshot[e.key] = argb
|
||||
}
|
||||
|
||||
if (!carTypesResp || !paletteResp) {
|
||||
ElMessage.warning('车型样式 / 报警颜色加载部分失败,请确认 SimpleLite 已启动(端口 8222)。')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载车型样式失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePalette() {
|
||||
if (!paletteDirty.value) return
|
||||
savingPalette.value = true
|
||||
try {
|
||||
await reflectionApi.putAlarmColors({ ...paletteDraft })
|
||||
ElMessage.success('调色板已写入并生效')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
ElMessage.error(`保存调色板失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
savingPalette.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetPaletteToDefault() {
|
||||
resettingPalette.value = true
|
||||
const defaultArgb: Record<string, number> = {
|
||||
避障: 0xffff9800,
|
||||
运行: 0xff4caf50,
|
||||
上线: 0xff2196f3,
|
||||
下线: 0xff9e9e9e,
|
||||
导航失联: 0xff9c27b0,
|
||||
报警: 0xfff44336,
|
||||
低电量: 0xffffc107
|
||||
}
|
||||
for (const k of Object.keys(paletteDraft)) delete paletteDraft[k]
|
||||
for (const k of Object.keys(defaultArgb)) paletteDraft[k] = defaultArgb[k]
|
||||
resettingPalette.value = false
|
||||
ElMessage.info('已恢复为 7 种默认颜色,未写入。点「保存调色板」生效。')
|
||||
}
|
||||
|
||||
async function onSaveType(row: CarStyleTypeRow) {
|
||||
const body = forms[row.typeName]
|
||||
if (!body) return
|
||||
savingTypeName.value = row.typeName
|
||||
try {
|
||||
const saved = await reflectionApi.putCarStyle(row.typeName, body)
|
||||
forms[row.typeName] = { ...saved }
|
||||
row.hasOverride = true
|
||||
ElMessage.success(`已保存 ${row.label} 车型样式`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`保存 ${row.label} 失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
savingTypeName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetType(row: CarStyleTypeRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`把 ${row.label} (${row.typeName}) 的样式恢复为全局默认?删除条目后将与未覆盖车型一致。`,
|
||||
'恢复默认',
|
||||
{ confirmButtonText: '恢复', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.deleteCarStyle(row.typeName)
|
||||
ElMessage.success(`已恢复 ${row.label} 为默认样式`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
ElMessage.error(`恢复默认失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cse-root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
.cse-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.cse-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.cse-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cse-card-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.cse-desc {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cse-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cse-palette-item {
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cse-palette-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cse-palette-key {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.cse-palette-pickers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cse-color-code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.cse-types-card {
|
||||
flex: 1;
|
||||
}
|
||||
.cse-row-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.cse-row-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cse-row-fqn {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cse-btn-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<el-form label-width="120px" size="default" class="cse-form" :model="local">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="长度 (m)">
|
||||
<el-input-number :model-value="local.bodyLengthM" :min="0.12" :max="4" :step="0.01" :precision="2" @update:model-value="(v) => update({ bodyLengthM: Number(v ?? 0) })" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="宽度 (m)">
|
||||
<el-input-number :model-value="local.bodyWidthM" :min="0.08" :max="3" :step="0.01" :precision="2" @update:model-value="(v) => update({ bodyWidthM: Number(v ?? 0) })" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="显示名称">
|
||||
<el-switch :model-value="local.showLabel" @update:model-value="(v: string | number | boolean) => update({ showLabel: Boolean(v) })" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="车体颜色">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(local.bodyColorArgb)"
|
||||
show-alpha
|
||||
@update:model-value="(v: string | null) => update({ bodyColorArgb: v ? hexToArgb(v) : 0 })"
|
||||
/>
|
||||
<code class="cse-color-code">#{{ argbToHex(local.bodyColorArgb).toUpperCase() }}</code>
|
||||
<span class="cse-color-hint">无报警/上线时生效</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="描边颜色">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(local.outlineColorArgb)"
|
||||
show-alpha
|
||||
@update:model-value="(v: string | null) => update({ outlineColorArgb: v ? hexToArgb(v) : 0 })"
|
||||
/>
|
||||
<code class="cse-color-code">#{{ argbToHex(local.outlineColorArgb).toUpperCase() }}</code>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="标签颜色">
|
||||
<el-color-picker
|
||||
:model-value="argbToHex(local.labelColorArgb)"
|
||||
show-alpha
|
||||
@update:model-value="(v: string | null) => update({ labelColorArgb: v ? hexToArgb(v) : 0 })"
|
||||
/>
|
||||
<code class="cse-color-code">#{{ argbToHex(local.labelColorArgb).toUpperCase() }}</code>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="3D 模型路径">
|
||||
<div class="cse-model-row">
|
||||
<el-input
|
||||
:model-value="local.modelPath"
|
||||
placeholder="如 ./models/agv-base.glb(路径仅保存,3D 替换矩形渲染由后续阶段接入)"
|
||||
clearable
|
||||
@update:model-value="(v: string) => update({ modelPath: v ?? '' })"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="pickModelFile">浏览…</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".glb,.gltf,.obj,.fbx"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
<p class="cse-model-hint">
|
||||
现版本仅记录文件路径作为元信息。3D 渲染替代矩形将在后续阶段实现(CycleGUI Workspace LoadModel)。
|
||||
如需上传文件到服务器请先用其它方式将模型放到 SimpleLite 可访问的相对路径。
|
||||
</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="$emit('save')">保存此车型</el-button>
|
||||
<el-button v-if="hasOverride" type="warning" plain @click="$emit('reset')">恢复为默认</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CarStyleDto } from '@/api/reflection'
|
||||
|
||||
const props = defineProps<{
|
||||
typeFullName: string
|
||||
modelValue: CarStyleDto
|
||||
hasOverride: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: CarStyleDto): void
|
||||
(e: 'save'): void
|
||||
(e: 'reset'): void
|
||||
}>()
|
||||
|
||||
const local = computed<CarStyleDto>(() => props.modelValue)
|
||||
|
||||
function update(patch: Partial<CarStyleDto>) {
|
||||
emit('update:modelValue', { ...local.value, ...patch })
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
function pickModelFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
function onFileSelected(ev: Event) {
|
||||
const target = ev.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
// 浏览器禁止读取真实绝对路径,这里只保留文件名作为提示,由用户改写为相对项目可访问的路径。
|
||||
update({ modelPath: file.name })
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function argbToHex(argb: number): string {
|
||||
const a = (argb >>> 24) & 0xff
|
||||
const r = (argb >>> 16) & 0xff
|
||||
const g = (argb >>> 8) & 0xff
|
||||
const b = argb & 0xff
|
||||
const hex = (n: number) => n.toString(16).padStart(2, '0')
|
||||
return `${hex(r)}${hex(g)}${hex(b)}${hex(a)}`
|
||||
}
|
||||
function hexToArgb(hex: string): number {
|
||||
const m = hex.replace(/^#/, '')
|
||||
if (m.length === 6) {
|
||||
const r = parseInt(m.slice(0, 2), 16)
|
||||
const g = parseInt(m.slice(2, 4), 16)
|
||||
const b = parseInt(m.slice(4, 6), 16)
|
||||
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0
|
||||
}
|
||||
if (m.length === 8) {
|
||||
const r = parseInt(m.slice(0, 2), 16)
|
||||
const g = parseInt(m.slice(2, 4), 16)
|
||||
const b = parseInt(m.slice(4, 6), 16)
|
||||
const a = parseInt(m.slice(6, 8), 16)
|
||||
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cse-form {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.cse-color-code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.cse-color-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.cse-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.cse-model-hint {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="map-monitor-page">
|
||||
<el-row :gutter="12" class="kpi-row">
|
||||
<el-col :span="8">
|
||||
<el-statistic title="在线车辆" :value="onlineCars">
|
||||
<template #suffix><span class="muted">/ {{ cars.length }}</span></template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-statistic title="运行中任务" :value="runningMissions" />
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-statistic title="排队任务" :value="queuedMissions" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12" class="main-row">
|
||||
<el-col :span="16">
|
||||
<div class="canvas-with-alarms">
|
||||
<Workspace3D
|
||||
ref="workspaceRef"
|
||||
:host="vrHost"
|
||||
:scope="auth.scope ?? 'Platform'"
|
||||
:token="auth.token ?? ''"
|
||||
:read-only="false"
|
||||
:embed-ui="true"
|
||||
@pick="onPick"
|
||||
@select="onSelect"
|
||||
@ready="onWorkspaceReady"
|
||||
/>
|
||||
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
|
||||
<WorkspaceCanvasToolbar />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8" class="side-col">
|
||||
<el-card shadow="never" class="side-card workbench-card">
|
||||
<template #header>
|
||||
<span>车辆监控台</span>
|
||||
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
|
||||
刷新
|
||||
</el-button>
|
||||
</template>
|
||||
<VehicleMonitorPanel
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:selected-id="selectedVehicleId"
|
||||
@select="onVehicleSelect"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="side-card detail-card">
|
||||
<template #header><span>选中信息</span></template>
|
||||
<MonitorSelectionPanel
|
||||
:selection="selection"
|
||||
:refresh-key="tick"
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import Workspace3D from '@/components/Workspace3D.vue'
|
||||
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
|
||||
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
|
||||
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
|
||||
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
|
||||
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
|
||||
import { reflectionApi } from '@/api/reflection'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
import type { SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
|
||||
const cars = ref<Car[]>([])
|
||||
const missions = ref<Mission[]>([])
|
||||
const selection = ref<SelectedObjectRef | null>(null)
|
||||
const tick = ref(0)
|
||||
const refreshing = ref(false)
|
||||
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
|
||||
|
||||
/**
|
||||
* 浮动报警:订阅后端 AlarmStreamService 推送的 `alarm` 事件,
|
||||
* 维持一个滚动列表;超过 30 条丢最旧。clear 事件由 FloatingAlarmStack 自动过滤。
|
||||
*
|
||||
* 注意:本页只持有一个 useProjectionStream 实例,所有 SSE 事件(alarm / car-state /
|
||||
* mission-status / selection-detail / ...)共用同一条 EventSource,避免历史上「自己 + useMapEditStream
|
||||
* 各开一条」造成的两路 SSE 占用。
|
||||
*/
|
||||
const alarms = ref<AlarmEvent[]>([])
|
||||
|
||||
async function onAlarmLocate(a: AlarmEvent) {
|
||||
// 通过 reflectionApi.setSelection 把 SimpleLite 3D 场景的高亮切到该车,
|
||||
// CycleGUI 默认会把相机聚焦到 GetPosition 命中目标——视觉上等同于 flyTo。
|
||||
// a.carId 已经是 number(AlarmEvent 类型保证),无须额外字符串处理;如果将来后端
|
||||
// 改成字符串,这里 Number(...) 会返回 NaN 并由下面的 isFinite 拦下来。
|
||||
const numId = Number(a.carId)
|
||||
if (!Number.isFinite(numId)) {
|
||||
ElMessage.warning(`报警车辆 id 无法解析:${a.carId}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.setSelection('car', numId)
|
||||
selection.value = { kind: 'vehicle', id: String(numId), name: a.carName ?? String(a.carId) }
|
||||
} catch (err) {
|
||||
ElMessage.error(`定位失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
function onAlarmAck(_a: AlarmEvent) {
|
||||
// 当前后端无 ACK 接口,前端本地标记即可(FloatingAlarmStack 已处理)
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
|
||||
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length)
|
||||
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length)
|
||||
|
||||
const selectedVehicleId = computed(() => {
|
||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
||||
const sel = selection.value
|
||||
const match = cars.value.find((c) => c.id === sel.id || detailIdForCar(c) === sel.id)
|
||||
return match?.id ?? null
|
||||
})
|
||||
|
||||
function onPick(_p: { x: number; y: number }) { /* 坐标由 3D 选中对象驱动 */ }
|
||||
|
||||
// SimpleLite 3D 端发送的对象命名规则(见 SimpleUI.cs / UISite.cs / TrackUiHelper):
|
||||
// - 车辆:`Car-{id}` 或 `UICar-{id}`(不同入口不同前缀,正则同时兼容)
|
||||
// - 站点:`UISite-{id}`
|
||||
// - 路径:`UITrack-{id}` / `UICircularArcTrack-{id}` / `UIBezierTrack-{id}` / `UINurbsTrack-{id}`
|
||||
const RX_CAR = /^(?:UI)?(?:Car|Vehicle)-(\d+)$/i
|
||||
const RX_SITE = /^UISite-(\d+)$/i
|
||||
const RX_TRACK = /^(?:UITrack|UICircularArcTrack|UIBezierTrack|UINurbsTrack)-(\d+)$/i
|
||||
|
||||
function parseSceneObjectName(name: string): { kind: 'vehicle' | 'site' | 'track'; id: string } | null {
|
||||
const car = RX_CAR.exec(name)
|
||||
if (car) return { kind: 'vehicle', id: car[1]! }
|
||||
const site = RX_SITE.exec(name)
|
||||
if (site) return { kind: 'site', id: site[1]! }
|
||||
const track = RX_TRACK.exec(name)
|
||||
if (track) return { kind: 'track', id: track[1]! }
|
||||
// 与地图编辑页一致:贪婪吃掉 UI 前缀字母段,兼容 UIBezierTrack-3 等
|
||||
const m = /^UI([A-Za-z]+)-(\d+)$/.exec(name) ?? /^([A-Za-z]+)-(\d+)$/.exec(name)
|
||||
if (!m) return null
|
||||
const tn = m[1]!.toLowerCase()
|
||||
const id = m[2]!
|
||||
if (tn === 'site') return { kind: 'site', id }
|
||||
if (tn === 'car' || tn === 'vehicle') return { kind: 'vehicle', id }
|
||||
if (tn.includes('track')) return { kind: 'track', id }
|
||||
return null
|
||||
}
|
||||
|
||||
function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null): boolean {
|
||||
if (!a && !b) return true
|
||||
if (!a || !b) return false
|
||||
return a.kind === b.kind && a.id === b.id
|
||||
}
|
||||
|
||||
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
|
||||
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
|
||||
let next: SelectedObjectRef
|
||||
if (parsed.kind === 'vehicle') {
|
||||
const car = (rawName ? findSelectedCar(rawName) : undefined)
|
||||
?? cars.value.find((c) => detailIdForCar(c) === parsed.id)
|
||||
next = car
|
||||
? { kind: 'vehicle', id: detailIdForCar(car), name: car.name }
|
||||
: { kind: 'vehicle', id: parsed.id, name: `Vehicle ${parsed.id}` }
|
||||
} else {
|
||||
next = {
|
||||
kind: parsed.kind,
|
||||
id: parsed.id,
|
||||
name: rawName || `${parsed.kind === 'site' ? 'Site' : 'Track'} ${parsed.id}`
|
||||
}
|
||||
}
|
||||
if (sameSelection(selection.value, next)) return
|
||||
selection.value = next
|
||||
tick.value++
|
||||
}
|
||||
|
||||
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
|
||||
if (kind === 'car') {
|
||||
const sid = String(id)
|
||||
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
|
||||
const car = findSelectedCar(rawName) ?? cars.value.find((c) => detailIdForCar(c) === sid || c.id === sid)
|
||||
const next = car
|
||||
? { kind: 'vehicle' as const, id: detailIdForCar(car), name: car.name }
|
||||
: { kind: 'vehicle' as const, id: sid, name: `Vehicle ${sid}` }
|
||||
if (!sameSelection(selection.value, next)) {
|
||||
selection.value = next
|
||||
tick.value++
|
||||
}
|
||||
return
|
||||
}
|
||||
const sid = String(id)
|
||||
const rawName = names?.find((n) => {
|
||||
const p = parseSceneObjectName(n)
|
||||
return p?.kind === kind && p.id === sid
|
||||
})
|
||||
const next = {
|
||||
kind,
|
||||
id: sid,
|
||||
name: rawName || `${kind === 'site' ? 'Site' : 'Track'} ${sid}`
|
||||
}
|
||||
if (!sameSelection(selection.value, next)) {
|
||||
selection.value = next
|
||||
tick.value++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SimpleLite 画布点选后通过 SSE `selection-detail` 广播(见 SimpleUI.BroadcastSelectionToWeb)。
|
||||
* embed 模式通常不会 postMessage `workspace.select`,因此必须在此同步侧栏。
|
||||
*/
|
||||
function onSelectionDetailFromStream(e: SelectionDetailEvent) {
|
||||
const siteN = e.siteIds?.length ?? 0
|
||||
const trackN = e.trackIds?.length ?? 0
|
||||
const carN = e.carIds?.length ?? 0
|
||||
const nameN = Array.isArray(e.names) ? e.names.length : 0
|
||||
if (siteN + trackN + carN + nameN === 0) {
|
||||
selection.value = null
|
||||
tick.value++
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(e.names)) {
|
||||
for (const n of e.names) {
|
||||
const parsed = parseSceneObjectName(n)
|
||||
if (parsed) {
|
||||
applyParsedSelection(parsed, n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.kind && e.id) {
|
||||
applySelectionFromKindId(e.kind, e.id, e.names)
|
||||
return
|
||||
}
|
||||
|
||||
if (siteN > 0) {
|
||||
applySelectionFromKindId('site', e.siteIds![0]!, e.names)
|
||||
return
|
||||
}
|
||||
if (trackN > 0) {
|
||||
applySelectionFromKindId('track', e.trackIds![0]!, e.names)
|
||||
return
|
||||
}
|
||||
if (carN > 0) {
|
||||
applySelectionFromKindId('car', e.carIds![0]!, e.names)
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(names: string[]) {
|
||||
if (!names.length) {
|
||||
// embed 画布可能不发 select 或发空数组;勿清空,改读后端当前选中。
|
||||
void fallbackSelectionFromBackend()
|
||||
return
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
const parsed = parseSceneObjectName(n)
|
||||
if (!parsed) continue
|
||||
applyParsedSelection(parsed, n)
|
||||
return
|
||||
}
|
||||
|
||||
void fallbackSelectionFromBackend()
|
||||
}
|
||||
|
||||
function onWorkspaceReady() {
|
||||
void fallbackSelectionFromBackend()
|
||||
}
|
||||
|
||||
async function fallbackSelectionFromBackend() {
|
||||
try {
|
||||
const sel = await reflectionApi.getSelection()
|
||||
if (!sel?.kind || sel.id == null) return
|
||||
if (sel.kind !== 'car' && sel.kind !== 'site' && sel.kind !== 'track') return
|
||||
applySelectionFromKindId(sel.kind, Number(sel.id), sel.name ? [sel.name] : undefined)
|
||||
} catch {
|
||||
// 仅作为命名兼容兜底,失败时保持当前 selection,不打断主流程。
|
||||
}
|
||||
}
|
||||
|
||||
async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
if (!sameSelection(selection.value, ref)) {
|
||||
selection.value = ref
|
||||
tick.value++
|
||||
}
|
||||
const id = Number(ref.id)
|
||||
if (!Number.isFinite(id)) return
|
||||
try {
|
||||
await reflectionApi.setSelection('car', id)
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectedCar(name: string): Car | undefined {
|
||||
const parsedId = parseWorkspaceCarId(name)
|
||||
return cars.value.find((c) =>
|
||||
c.name === name ||
|
||||
c.id === name ||
|
||||
(parsedId != null && detailIdForCar(c) === parsedId)
|
||||
)
|
||||
}
|
||||
|
||||
function parseWorkspaceCarId(name: string): string | null {
|
||||
const m = /^(?:UI)?(?:Car|Vehicle)-(\d+)$/i.exec(name)
|
||||
return m?.[1] ?? null
|
||||
}
|
||||
|
||||
function detailIdForCar(car: Car): string {
|
||||
if (car.rawId != null) return String(car.rawId)
|
||||
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
cars.value = await listCars()
|
||||
missions.value = await listMissions()
|
||||
} catch (err) {
|
||||
console.warn('[MapMonitor] refreshAll failed', err)
|
||||
} finally {
|
||||
tick.value++
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// SSE 实时通道:本页只起一条流,所有事件都从这里分发。
|
||||
//
|
||||
// 刷新策略:
|
||||
// - car-state / mission-status:后端真正的"车辆/任务状态变更"事件 → 立刻 refreshAll(目前后端
|
||||
// 还没真正实现这两条,但为未来事件型推送预留)。
|
||||
// - alarm:直接更新 alarms 列表,不需要 refreshAll。
|
||||
// - snapshot-tick:后端 1Hz 心跳,仅含 cars/missions/sites/tracks 数字,不带实际状态。**不要**
|
||||
// 在这里触发 refreshAll,否则会等价"1s 全量轮询 listCars+listMissions",比原来的 3s 轮询还
|
||||
// 重。snapshot-tick 仅用于 `stream.connected` 心跳判定。
|
||||
// - selection-detail:画布点选后 SimpleLite 广播,驱动右侧「选中信息」面板(embed 模式主路径)。
|
||||
// - 其它 object-* / pick-result / project-*:本页不处理,由地图编辑页订阅。
|
||||
//
|
||||
// 实时通道未连接(旧 SimpleLite / 离线)时退回 3s 轮询保底。
|
||||
const stream = useProjectionStream({ autoConnect: true })
|
||||
|
||||
function onStreamEvent(e: StreamEvent) {
|
||||
if (!e?.kind) return
|
||||
switch (e.kind) {
|
||||
case 'alarm': {
|
||||
const a = e.payload as AlarmEvent | undefined
|
||||
if (!a) return
|
||||
// clear 事件 carId 同样有,但 info 为空;FloatingAlarmStack 内部按 info 是否为空过滤。
|
||||
alarms.value = [a, ...alarms.value.filter((x) => x.carId !== a.carId)].slice(0, 30)
|
||||
break
|
||||
}
|
||||
case 'car-state':
|
||||
case 'mission-status':
|
||||
void refreshAll()
|
||||
break
|
||||
case 'selection-detail':
|
||||
if (e.payload && typeof e.payload === 'object') {
|
||||
onSelectionDetailFromStream(e.payload as SelectionDetailEvent)
|
||||
}
|
||||
break
|
||||
case 'monitor-config-updated':
|
||||
// 运营维护保存后 SimpleLite 广播,刷新选中面板动作列表
|
||||
tick.value++
|
||||
break
|
||||
// 'snapshot-tick' 故意不触发 refreshAll —— 见上面注释。
|
||||
}
|
||||
}
|
||||
stream.on(onStreamEvent)
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll()
|
||||
pollTimer = setInterval(() => {
|
||||
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
|
||||
if (!stream.connected.value || cars.value.length === 0) void refreshAll()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
stream.off(onStreamEvent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-monitor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: calc(100vh - 56px - 36px - 32px);
|
||||
}
|
||||
.kpi-row { flex: none; }
|
||||
.main-row { flex: 1; min-height: 0; }
|
||||
.main-row > .el-col { display: flex; flex-direction: column; min-height: 0; }
|
||||
.side-col { gap: 12px; }
|
||||
.side-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.workbench-card { flex: 1.2; }
|
||||
.detail-card { flex: 1; }
|
||||
.workbench-card :deep(.el-card__body),
|
||||
.detail-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.78); font-size: 12px; }
|
||||
.workbench-card :deep(.el-card__header),
|
||||
.detail-card :deep(.el-card__header) {
|
||||
color: #fff;
|
||||
}
|
||||
:deep(.el-row.main-row) { height: 100%; }
|
||||
:deep(.el-col-16 > div) { height: 100%; min-height: 0; }
|
||||
.canvas-with-alarms { position: relative; height: 100%; min-height: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.canvas-with-alarms :deep(.workspace-3d-wrap) { flex: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="mission-page">
|
||||
<ReflectionManagerPanel
|
||||
kind="process"
|
||||
kind-label="任务"
|
||||
title="任务编排(Mission / 进程,含插件 MissionType 实例化)"
|
||||
empty-text="当前没有任务;点击右上角「新建任务」从已加载的 MissionType 中选一个实例化。"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* /admin/missions 路由的页面入口。
|
||||
*
|
||||
* 由于「任务」与「进程」在后端 ReflectionApiController 共用 kind=process / mission
|
||||
* (UiDiscoveryCache.MissionCreatableTypes 同源),这里直接用 ReflectionManagerPanel
|
||||
* 接入,按下「新建任务」会列出所有 MissionType 子类(含 taskflow / trigger)。
|
||||
*
|
||||
* 旧版本的 DataTablePro + listMissions mock 已经下线,所有数据走 SimpleLite 反射 API。
|
||||
*/
|
||||
import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPanel.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-page {
|
||||
height: calc(100vh - 56px);
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="orchestration-page">
|
||||
<el-tabs v-model="tab" class="orch-tabs" type="border-card" @tab-change="onTabChange">
|
||||
<el-tab-pane label="车辆" name="cars">
|
||||
<CarPanelView v-if="tab === 'cars'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="进程" name="process">
|
||||
<MissionEditorView v-if="tab === 'process'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="脚本" name="script">
|
||||
<ReflectionKindTable
|
||||
v-if="tab === 'script'"
|
||||
kind="script"
|
||||
empty-text="脚本(继承 CarProgram 的子类型)未发现可暴露条目"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="任务流" name="taskflow">
|
||||
<ReflectionKindTable
|
||||
v-if="tab === 'taskflow'"
|
||||
kind="mission"
|
||||
extra-filter="taskflow"
|
||||
empty-text="未发现任务流条目"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="触发器" name="trigger">
|
||||
<ReflectionKindTable
|
||||
v-if="tab === 'trigger'"
|
||||
kind="mission"
|
||||
extra-filter="trigger"
|
||||
empty-text="未发现触发器条目"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import CarPanelView from '@/views/admin/CarPanelView.vue'
|
||||
import MissionEditorView from '@/views/admin/MissionEditorView.vue'
|
||||
import ReflectionKindTable from '@/components/orchestration/ReflectionKindTable.vue'
|
||||
|
||||
const tab = ref<'cars' | 'process' | 'script' | 'taskflow' | 'trigger'>('cars')
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
tab.value = name as typeof tab.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = sessionStorage.getItem('orchestration.tab')
|
||||
if (cached) tab.value = cached as typeof tab.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.orchestration-page {
|
||||
height: calc(100vh - 56px);
|
||||
padding: 12px;
|
||||
}
|
||||
.orch-tabs {
|
||||
height: 100%;
|
||||
}
|
||||
.orch-tabs :deep(.el-tabs__content) {
|
||||
height: calc(100% - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header><span>调度回放(PlaybackPolicy.retentionDays={{ retention }} 天)</span></template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6">
|
||||
<el-input v-model="filter.kw" placeholder="按任务 / 车辆检索" clearable />
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-date-picker
|
||||
v-model="filter.range"
|
||||
type="datetimerange"
|
||||
range-separator="→"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
style="width: 100%" />
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-button type="primary">检索快照</el-button>
|
||||
<el-button>下载日志</el-button>
|
||||
<el-button :icon="VideoPlay" plain>回放选中</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-table :data="rows" stripe>
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="ts" label="时间" width="180" />
|
||||
<el-table-column prop="type" label="类型" width="140" />
|
||||
<el-table-column prop="summary" label="概要" />
|
||||
<el-table-column prop="size" label="大小" width="100" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default>
|
||||
<el-button text size="small">回放</el-button>
|
||||
<el-button text size="small">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { VideoPlay } from '@element-plus/icons-vue'
|
||||
|
||||
const retention = 30
|
||||
const filter = reactive<{ kw: string; range: [Date, Date] | null }>({ kw: '', range: null })
|
||||
|
||||
const rows = ref([
|
||||
{ id: 'SNAP-001', ts: '2026-05-19 10:21:33', type: '调度异常', summary: 'AGV-005 上线超时 → 故障', size: '128 KB' },
|
||||
{ id: 'SNAP-002', ts: '2026-05-19 14:05:17', type: '路口死锁', summary: '路口-N 等待链 3 节点 30s', size: '64 KB' },
|
||||
{ id: 'SNAP-003', ts: '2026-05-20 09:11:02', type: '手动快照', summary: '用户 admin 触发 SnapshotExport', size: '420 KB' }
|
||||
])
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user