Compare commits

...
10 Commits
Author SHA1 Message Date
黄兆尉andCursor 3968f5dc1f 加固地图编辑拾取循环,避免多路 pick 竞争与选中回环。
统一 stop/restart pick 代际控制,并小幅整理编辑工具栏交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:37 +08:00
黄兆尉andCursor c9e835aaf7 运维网关支持按车辆配置方法真实下发,并按用户过滤审计。
新增 ops.car.execute 路径与前端运维操作/选中面板联动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:34 +08:00
黄兆尉andCursor c5d5f7a726 重构页面目录与 RBAC:扁平配置入口并对齐管理/运营页映射。
下线运营总览幽灵页,补 ExpandKeysForScope / PlatformToMonitor,同步导航与角色配置 UI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:50:02 +08:00
黄兆尉andCursor 59ccd7934a 登录固定 WebOnly,并增强部署向导流程。
移除启动模式切换与 ScopeSwitcher;向导拆分子组件并补充 setup 工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:51 +08:00
黄兆尉andCursor 79c9e36d89 在 MiGu 侧落地日志定时清理与磁盘空间告警。
系统配置可开关清理策略;日志管理页支持查看/保存清理选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:48:25 +08:00
黄兆尉andCursor 4180140ae4 Signal 数据中心改为运行时加载 schema,解除 StandardScene 工程编译耦合。
加固 Config/Signal 路径解析,前端 DataCenter 表单类型与 API 对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:47:15 +08:00
黄兆尉andCursor 3686abdc78 将调度内核标识从 SimpleLite 全面重命名为 Simple3。
配置段/环境变量、Launcher、健康检查 API、OpenAPI 与前后端文案同步;兼容探测旧 SimpleLite 进程名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:46:52 +08:00
黄兆尉 912c44c9bb Merge remote-tracking branch 'origin/main' 2026-08-26 13:56:20 +08:00
黄兆尉andCursor 5d277cf17d merge: 合并 origin/main(MiGu.DB 持久层拆分与车辆/OTA 更新)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:34:44 +08:00
zhaowei.huang 7af29fe07f fix: improve map editor and monitor operations 2026-07-26 14:14:19 +08:00
144 changed files with 7080 additions and 2961 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ MiGu.Server/data/*.db
MiGu.Server/data/*.db-shm
MiGu.Server/data/*.db-wal
MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录*.json 上面那行不递归)
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
MiGu.Server/data/ota/
# 前端构建产物
@@ -34,7 +34,7 @@ _build_out.txt
*.swp
Thumbs.db
Desktop.ini
/.cursor/rules
/.cursor/
/MiGu.Server/wwwroot
/MiGu.Server/data/platform.db
+116 -116
View File
@@ -1,10 +1,10 @@
# SimpleLite 三端系统架构设计
# Simple3 三端系统架构设计
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。SimpleLite 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。Simple3 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> 文档版本:v1.7.0
> 更新日期:2026-05-20
> 范围:SimpleLite(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 SimpleLite**
> 范围:Simple3(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 Simple3**
> v1.7.0 变更(多主题色板 · 工业紫默认):
> - **6 套可切换主题**`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
@@ -37,13 +37,13 @@
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
> v1.4 变更(保留):
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 SimpleLite 内置 `SystemMission``StartPlatformMission`)拉起、被 SimpleLite Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 Simple3 内置 `SystemMission``StartPlatformMission`)拉起、被 Simple3 Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **整体定位章节重写**:明确"两类后端可执行文件 + 一个进程编排者"的关系,避免读者误以为 Platform 是用 Windows Service / systemd 独立部署的服务。
> v1.3 变更(保留):
> 1. **取消 RCSMonitor.Server**`rcsmonitor-vue` 不再有独立后端进程,直接连 MiGu.Server;由 JWT 中的 `scope=RCSMonitor` + 角色权限码 + `WidgetGrant` 三层限制可见控件与可调用 API。
> 2. **3D 渲染基座定为 webVRender**:复用 SimpleLite WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → SimpleLite WebAPI 的反代;带鉴权与审计中间件。
> 2. **3D 渲染基座定为 webVRender**:复用 Simple3 WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → Simple3 WebAPI 的反代;带鉴权与审计中间件。
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
> 5. **Web-Enabled 下严禁同时本地操作**CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
@@ -79,43 +79,43 @@
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 SimpleLite 统一编排:**
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 Simple3 统一编排:**
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|----|----------|----------------|------------|------|--------|
| **SimpleLite 桌面端** | CycleGUI 客户端 | `SimpleLite.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 SimpleLite Watchdog 重启) | **SimpleLite 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 SimpleLite |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 SimpleLite 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
| **Simple3 桌面端** | CycleGUI 客户端 | `Simple3.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **Simple3 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 Simple3 Watchdog 重启) | **Simple3 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 Simple3 |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 Simple3 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
**关键澄清(v1.4 重点):**
- **后端可执行文件只有 2 个**`SimpleLite.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `SimpleLite.exe`MiGu.Server 由 SimpleLite 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 SimpleLite**`SystemMission` 是 SimpleLite 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **后端可执行文件只有 2 个**`Simple3.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `Simple3.exe`MiGu.Server 由 Simple3 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 Simple3**`SystemMission` 是 Simple3 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **Vue 两份产物,同一进程托管**MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`
- **鉴权与限权完全在 MiGu.Server 完成**RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
- **Desktop-Only 模式下,SystemMission 不触发**MiGu.Server 根本不会启动,Vue 端无法访问。
> 一句话:**用户点 SimpleLite.exe,由 SimpleLite 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
> 一句话:**用户点 Simple3.exe,由 Simple3 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
### 1.2 核心拆分原则
1. **Crash-Isolation(搞不崩主服务)**
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 SimpleLite`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 Simple3`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
2. **共享数据模型,独立运行时**
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 SimpleLite 里跑,Platform 只持有"投影"。
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 Simple3 里跑,Platform 只持有"投影"。
3. **库分离 + Provider 抽象**(详见 §7
- `simple_main` 由 SimpleLite 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- `simple_main` 由 Simple3 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- 持久层基于 **EF Core****通过 Provider 切换底层数据库**:默认 **SQLite**(边缘单机,配 ROSE HA);可选 **MySQL / PostgreSQL / SQL Server**,匹配现场已有 IT 基础设施与企业级 HA 能力(详见 §7.4–§7.7)。
- 高可用方案随 Provider 不同:SQLite 用 ROSE HAMySQL 用 InnoDB ClusterPostgreSQL 用 PatroniSQL Server 用 Always On AG。
4. **统一协议层**
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 SimpleLite 的设计/调度 API。
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 Simple3 的设计/调度 API。
5. **统一身份**
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),Simple3 在 Desktop-Only 下用本地用户表降级。
6. **运行模式互斥(强约束)**
- **Desktop-Only**CycleGUI 客户端独占;WebAPI / Platform **不启动**
@@ -139,7 +139,7 @@ flowchart TB
end
subgraph Host["主控宿主机 / 边缘服务器"]
subgraph SL["SimpleLite 主进程 (CycleGUI + ASP.NET Core 8)"]
subgraph SL["Simple3 主进程 (CycleGUI + ASP.NET Core 8)"]
direction TB
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
@@ -155,7 +155,7 @@ flowchart TB
direction TB
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
PL_API["Platform WebAPI<br/>(配置中心 / 外部对接 / 库位 / 账号)"]
PL_YARP["YARP 反代<br/>/api/sl/* → SimpleLite WebAPI<br/>+ Auth/审计中间件"]
PL_YARP["YARP 反代<br/>/api/sl/* → Simple3 WebAPI<br/>+ Auth/审计中间件"]
PL_Auth["SimpleAuth<br/>(JWT 签发 + Scope 校验)"]
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
PL_DB[("SQLite: platform.db<br/>WAL")]
@@ -203,11 +203,11 @@ flowchart TB
**要点:**
- **只有两个后端可执行文件**`SimpleLite.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 SimpleLite 的 SystemMission 拉起**:用户只启动 SimpleLiteWeb-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **只有两个后端可执行文件**`Simple3.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
- **`PL_YARP``/api/sl/*` 反代到 SimpleLite**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 SimpleLite 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **`PL_YARP``/api/sl/*` 反代到 Simple3**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 Simple3 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **Desktop-Only 模式**`SL_API``PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
- **Web-Enabled 模式**CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
@@ -219,8 +219,8 @@ flowchart TB
```mermaid
flowchart TB
Start([SimpleLite.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>SimpleLite Boot Login]
Start([Simple3.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>Simple3 Boot Login]
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
Login --> Form
AutoLogin --> ModeSwitch
@@ -269,7 +269,7 @@ flowchart TB
| 元素 | 控件 | 说明 |
|------|------|------|
| 标题 | Label | "SimpleLite · 启动登录" |
| 标题 | Label | "Simple3 · 启动登录" |
| 用户名 | TextInput | 默认上次登录用户 |
| 密码 | TextInput (password) | — |
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
@@ -281,7 +281,7 @@ flowchart TB
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
| 退出按钮 | Button | 不进入主程序,退出进程 |
> 实现位置:新增 `SimpleLite/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
> 实现位置:新增 `Simple3/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
#### simple.json 增量字段
@@ -310,18 +310,18 @@ flowchart TB
| 场景 | 行为 |
|------|------|
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
| Desktop-Only → Web-Enabled | **必须退出 SimpleLite 重新登录**;不支持热切(避免半启动状态) |
| Desktop-Only → Web-Enabled | **必须退出 Simple3 重新登录**;不支持热切(避免半启动状态) |
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `SimpleLite.exe --re-login` 强制弹出登录窗 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `Simple3.exe --re-login` 强制弹出登录窗 |
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset``rememberRunMode` 置为 `false`**仅在下次重启时生效** |
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
```text
┌──────────────────────────────────────────┐
│ SimpleLite Service Status │
│ Simple3 Service Status │
├──────────────────────────────────────────┤
│ 模式: Web-Enabled │
│ 启动时间: 2026-05-18 09:00:00 │
@@ -348,7 +348,7 @@ flowchart TB
```mermaid
flowchart LR
A["SimpleLite.exe<br/>启动 + 登录"] --> Mode{运行模式}
A["Simple3.exe<br/>启动 + 登录"] --> Mode{运行模式}
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
D --> SchedD[调度引擎]
@@ -360,7 +360,7 @@ flowchart LR
WHost --> WV[WebTerminal :8223<br/>webVRender]
WHost --> StatusPanel[CycleGUI 状态窗]
subgraph SysMis["SimpleLite SystemMission 体系"]
subgraph SysMis["Simple3 SystemMission 体系"]
E["StartPlatformMission<br/>(SystemMission 子类)"]
SM_Reg["SystemMissionRegistry"]
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
@@ -380,24 +380,24 @@ flowchart LR
### 4.2 SystemMission 体系(v1.4 新增章节)
SimpleLite 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
Simple3 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
**关键类:**
| 类型 | 类型 / 命名空间 | 职责 |
|------|------------------|------|
| `SystemMission`(抽象) | `SimpleLite.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `SimpleLite.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `SimpleLite.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `SimpleLite.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `SimpleLite.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `SimpleLite.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `SimpleLite.Bootstrap` | `DesktopOnly` / `WebEnabled` |
| `SystemMission`(抽象) | `Simple3.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `Simple3.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `Simple3.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `Simple3.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `Simple3.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `Simple3.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `Simple3.Bootstrap` | `DesktopOnly` / `WebEnabled` |
**生命周期:**
```text
SimpleLite 启动 (Web-Enabled)
Simple3 启动 (Web-Enabled)
├─ Kestrel Up :7001/7002
├─ WebTerminal Up :8223
├─ SchedulerRuntime.Start()
@@ -414,7 +414,7 @@ SimpleLite 启动 (Web-Enabled)
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
SimpleLite 优雅停机:
Simple3 优雅停机:
SystemMissionRegistry.StopAll()
└─ StartPlatformMission.Stop()
├─ 发送 SIGTERM 等价(命名管道 "shutdown" 消息)
@@ -424,26 +424,26 @@ SimpleLite 优雅停机:
**实现要点:**
- **位置**:新建 `SimpleLite/Bootstrap/``SimpleLite/RCS/SystemMissions/`
- **位置**:新建 `Simple3/Bootstrap/``Simple3/RCS/SystemMissions/`
- **不写入主库 Missions 表**SystemMission 是进程级配置,不参与项目序列化;存储在 `simple_main.db.SystemMissionConfig`(或直接读 `simple.json`)。
- **崩溃隔离**Platform 异常不影响 SimpleLite 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 SimpleLite 唯一;MiGu.Server 启动时校验"父进程是否为 SimpleLite",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **崩溃隔离**Platform 异常不影响 Simple3 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 Simple3 唯一;MiGu.Server 启动时校验"父进程是否为 Simple3",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **静态资源**MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`
---
## 5. 三端职责矩阵
| 维度 | SimpleLite 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
| 维度 | Simple3 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
|------|---------------------|------------------------------------------|------------------------------------------|
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌SimpleLite地图) | 只读投影,不可编辑 |
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌Simple3地图) | 只读投影,不可编辑 |
| 任务编排(Mission/Recipe | **读写** | **全功能读写**(含 Demand / Recipe | 基础动作:暂停/取消/重派/优先级 |
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
| CAD 工具 | **可用** | **可用**Vue 调用 `/api/sl/cad/*` | 不可用 |
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 Simple3 webVRender**(全功能交互) | **嵌入 Simple3 webVRender**(只读 + 圈选) |
| 系统级配置 | 局部(Desktop-Only | **全局编辑 + 下发** | 不可编辑 |
| 外部对接(MES/WMS/RCS | 不涉及 | **唯一入口** | 不涉及 |
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
@@ -549,7 +549,7 @@ Platform 将典型业务场景抽象为标准模板,支持开箱即用和后
```mermaid
flowchart TB
subgraph 顶层["顶层(按端隔离)"]
L1A["SimpleLite (CycleGUI + WebAPI Host)"]
L1A["Simple3 (CycleGUI + WebAPI Host)"]
L1B["MiGu.Server (ASP.NET Core + YARP)"]
L1B2["platform-vue (SPA, 管理员)"]
L1B3["rcsmonitor-vue (SPA, 运营)"]
@@ -608,17 +608,17 @@ flowchart TB
| `SimpleShared.Persistence` | net8.0 | EF Core 8 多 Provider 抽象(**SQLite / MySQL / PostgreSQL / SQL Server**+ 迁移 + `DbProviderOptions` + 连接串构建器 + `WalSqliteOptions`(仅 SQLite |
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 SimpleLite** + 静态托管两份 Vue dist;**启动时校验父进程为 SimpleLite,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 SimpleLite 所有控件 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 Simple3** + 静态托管两份 Vue dist;**启动时校验父进程为 Simple3,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 Simple3 所有控件 |
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
| `packages/sl-controls`pnpm workspace | Vue 3 组件库 | Workspace3D(嵌 webVRender/ 地图列表 / 任务表 / CAD 工具栏等共享组件 |
### 6.1 Platform Vue 控件复刻清单(与 SimpleLite CycleGUI 对照)
### 6.1 Platform Vue 控件复刻清单(与 Simple3 CycleGUI 对照)
| SimpleLite CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
| Simple3 CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
|--------------------------|--------------------------------|------|
| 主菜单栏 / 工具栏 | `<AppShell>` 顶栏 + 侧栏 | platform-vue 全功能;rcsmonitor-vue 仅显示运营菜单 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 SimpleLite webVRender**;交互事件用 `postMessage` 桥接 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 Simple3 webVRender**;交互事件用 `postMessage` 桥接 |
| 地图面板(Sites / Tracks | `<MapEditor>` + `<TrackTable>` | 表格 + 拓扑视图 |
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
@@ -674,7 +674,7 @@ function onReady() {
</script>
```
`SimpleLite/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
`Simple3/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
- `scope=RCSMonitor``ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
@@ -690,7 +690,7 @@ builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(ctx =>
{
// 透传当前 JWT 的 userId / scope 给 SimpleLite
// 透传当前 JWT 的 userId / scope 给 Simple3
ctx.AddRequestTransform(async tc =>
{
var ident = tc.HttpContext.User;
@@ -767,7 +767,7 @@ app.Run();
```mermaid
flowchart TB
subgraph App["应用代码 (SimpleLite / MiGu.Server)"]
subgraph App["应用代码 (Simple3 / MiGu.Server)"]
DOM["领域服务 / Repository"]
EFC["EF Core 8 DbContext"]
OPT["DbProviderOptions<br/>{ provider, conn, ha }"]
@@ -813,7 +813,7 @@ flowchart TB
| 逻辑库 | 拥有进程 | 主要表/数据 |
|--------|----------|-------------|
| **simple_main** | SimpleLite | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **simple_main** | Simple3 | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **platform** | Platform | Users, Roles, RolePermissions, WidgetGrants, SystemConfig, ExternalAdapters, RoutingPolicies, VehicleMaintenancePolicies, ChargePolicies, TaskAllocationPolicies, TrafficRules, Locations, Inventory, OpsLogs(含 RCSMonitor 来源), Annotations, Snapshots, Versions, CustomWidgets |
> 原 v1.2 中的 `rcsmonitor_local` 取消;其表(OpsLogs / Annotations / Snapshots / WidgetLayoutCache)合并到 `platform`,加 `source='RCSMonitor'` 字段区分。
@@ -824,12 +824,12 @@ flowchart TB
```mermaid
flowchart TB
subgraph A["主节点 NODE-A (Active)"]
SL_A["SimpleLite.exe"]
SL_A["Simple3.exe"]
PL_A["MiGu.Server.exe"]
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
end
subgraph B["备节点 NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
D_B[("ROSE 镜像盘 R:\")]
end
@@ -860,7 +860,7 @@ flowchart TB
|------|------|------|
| `RES_DISK_R` | Mirror Disk | — |
| `RES_VIP_LAN` | Virtual IP | RES_DISK_R |
| `RES_SVC_SL` | Service: SimpleLite (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_SL` | Service: Simple3 (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_PL` | Service: MiGu.Server | RES_DISK_R, RES_VIP_LAN, RES_SVC_SL |
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
@@ -871,7 +871,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层 (VIP + 重试)"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Router["MySQL Router (或 ProxySQL)"]
@@ -911,7 +911,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph LB["HAProxy / PgBouncer"]
@@ -953,7 +953,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Listener["AG Listener (DNS + VIP)"]
@@ -983,7 +983,7 @@ flowchart TB
- **EF Core Provider**`Microsoft.EntityFrameworkCore.SqlServer`;支持 SQL Server 2019+(建议 Enterprise 版以启用同步提交副本数 ≥3 + Read-Only Routing)。Standard 版可降级为 2 副本基本可用性组。
- **HA 形态****WSFCWindows Server Failover Cluster+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimpleLite 桌面端可走 Windows 集成认证。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimple3 桌面端可走 Windows 集成认证。
- **TempDB**:每节点分摊;MultipleActiveResultSetsMARS)建议关闭,EF Core 已能避免。
- **备份**`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
- **RTO / RPO**RTO ≈ 1030sAG 自动 failover);RPO ≈ 0(同步提交副本)。
@@ -1091,7 +1091,7 @@ flowchart TB
"database": "simple_main",
"user": "simple_app",
"password": "${SECRET_MSSQL_PWD}",
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=SimpleLite"
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=Simple3"
},
"platform": {
"server": "tcp:rcs-ag.lan,1433",
@@ -1158,7 +1158,7 @@ classDiagram
**落地四件套:**
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 SimpleLite 本地签发短期 Token(24h)。
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 Simple3 本地签发短期 Token(24h)。
2. **授权**:所有 WebAPI + WS 调用走 `IPermissionEvaluator`;权限码 `<area>.<action>`
3. **范围(Scope**`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope``/admin/*``Platform``/monitor/*``RCSMonitor`)。
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
@@ -1173,7 +1173,7 @@ sequenceDiagram
participant U as 运营用户
participant RV as rcsmonitor-vue
participant P as MiGu.Server
participant L as SimpleLite (YARP 后侧)
participant L as Simple3 (YARP 后侧)
participant DBp as platform.db
U->>RV: 访问 https://host:8080/monitor
@@ -1255,7 +1255,7 @@ flowchart LR
UI11 --> Sch
Sch --> Val --> Ver --> Pub
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["SimpleLite (主服务)"]
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["Simple3 (主服务)"]
Pub -- "WS broadcast: config.updated" --> RMVue["rcsmonitor-vue"]
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
```
@@ -1316,7 +1316,7 @@ public record EffectivePermissions(
List<WidgetGrantDto> VisibleWidgets);
```
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 SimpleLite,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 Simple3,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
---
@@ -1327,14 +1327,14 @@ public record EffectivePermissions(
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite (CycleGUI)
participant L as Simple3 (CycleGUI)
participant BL as BootLoginPanel
participant K as Kestrel (WebAPI)
participant P as MiGu.Server (YARP)
participant V as platform-vue
participant RV as rcsmonitor-vue
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读取 simple.json
alt rememberRunMode = true
L->>L: 直接采用 runMode (仍鉴权)
@@ -1373,10 +1373,10 @@ sequenceDiagram
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite
participant L as Simple3
participant BL as BootLoginPanel
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
L->>BL: 必要时弹出登录窗
U->>BL: 输入凭据 + 选 Desktop-Only
@@ -1387,13 +1387,13 @@ sequenceDiagram
L-->>U: 进入设计端主界面
```
### 10.3 platform-vue 编辑地图站点 → SimpleLite 落库
### 10.3 platform-vue 编辑地图站点 → Simple3 落库
```mermaid
sequenceDiagram
participant V as platform-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (WebAPI)
participant L as Simple3 (WebAPI)
participant DBl as simple_main.db
participant RV as rcsmonitor-vue (订阅)
@@ -1406,7 +1406,7 @@ sequenceDiagram
P-->>V: 201 + siteId
L->>L: WS broadcast { type: "map.site.created", payload }
L-->>P: WS event
P-->>V: WS event (前端 Workspace3D iframe 收到 SimpleLite 推送的 PutModelObject 同步)
P-->>V: WS event (前端 Workspace3D iframe 收到 Simple3 推送的 PutModelObject 同步)
P-->>RV: WS event (RCSMonitor 接收并按权限展示)
```
@@ -1416,7 +1416,7 @@ sequenceDiagram
sequenceDiagram
participant V as platform-vue
participant P as Platform
participant L as SimpleLite
participant L as Simple3
participant E as DispatchLoop
participant RV as rcsmonitor-vue
@@ -1440,7 +1440,7 @@ sequenceDiagram
participant U as 运营人员
participant RV as rcsmonitor-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (Ops 网关)
participant L as Simple3 (Ops 网关)
participant E as DispatchLoop
participant DBp as platform.db
participant DBl as simple_main.db
@@ -1476,10 +1476,10 @@ sequenceDiagram
VIP->>NA: 路由到 Active
Note over NA: 节点故障
ROSE->>NA: 心跳超时 → 触发切换
ROSE->>NA: 强制停止 SimpleLite / Platform
ROSE->>NA: 强制停止 Simple3 / Platform
ROSE->>NA: 卸载 R: 卷
ROSE->>NB: 挂载 R: 卷
ROSE->>NB: 启动 SimpleLite (WAL 重放 + 主库自检)
ROSE->>NB: 启动 Simple3 (WAL 重放 + 主库自检)
ROSE->>NB: 启动 MiGu.Server
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
CLI--xVIP: 短暂中断
@@ -1495,12 +1495,12 @@ sequenceDiagram
| 通道 | 协议 | 编码 | 端点 | 用途 |
|------|------|------|------|------|
| MiGu.Server (YARP) ↔ SimpleLite | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| MiGu.Server (YARP) ↔ Simple3 | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| platform-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/admin` | UI 操作、事件推送 |
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor``scope=RCSMonitor`) | UI 操作、事件推送(受限) |
| Vue Workspace3D ↔ SimpleLite WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| Vue Workspace3D ↔ Simple3 WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
| Vehicle ↔ SimpleLite | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| Vehicle ↔ Simple3 | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
@@ -1510,7 +1510,7 @@ sequenceDiagram
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
- 幂等:写操作支持 `Idempotency-Key` 请求头
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
- SwaggerPlatform / SimpleLite 都暴露 `/swagger`(生产可关)
- SwaggerPlatform / Simple3 都暴露 `/swagger`(生产可关)
- **YARP Scope 白名单**`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*``/api/sl/projection/*` 命中;其他路径直接 403
---
@@ -1521,12 +1521,12 @@ sequenceDiagram
flowchart TB
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
subgraph NA["NODE-A (Active)"]
SL_A["SimpleLite.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
SL_A["Simple3.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
PL_A["MiGu.Server.exe<br/>:8080/8081<br/>(YARP + 双 Vue dist)"]
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
end
subgraph NB["NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
end
@@ -1556,12 +1556,12 @@ flowchart TB
| 端口 | 服务 | 启用条件 |
|------|------|----------|
| 7001 | SimpleLite WebAPI | Web-Enabled |
| 7002 | SimpleLite WebSocket | Web-Enabled |
| 7001 | Simple3 WebAPI | Web-Enabled |
| 7002 | Simple3 WebSocket | Web-Enabled |
| 8080 | Platform WebAPI + platform-vue 静态(/admin+ rcsmonitor-vue 静态(/monitor | Web-Enabled |
| 8081 | Platform WebSocket | Web-Enabled |
| 8222 | SimpleLite 车端 / API(现有) | 所有模式 |
| 8223 | SimpleLite WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
| 8223 | Simple3 WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
@@ -1588,7 +1588,7 @@ flowchart LR
SHH["SimpleShared.WebApiHost"]
end
subgraph apps["两端后端"]
SL["SimpleLite (CycleGUI + WebAPI)"]
SL["Simple3 (CycleGUI + WebAPI)"]
PSrv["MiGu.Server (WebAPI + YARP)"]
end
subgraph frontends["前端 (pnpm workspace)"]
@@ -1709,7 +1709,7 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|--------|----------|----------|
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProviderEF Core + SQLite + 迁移 |
| **M1 - 启动登录 + 运行模式** | 1 周 | `BootLoginPanel``RunMode` 枚举;CycleGUI 状态窗;`simple.json.runMode` 持久化;Web-Enabled 业务面板硬隐藏 |
| **M2 - WebAPI 协议层** | 1.5 周 | SimpleLite 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M2 - WebAPI 协议层** | 1.5 周 | Simple3 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`Workspace3D(嵌入 webVRender+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.ServerWebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist+ 配置中心 11 个 schema + 用户/角色/控件权限 |
| **M5 - platform-vue (管理员)** | 2.5 周 | 全功能套壳:登录、地图设计、车辆/任务、CAD、字段/图层、配置中心、库位、自定义控件、回放、设备管理、车队生命周期、场景模板 |
@@ -1726,14 +1726,14 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|------|------|
| 1 | 运行模式持久化 | **写入 `simple.json.runMode` / `rememberRunMode`**(不使用 %APPDATA% |
| 2 | Web-Enabled 下是否允许同时本地操作 | **不允许**CycleGUI 仅状态窗;不提供"临时启用本地 UI"开关 |
| 3 | 3D 引擎 | **复用 SimpleLite WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 3 | 3D 引擎 | **复用 Simple3 WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 4 | 反向代理 | **YARP**MiGu.Server 用 YARP 反代 `/api/sl/*` |
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 SimpleLite |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 Simple3 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 Simple3 |
| 11 | **持久层多 Providerv1.5** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF MigrationsCI 全矩阵跑通;运行期由 `simple.json.database.provider` 决定 |
| 12 | **跨 Provider 切换策略** | 不支持热切;切换走停服窗口 + `tools/db/migrate.ps1`;旧文件作为回退包保留 |
@@ -1743,22 +1743,22 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
| 现有文件 | 改动方向 |
|----------|----------|
| `SimpleLite/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `SimpleLite/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `SimpleLite/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `SimpleLite/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `SimpleLite/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `SimpleLite/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `SimpleLite/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `SimpleLite/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `SimpleLite/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `SimpleLite/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `SimpleLite/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `SimpleLite/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `SimpleLite/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `SimpleLite/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `Simple3/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `Simple3/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `Simple3/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `Simple3/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `Simple3/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `Simple3/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `Simple3/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `Simple3/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `Simple3/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `Simple3/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `Simple3/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `Simple3/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `Simple3/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `Simple3/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `SimpleScheduler/WebApi.cs` | 调用方从 Nancy 切到 ASP.NET Core Controller;保持 `SchedulerRuntime` 外观稳定 |
| `SimpleLite/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Simple3/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Plugins/Sample` | 示例插件读取 `IConfigSnapshotProvider`;不再读 `Configuration.conf` |
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
| 新增 `tools/db/*` | 跨 Provider 迁移脚本(`migrate.ps1` / `dump.ps1` / `restore.ps1`);按目标 DB 校验 schema 与索引 |
@@ -1909,12 +1909,12 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|-----|------|------|
| B1 | Kestrel + Swagger + CORS | ✅ | `Program.cs` |
| B2 | Auth / Health / Config / Ops / Projection 控制器 | ✅ 骨架 | Mock/内存存储 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 SimpleLite 实际监听 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 Simple3 实际监听 |
| B4 | 静态托管 + SPA fallback | ✅ | 单一 `wwwroot/index.html` |
| B5 | JWT 真签发 + 库持久化 | ☐ | 仍为 Mock |
| B6 | 父进程 SimpleLite 校验 | ☐ | §4、§15 #10 |
| B6 | 父进程 Simple3 校验 | ☐ | §4、§15 #10 |
### 18.3 SimpleLite / 共享内核(ARCHITECTURE 主体)
### 18.3 Simple3 / 共享内核(ARCHITECTURE 主体)
| # | 项 | 状态 | 备注 |
|---|-----|------|------|
@@ -1942,5 +1942,5 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
## 关联文档
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — SimpleLite 功能对齐执行计划
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — Simple3 功能对齐执行计划
- [SIMPLELITE_COMPOSER_PARITY_PLAN.md](./SIMPLELITE_COMPOSER_PARITY_PLAN.md) — 与 SimpleComposer 差距分析
+19 -19
View File
@@ -1,19 +1,19 @@
# SimpleLite 面向咪咕平台数据 API 文档
# Simple3 面向咪咕平台数据 API 文档
> **版本**:与 SimpleLite 源码同步
> **版本**:与 Simple3 源码同步
> **更新日期**2026-06-18
> **服务栈**EmbedIO WebApi(非 ASP.NET Core
> **OpenAPI**`Docs/openapi/simplelite-projection.json`(已在 MiGu.Server Swagger UI 中展示)
> **OpenAPI**`Docs/openapi/simple3-projection.json`(已在 MiGu.Server Swagger UI 中展示)
---
## 1. 架构与访问路径
SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 SimpleLite
Simple3 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 Simple3
| 层级 | 前缀 | 说明 |
|------|------|------|
| 直连 SimpleLite | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 直连 Simple3 | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 |
```
@@ -23,7 +23,7 @@ SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1
MiGu.Server :8080 (/api/sl/*)
│ 追加 X-Platform-Internal-Token
SimpleLite :8222 (/projection/*)
Simple3 :8222 (/projection/*)
```
### 1.1 鉴权
@@ -31,12 +31,12 @@ SimpleLite :8222 (/projection/*)
| 场景 | 要求 |
|------|------|
| 经 MiGu.Server 访问 | 需登录 JWT`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` |
| 直连 SimpleLite | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
| 直连 Simple3 | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
Token 解析优先级(SimpleLite 侧):
Token 解析优先级(Simple3 侧):
1. `simple.json``platform.internalToken`
2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN`
2. 环境变量 `SIMPLE3__PLATFORM__INTERNALTOKEN`
3. `Platform.Server/data/.internal-token``MiGu.Server/data/.internal-token`
### 1.2 通用响应格式
@@ -60,8 +60,8 @@ Token 解析优先级(SimpleLite 侧):
开发环境下启动 MiGu.Server 后访问:
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi
- SimpleLite 相关接口标签前缀为 `SimpleLite/`
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + Simple3 全部 WebApi
- Simple3 相关接口标签前缀为 `Simple3/`
---
@@ -112,7 +112,7 @@ Token 解析优先级(SimpleLite 侧):
{
"id": "C01",
"name": "AGV-1",
"typeName": "SimpleLite.RCS.CarTypes.Car",
"typeName": "Simple3.RCS.CarTypes.Car",
"rawId": 1,
"x": 1200.5,
"y": 800.0,
@@ -285,7 +285,7 @@ Token 解析优先级(SimpleLite 侧):
同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields``IOArea``Speed` 等基类字段)。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("SimpleLite")` 向 SimpleLite 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("Simple3")` 向 Simple3 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
---
@@ -467,7 +467,7 @@ Token 解析优先级(SimpleLite 侧):
咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`
| 文件 | 对应 SimpleLite 前缀 |
| 文件 | 对应 Simple3 前缀 |
|------|----------------------|
| `reflection.ts` | `/api/sl/projection/reflection` |
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
@@ -480,16 +480,16 @@ Token 解析优先级(SimpleLite 侧):
| 服务 | 默认端口 | 路径 |
|------|----------|------|
| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite |
| SimpleLite 投影 API | 8222 | `/projection/*` |
| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
| MiGu.Server | 8080 | `/api/sl/*` → Simple3 |
| Simple3 投影 API | 8222 | `/projection/*` |
| Simple3 webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
**健康检查**`GET http://localhost:8080/api/health/simplelite`
**健康检查**`GET http://localhost:8080/api/health/simple3`
**本地联调**
```powershell
# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起)
# 1. 启动 Simple3(或登录 MiGu.Server 后自动拉起)
# 2. 启动 MiGu.Server
# 3. 浏览器打开 http://localhost:8080/swagger
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
+66 -66
View File
@@ -6,10 +6,10 @@
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 Simple3 子进程拉起。
- 平台前端:`frontends/apps/simple-platform-vue`Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`
调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。
调度内核 `Simple3` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/Simple3`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `Simple3` 协作。
## 2. 技术栈总览
@@ -21,7 +21,7 @@
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
- 代理:YARP Reverse Proxy。
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`
- 进程编排:`Simple3Launcher` 拉起或复用 `Simple3.exe`
### 前端
@@ -34,9 +34,9 @@
### 外部/相邻系统
- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- `Simple3.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
- Simple3 Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
## 3. 顶层目录职责
@@ -64,33 +64,33 @@ flowchart LR
Server --> Auth["Auth/RBAC/JWT"]
Server --> Config["ConfigStore<br/>data/config-*.json"]
Server --> Wizard["部署向导<br/>deployment profile"]
Server --> Launcher["SimpleLiteLauncher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"]
Server --> Launcher["Simple3Launcher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["Simple3.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["Simple3 :8222"]
Server -->|YARP /vr/*| VR["webVRender :8223"]
Vue -->|iframe 或 /vr 代理| VR
```
关键说明:
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local``--display-mode=web`
- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite
- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 Simple3
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 Simple3 命令行 `--display-mode=web+local``--display-mode=web`
- `Simple3Launcher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 Simple3
- 如果 `8222` 已经有 Simple3 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
## 5. 文档蓝图与当前代码的差异
仓库中存在两类叙述:
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `Simple3`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“Simple3 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
当前代码落地状态:
- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
- 已落地:`MiGu.Server` 主入口、登录拉起 Simple3、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、Simple3 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 Simple3 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
## 6. 后端模块
@@ -101,14 +101,14 @@ flowchart LR
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`
- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、SimpleLiteLauncher。
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、Simple3Launcher。
- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。
- 托管静态资源和 SPA fallback。
核心关系:
- 调用 `JwtIssuer` 颁发/验签 JWT。
- 调用 `InternalTokenStore` 管理转发给 SimpleLite`X-Platform-Internal-Token`
- 调用 `InternalTokenStore` 管理转发给 Simple3`X-Platform-Internal-Token`
- YARP `sl-route` 会自动注入内部 token。
- `app.MapControllers()` 处理平台自身 API`app.MapReverseProxy()` 处理 `/api/sl/*``/vr/*`
@@ -116,7 +116,7 @@ flowchart LR
职责:
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 SimpleLite,签发 JWT 和 Cookie。
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 Simple3,签发 JWT 和 Cookie。
- `POST /api/auth/logout`:清理 Cookie。
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
- `POST /api/auth/switch-scope`:同一用户在 `Platform``RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
@@ -125,7 +125,7 @@ flowchart LR
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
- `JwtIssuer`:签发 JWT。
- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite
- `Simple3Launcher`:登录后拉起或复用 Simple3
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
### 6.3 RBAC 权限模块:`Auth/`
@@ -136,7 +136,7 @@ flowchart LR
- `RbacModels.cs`:用户、角色、DTO。
- `PageCatalog.cs`:页面权限目录。
- `JwtIssuer.cs`JWT 颁发和验签参数。
- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。
- `InternalTokenStore.cs`:平台与 Simple3 内部通信 token。
职责:
@@ -182,32 +182,32 @@ flowchart LR
- 前端 `useConfigStore` 统一读写这些 section。
- 许多配置页只是不同 section 的编辑视图。
- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。
- `deployment` section 还会参与菜单裁剪与 Simple3 插件选择。
注意:
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
### 6.5 SimpleLite 拉起与诊断:`Launcher/`
### 6.5 Simple3 拉起与诊断:`Launcher/`
主要文件:
- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`
- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。
- `Simple3Launcher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `Simple3Options.cs`:绑定 `appsettings.json:Simple3`
- `Simple3BuildSync.cs`:辅助同步/探测 Simple3 构建与 API。
职责:
- 将业务启动模式翻译为 SimpleLite 内部 display mode
- 将业务启动模式翻译为 Simple3 内部 display mode
- `WebOnly` -> `--display-mode=web`
- `DesktopAndWeb` -> `--display-mode=web+local`
- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite
- 拉起前检查 `8222` 是否已有 Simple3,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `Simple3.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 Simple3
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite
- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 Simple3
- `GET /api/health/simple3` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
@@ -217,7 +217,7 @@ flowchart LR
- 二次校验 JWT 的 `ops` claim。
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
- `monitor.note.write` 仅写审计。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 Simple3 反射 API。
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
默认白名单:
@@ -229,9 +229,9 @@ flowchart LR
重要设计:
- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。
- 真实下发路径为 SimpleLite 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
- 真实下发路径为 Simple3 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
### 6.7 投影与 SimpleLite 代理
### 6.7 投影与 Simple3 代理
模块组成:
@@ -249,8 +249,8 @@ flowchart LR
职责:
- `GET /api/health`:平台后端健康检查,返回启动时间、运行时长、端口规划。
- `GET /api/health/simplelite`SimpleLite 路径和端口诊断。
- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。
- `GET /api/health/simple3`Simple3 路径和端口诊断。
- `POST /api/health/simple3/restart-for-update`:授权用户可触发关闭 Simple3、同步最新 DLL 并重新拉起。
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
@@ -258,7 +258,7 @@ flowchart LR
- 首次部署时收集导航方式、功能模块、业务场景。
- 保存后写入 `deployment` 配置 section。
- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 将导航方式映射为 Simple3 场景插件 ID,并调用 `Simple3Launcher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`
对外 API
@@ -273,7 +273,7 @@ flowchart LR
职责:
- 读取 SimpleLite 工作目录下的 `log/` 文件。
- 读取 Simple3 工作目录下的 `log/` 文件。
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
@@ -290,14 +290,14 @@ flowchart LR
职责:
- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。
- 先调用 Simple3 `/projection/map-edit/maps` 获取地图目录。
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
关系:
- 依赖 SimpleLite 与 MiGu.Server 同机部署。
- 依赖 `SimpleLiteOptions.ProjectionPort``InternalTokenStore`
- 依赖 Simple3 与 MiGu.Server 同机部署。
- 依赖 `Simple3Options.ProjectionPort``InternalTokenStore`
## 7. 前端模块
@@ -328,7 +328,7 @@ flowchart LR
职责:
- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。
- 登录页采集用户名、密码、scope 和 Simple3 启动模式。
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
@@ -346,7 +346,7 @@ flowchart LR
- `api/config.ts`:配置中心读写。
- `api/projection.ts`:站点、路径、车辆、任务投影。
- `api/ops.ts`:运维白名单执行和审计。
- `api/reflection.ts`SimpleLite 反射对象、字段、方法执行。
- `api/reflection.ts`Simple3 反射对象、字段、方法执行。
- `api/mapEdit.ts`:地图编辑相关 API。
- `api/wizard.ts`:部署向导。
- `api/logs.ts`:日志管理。
@@ -354,7 +354,7 @@ flowchart LR
关系:
- 所有平台 API 都以 `/api` 为 baseURL。
- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite
- `/api/sl/*` 由 MiGu.Server 反代给 Simple3
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
### 7.4 Shell、主题和权限控制组件
@@ -418,7 +418,7 @@ flowchart LR
- `useHistory`:撤销/重做命令栈。
- `useSelection`:对象选择状态。
- `useAlignment``useBatchGenerate``useClipboard`:编辑辅助能力。
- `mapEditApi``reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。
- `mapEditApi``reflectionApi`:与 Simple3 的地图编辑和反射 API 通信。
#### 地图监控
@@ -431,7 +431,7 @@ flowchart LR
- `VehicleMonitorPanel`:车辆监控台。
- `MissionListPanel`:任务列表。
- `MonitorSelectionPanel`:选中对象详情和动作。
- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。
- `useProjectionStream`:订阅 Simple3/平台 SSE 事件。
### 7.7 运营端页面:`/monitor/*`
@@ -469,13 +469,13 @@ flowchart LR
- 选择导航方式:磁导航、二维码导航、激光导航。
- 选择功能模块:当前包含 WMS、PTL。
- 选择业务场景模板。
- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
- 预览将激活的 Simple3 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
保存后:
- 调用 `PUT /api/wizard/profile`
- 后端写入 `deployment` section。
- 后端写入 SimpleLite `plugins/active-scenes.json`
- 后端写入 Simple3 `plugins/active-scenes.json`
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
## 8. 数据与持久化
@@ -491,9 +491,9 @@ flowchart LR
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
### SimpleLite 侧文件
### Simple3 侧文件
MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
MiGu.Server 会直接或间接使用 Simple3 工作目录:
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
- `log/**/*.log`:日志管理模块读取并分析。
@@ -511,7 +511,7 @@ MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
- `/api/maps/{name}/content`:地图 JSON 预览。
- `/api/health/*`:健康检查。
### 平台后端到 SimpleLite
### 平台后端到 Simple3
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`
@@ -533,7 +533,7 @@ flowchart TB
RBAC["RbacStore / JwtIssuer"]
Config["ConfigStore"]
Wizard["WizardController"]
Launcher["SimpleLiteLauncher"]
Launcher["Simple3Launcher"]
Ops["OpsController"]
Logs["LogsController"]
Maps["MapsContentController"]
@@ -551,8 +551,8 @@ flowchart TB
WizardVue["WizardView.vue"]
end
subgraph SimpleLite["SimpleLite 相邻仓库"]
SLExe["SimpleLite.exe"]
subgraph Simple3["Simple3 相邻仓库"]
SLExe["Simple3.exe"]
Projection["Projection API :8222"]
VRender["webVRender :8223"]
LogsFile["log/**/*.log"]
@@ -591,14 +591,14 @@ flowchart TB
## 11. 典型业务链路
### 11.1 登录并启动 SimpleLite
### 11.1 登录并启动 Simple3
1. 用户打开 `/login`
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`
3. `AuthController``RbacStore.VerifyCredentials()` 校验密码。
4. `AuthController` 检查用户是否可使用请求的 scope。
5. `AuthController``SimpleLiteLauncher.MaybeStart()`
6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`
5. `AuthController``Simple3Launcher.MaybeStart()`
6. `Simple3Launcher` 复用或拉起 `Simple3.exe`
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
@@ -608,7 +608,7 @@ flowchart TB
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 SimpleLite
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 Simple3
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
### 11.3 运营端执行运维动作
@@ -618,7 +618,7 @@ flowchart TB
3. 用户确认后调用 `/api/sl/ops/execute`
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。
6. 已配置映射时调用 Simple3 反射 execute;未配置映射时返回未下发。
7. 审计落盘,前端显示执行结果。
### 11.4 首次部署配置向导
@@ -628,7 +628,7 @@ flowchart TB
3. 前端加载 `/api/wizard/options``/api/wizard/profile`
4. 用户选择导航方式、模块、场景。
5. 保存后端写 `deployment` section。
6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`
6. 后端将导航方式转换为场景插件 ID,并写 Simple3 `plugins/active-scenes.json`
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
## 12. 构建与运行
@@ -645,7 +645,7 @@ dotnet run
- 平台:`http://localhost:8080/login`
- 健康检查:`http://localhost:8080/api/health`
- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite`
- Simple3 诊断:`http://localhost:8080/api/health/simple3`
- Swagger:开发环境 `/swagger`
### 前端开发
@@ -679,7 +679,7 @@ pnpm dev
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simplelite` 的真实数据。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simple3` 的真实数据。
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
@@ -687,8 +687,8 @@ pnpm dev
## 14. 快速索引
- 后端启动:`MiGu.Server/Program.cs`
- 登录和启动 SimpleLite`MiGu.Server/Controllers/AuthController.cs`
- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs`
- 登录和启动 Simple3`MiGu.Server/Controllers/AuthController.cs`
- Simple3 拉起器:`MiGu.Server/Launcher/Simple3Launcher.cs`
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
- 权限 API`MiGu.Server/Controllers/RbacController.cs`
+1 -1
View File
@@ -2,7 +2,7 @@ namespace MiGu.DB.Domains.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史。
/// 以任务 Id 为主键;Simple3/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史。
/// </summary>
public sealed class CdmTaskRecord
{
+12 -12
View File
@@ -3,24 +3,24 @@
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
/// 设计动机:Simple3 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
/// (减少 Simple3 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <b>本机回环</b>127.0.0.1 / ::1Simple3 直接放行(开发机直连不受影响);
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 Simple3 8222 时,YARP transform
/// 会自动追加该 header
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
/// - Simple3 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLE3__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
///
/// Token 来源优先级:
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
/// 文件供本机 Simple3 读取(同机部署常见场景)
/// </summary>
public sealed class InternalTokenStore
{
@@ -39,7 +39,7 @@ public sealed class InternalTokenStore
return;
}
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 Simple3 读取
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
var file = Path.Combine(dataDir, ".internal-token");
@@ -71,12 +71,12 @@ public sealed class InternalTokenStore
PersistedFilePath = file;
logger.LogWarning(
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
"若 MiGu.Server 与 Simple3 不在同一台机器,请把同一个值写入 Simple3 端 simple.json:platform.internalToken。",
file);
}
catch (Exception ex)
{
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
logger.LogError(ex, "internal token 落盘失败 —— 远程 Simple3 调用将无法通过鉴权(每次重启 token 都不同)。");
}
}
}
+124 -18
View File
@@ -31,8 +31,12 @@ public static class PageCatalog
new("admin-dashboard", "总览", "概览", ScopePlatform),
new("admin-setup", "初始配置", "概览", ScopePlatform),
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
new("admin-playback", "调度回放", "概览", ScopePlatform),
new("admin-tasks", "任务管理", "概览", ScopePlatform),
new("admin-alarms", "报警管理", "概览", ScopePlatform),
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
new("admin-notes", "运维备注", "概览", ScopePlatform),
new("admin-logs", "日志管理", "概览", ScopePlatform),
// ── 管理端 / Platform:设计与编排 ──
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
@@ -50,20 +54,18 @@ public static class PageCatalog
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
// ── 运营端 / RCSMonitor ──
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
};
private static readonly HashSet<string> _keys =
@@ -76,21 +78,14 @@ public static class PageCatalog
["admin-config-vehicle"] = "admin-vehicle-hub",
["admin-config-fleet"] = "admin-vehicle-hub",
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
["admin-config-routing"] = "admin-config-strategy",
["admin-config-task"] = "admin-config-strategy",
["admin-config-traffic"] = "admin-config-strategy",
["admin-config-charge"] = "admin-config-strategy",
["admin-config-device"] = "admin-config-facility",
["admin-config-location"] = "admin-config-warehouse",
["admin-config-integrations"] = "admin-config-business",
["admin-config-scenario"] = "admin-config-business",
["admin-config-widget"] = "admin-config-business",
["admin-playback"] = "admin-config-ops-center",
["admin-config-ops"] = "admin-config-ops-center",
["admin-config-logs"] = "admin-config-ops-center",
["admin-config-logs"] = "admin-logs",
["admin-config-map-monitor"] = "admin-config-ops-center",
["admin-config-system"] = "admin-config-system-center",
["admin-config-auth"] = "admin-config-system-center",
["admin-config-system-center"] = "admin-config-system",
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
["monitor-dashboard"] = "monitor-map",
["admin-data-center-stations"] = "admin-data-center",
["admin-data-center-docks"] = "admin-data-center",
["admin-data-center-handshake"] = "admin-data-center",
@@ -98,6 +93,64 @@ public static class PageCatalog
["admin-data-center-mag-control"] = "admin-data-center",
};
/// <summary>
/// 管理端页面 → 运营端对应页面。给运营角色勾页时,只能从「当前管理员已有的管理端页」映射出来。
/// 无对应运营页的管理端入口(如任务/报警)不出现在映射里。
/// </summary>
public static readonly IReadOnlyDictionary<string, string> PlatformToMonitor =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["admin-map-monitor"] = "monitor-map",
["admin-vehicle-hub"] = "monitor-vehicle-hub",
["admin-ops-log"] = "monitor-ops",
["admin-notes"] = "monitor-notes",
};
/// <summary>仅运营端有的页面:不依赖管理端对应页,始终可授给运营角色。</summary>
public static readonly IReadOnlyList<string> MonitorOnlyPages =
new[] { "monitor-ops", "monitor-notes" };
/// <summary>
/// 把角色里勾选的页面展开到指定登录域:勾了运营监控的「运维记录」时,
/// 管理账号/通用角色进管理壳也能拿到对应的 admin-ops-log。
/// </summary>
public static HashSet<string> ExpandKeysForScope(IEnumerable<string> keys, string scope)
{
var scopeKeys = KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in keys ?? Array.Empty<string>())
{
if (string.Equals(raw, Wildcard, StringComparison.OrdinalIgnoreCase))
{
foreach (var k in scopeKeys) set.Add(k);
return set;
}
var key = NormalizeKey(raw);
if (scopeKeys.Contains(key)) set.Add(key);
var counterpart = CounterpartForScope(key, scope);
if (counterpart != null && scopeKeys.Contains(counterpart)) set.Add(counterpart);
}
return set;
}
/// <summary>管理页 ↔ 运营页互认:monitor-ops → admin-ops-log(当前域是 Platform 时)。</summary>
public static string? CounterpartForScope(string key, string targetScope)
{
if (string.Equals(targetScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
{
foreach (var kv in PlatformToMonitor)
{
if (string.Equals(kv.Value, key, StringComparison.OrdinalIgnoreCase))
return kv.Key;
}
return null;
}
if (string.Equals(targetScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase)
&& PlatformToMonitor.TryGetValue(key, out var monitorKey))
return monitorKey;
return null;
}
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
public static bool IsValidKey(string key) => _keys.Contains(key);
@@ -110,4 +163,57 @@ public static class PageCatalog
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Key)
.ToList();
/// <summary>当前管理员在管理端拥有的页面 → 可授给运营端角色的页面集合。</summary>
public static HashSet<string> GrantableMonitorPages(IEnumerable<string>? actorPlatformPages)
{
var set = new HashSet<string>(MonitorOnlyPages, StringComparer.OrdinalIgnoreCase);
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase)
|| HasAllKeys(list, KeysForScope(ScopePlatform)))
{
foreach (var k in KeysForScope(ScopeMonitor)) set.Add(k);
return set;
}
foreach (var p in list)
{
var key = NormalizeKey(p);
if (PlatformToMonitor.TryGetValue(key, out var monitorKey) && IsValidKey(monitorKey))
set.Add(monitorKey);
}
return set;
}
/// <summary>当前管理员可授给管理端角色的页面(不能超权:只能授自己已有的)。</summary>
public static HashSet<string> GrantablePlatformPages(IEnumerable<string>? actorPlatformPages)
{
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
var all = KeysForScope(ScopePlatform);
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || HasAllKeys(list, all))
return all.ToHashSet(StringComparer.OrdinalIgnoreCase);
return list
.Select(NormalizeKey)
.Where(k => IsValidKey(k) && all.Contains(k, StringComparer.OrdinalIgnoreCase))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
/// <summary>按角色归属域,算出当前管理员可勾选的页面 Key。</summary>
public static HashSet<string> GrantablePagesForRoleScope(
string roleScope, IEnumerable<string>? actorPlatformPages)
{
if (string.Equals(roleScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase))
return GrantableMonitorPages(actorPlatformPages);
if (string.Equals(roleScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
return GrantablePlatformPages(actorPlatformPages);
// 通用域:管理端可授 + 运营端可授
var set = GrantablePlatformPages(actorPlatformPages);
set.UnionWith(GrantableMonitorPages(actorPlatformPages));
return set;
}
private static bool HasAllKeys(IEnumerable<string> have, IEnumerable<string> all)
{
var set = have.ToHashSet(StringComparer.OrdinalIgnoreCase);
return all.All(k => set.Contains(k));
}
}
+65 -9
View File
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
///
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
/// admin(超级管理员,scope=*,全部页面 / 操作)
/// ops (运营人员,scope=RCSMonitor,运营页 + 运维操作码)
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
/// </summary>
public sealed class RbacStore
@@ -58,9 +58,14 @@ public sealed class RbacStore
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
if (snap is { Users.Count: > 0 })
{
_snapshot = Normalize(snap);
_snapshot = Normalize(snap, out var migrated);
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
if (migrated)
{
Persist();
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
}
return;
}
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
@@ -82,21 +87,30 @@ public sealed class RbacStore
}
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
private static RbacSnapshot Normalize(RbacSnapshot snap)
/// <param name="migrated">任一角色页面 Key 被别名替换 / 丢弃 / 补齐时为 true,调用方应落盘。</param>
private static RbacSnapshot Normalize(RbacSnapshot snap, out bool migrated)
{
migrated = false;
snap.Roles ??= new();
snap.Users ??= new();
foreach (var r in snap.Roles)
{
r.Pages = (r.Pages ?? new())
var before = r.Pages ?? new();
var hadSystemHub = before.Any(p =>
string.Equals(p, "admin-config-system-center", StringComparison.OrdinalIgnoreCase));
var after = before
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (!SamePages(before, after)) migrated = true;
r.Pages = after;
r.Ops = (r.Ops ?? new()).Distinct().ToList();
r.WidgetGrants ??= new();
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
BackfillKnownPageMigrations(r);
var countBeforeBackfill = r.Pages.Count;
BackfillKnownPageMigrations(r, hadSystemHub);
if (r.Pages.Count != countBeforeBackfill) migrated = true;
}
foreach (var u in snap.Users)
{
@@ -105,7 +119,12 @@ public sealed class RbacStore
return snap;
}
private static void BackfillKnownPageMigrations(RbacRole r)
private static bool SamePages(IReadOnlyList<string> a, IReadOnlyList<string> b) =>
a.Count == b.Count
&& !a.Except(b, StringComparer.OrdinalIgnoreCase).Any()
&& !b.Except(a, StringComparer.OrdinalIgnoreCase).Any();
private static void BackfillKnownPageMigrations(RbacRole r, bool hadSystemHub)
{
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& r.Scope != PageCatalog.Wildcard) return;
@@ -132,6 +151,25 @@ public sealed class RbacStore
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-setup");
// 日志管理、调度回放从监控配置聚合页拆出:原先有聚合页权限的角色补上新入口。
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& r.Pages.Contains("admin-config-ops-center", StringComparer.OrdinalIgnoreCase))
{
if (!r.Pages.Contains("admin-logs", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-logs");
if (!r.Pages.Contains("admin-playback", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-playback");
}
// 「系统与权限」拆成系统配置 / 权限与角色。
if (!r.Pages.Contains(PageCatalog.Wildcard) && hadSystemHub)
{
if (!r.Pages.Contains("admin-config-system", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-system");
if (!r.Pages.Contains("admin-config-auth", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-auth");
}
}
private RbacSnapshot SeedDefault(IConfiguration config)
@@ -159,7 +197,7 @@ public sealed class RbacStore
{
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
Scope = PageCatalog.ScopeMonitor,
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Pages = new() { "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Ops = new()
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
@@ -250,6 +288,20 @@ public sealed class RbacStore
public bool CanUseScope(RbacUser user, string scope) =>
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 登录按账号角色自动选域:能进管理端则进管理端,否则进运营权限页。
/// 不再接受客户端挑选「运营端 / 管理端」。
/// </summary>
public string? ResolveLoginScope(RbacUser user)
{
var scopes = UsableScopes(user);
if (scopes.Contains(PageCatalog.ScopePlatform, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopePlatform;
if (scopes.Contains(PageCatalog.ScopeMonitor, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopeMonitor;
return null;
}
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
public List<string> RoleNamesOf(RbacUser user)
{
@@ -276,7 +328,7 @@ public sealed class RbacStore
foreach (var r in roles)
{
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
else pages.UnionWith(PageCatalog.ExpandKeysForScope(r.Pages, scope));
foreach (var o in r.Ops)
{
@@ -486,7 +538,11 @@ public sealed class RbacStore
{
if (pages is null) return new();
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
return pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeScope(string? scope) => scope switch
+53 -1
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <summary>
/// 配置中心存储(内存 + JSON 文件持久化占位)。
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3
/// </summary>
public sealed class ConfigStore
{
@@ -99,6 +99,58 @@ public sealed class ConfigStore
return Put("deployment", el);
}
public LogCleanupOptions GetLogCleanup()
{
var env = Get("system");
return env.Payload switch
{
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
JsonElement el => ParseLogCleanup(el),
_ => LogCleanupOptions.CreateDefaults()
};
}
public Envelope PutLogCleanup(LogCleanupOptions options)
{
var opt = (options ?? new LogCleanupOptions()).Clamp();
Dictionary<string, JsonElement> dict;
try
{
var current = Get("system").Payload;
var el = current is JsonElement je
? je
: JsonSerializer.SerializeToElement(current, _jsonOpts);
dict = el.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
: new Dictionary<string, JsonElement>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
dict = new Dictionary<string, JsonElement>();
}
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
return Put("system", merged);
}
private LogCleanupOptions ParseLogCleanup(JsonElement el)
{
try
{
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
return LogCleanupOptions.CreateDefaults();
}
}
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
{
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
+3 -3
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <para>它是「按选型裁剪」的单一事实来源:</para>
/// <list type="number">
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 Simple3 场景 id
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
/// </list>
@@ -35,7 +35,7 @@ public record DeploymentProfile(
UpdatedBy: "");
/// <summary>
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 导航方式 → Simple3 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
@@ -49,7 +49,7 @@ public record DeploymentProfile(
};
/// <summary>
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 把已选 <see cref="NavigationKinds"/> 映射为 Simple3 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
/// </summary>
public IReadOnlyList<string> ToActiveSceneIds()
+39
View File
@@ -0,0 +1,39 @@
namespace MiGu.Server.Configs;
/// <summary>
/// 日志清理与磁盘告警(system.logCleanup)。字段对齐 Simple3 <c>LogCleanupOptions</c>。
/// </summary>
public sealed class LogCleanupOptions
{
/// <summary>是否启用后台自动清理。关闭后仅「立即清理」可手动触发。默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>保留天数:删除 log/ 下 LastWriteTime 早于「现在 N 天」的 *.log。默认 30,最小 1。</summary>
public int RetentionDays { get; set; } = 30;
/// <summary>后台清理间隔(小时)。默认 24;&lt;= 0 归一为 1。</summary>
public int CheckIntervalHours { get; set; } = 24;
/// <summary>启动时先执行一次清理。默认 true。</summary>
public bool RunOnStartup { get; set; } = true;
/// <summary>是否启用磁盘剩余空间不足告警。默认 true。</summary>
public bool DiskAlertEnabled { get; set; } = true;
/// <summary>日志所在盘剩余低于该值(GB)时告警。默认 5。</summary>
public double DiskFreeAlertGB { get; set; } = 5;
/// <summary>磁盘检测间隔(分钟)。默认 30;&lt;= 0 归一为 30。</summary>
public int DiskCheckIntervalMinutes { get; set; } = 30;
public static LogCleanupOptions CreateDefaults() => new();
public LogCleanupOptions Clamp()
{
RetentionDays = Math.Clamp(RetentionDays, 1, 3650);
CheckIntervalHours = Math.Max(1, CheckIntervalHours);
if (DiskFreeAlertGB <= 0) DiskFreeAlertGB = 5;
DiskCheckIntervalMinutes = DiskCheckIntervalMinutes <= 0 ? 30 : DiskCheckIntervalMinutes;
return this;
}
}
+6 -3
View File
@@ -103,12 +103,15 @@ public sealed class OpsAuditStore
}
}
/// <summary>最近的审计(倒序,最新在前)。</summary>
public IReadOnlyList<AuditEntry> Recent()
/// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
public IReadOnlyList<AuditEntry> Recent(string? user = null)
{
lock (_gate)
{
var copy = new List<AuditEntry>(_entries);
IEnumerable<AuditEntry> src = _entries;
if (!string.IsNullOrWhiteSpace(user))
src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase));
var copy = src.ToList();
copy.Reverse();
return copy;
}
+4 -8
View File
@@ -1,12 +1,8 @@
namespace MiGu.Server.Configs;
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
public record SystemConfig
{
public static SystemConfig Default() => new(
DispatchLoopHz: 50,
Log: new LogPolicy("info", 7, 256),
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
public LogCleanupOptions LogCleanup { get; init; } = new();
public static SystemConfig Default() => new();
}
+46 -34
View File
@@ -12,19 +12,17 @@ namespace MiGu.Server.Controllers;
public class AuthController : ControllerBase
{
/// <summary>
/// 登录请求体。
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
/// 登录请求体。可选 <see cref="LaunchMode"/>Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
public record LoginRequest(string Username, string Password, string? Scope = null, string? LaunchMode = null);
/// <summary>
/// 登录响应。
/// 会话 N+1 增量字段:
/// <list type="bullet">
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="Simple3Launcher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
@@ -51,17 +49,18 @@ public class AuthController : ControllerBase
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
bool NeedsWizard = false);
bool NeedsWizard = false,
string? Token = null);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ConfigStore _config;
private readonly ILogger<AuthController> _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger<AuthController> log)
{
_rbac = rbac;
_jwt = jwt;
@@ -79,41 +78,40 @@ public class AuthController : ControllerBase
{
if (string.IsNullOrWhiteSpace(req.Username))
return BadRequest(new { message = "用户名不能为空" });
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
var user = _rbac.VerifyCredentials(req.Username, req.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
// scope 必须落在该账号角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」
var scope = _rbac.ResolveLoginScope(user);
if (scope == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
// 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。
var launchMode = NormalizeLaunchMode(req.LaunchMode);
SimpleLiteLauncher.LaunchResult? launchResult = null;
Simple3Launcher.LaunchResult? launchResult = null;
try
{
// M1waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
// M1waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
_log.LogInformation("Simple3 launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
}
catch (Exception ex)
{
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
_log.LogError(ex, "Simple3 launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
var (perm, roleNames, token) = BuildSession(user, req.Scope);
var (perm, roleNames, token) = BuildSession(user, scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
return Ok(new LoginResponse(token, dto, scope, runMode, perm,
LaunchStatus: launchResult?.Status,
LaunchWarning: launchResult?.Warning,
NeedsWizard: NeedsWizard()));
@@ -144,13 +142,20 @@ public class AuthController : ControllerBase
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
var (perm, roleNames, token) = BuildSession(user, preferred);
string? rotated = null;
if (!string.Equals(preferred, scope, StringComparison.OrdinalIgnoreCase))
{
SetAuthCookie(token);
rotated = token;
}
var (perm, roleNames, _) = BuildSession(user, scope);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new MeResponse(dto, scope, InferRunMode(), perm, NeedsWizard()));
return Ok(new MeResponse(dto, preferred, InferRunMode(), perm, NeedsWizard(), rotated));
}
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
@@ -172,6 +177,11 @@ public class AuthController : ControllerBase
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 有管理权限的账号固定走管理入口,不再切到运营壳;请用运营权限账号登录查看运营页。
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == PageCatalog.ScopePlatform && req.Scope == PageCatalog.ScopeMonitor)
return StatusCode(403, new { message = "请使用运营权限账号查看运营页面" });
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
@@ -200,22 +210,23 @@ public class AuthController : ControllerBase
return (perm, roleNames, token);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
private string InferRunMode()
{
var last = _launcher.LastLaunchMode;
if (string.IsNullOrEmpty(last)) return "Detached";
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
if (last == Simple3Launcher.ExternalReuseLaunchMode) return "WebOnly";
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode)
{
if (result is not { Started: true })
return "Detached";
// Simple3 无本地端:复用既有实例也按 WebOnly 展示。
if (result.Value.Status == "ReusingExisting")
return "WebEnabled";
return "WebOnly";
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
{
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
@@ -230,8 +241,9 @@ public class AuthController : ControllerBase
{
return raw?.Trim().ToLowerInvariant() switch
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
"desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb",
// Simple3 默认仅 Web;未传 / 未知也走 WebOnly
_ => "WebOnly",
};
}
+6 -1
View File
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
private readonly LogCleanupService _cleanup;
public ConfigController(ConfigStore store)
public ConfigController(ConfigStore store, LogCleanupService cleanup)
{
_store = store;
_cleanup = cleanup;
}
[HttpGet]
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
}
var env = _store.Put(section, payload);
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
_cleanup.ApplySchedule();
return Ok(new
{
section = env.Section,
+2 -2
View File
@@ -31,7 +31,7 @@ public sealed class FleetController : ControllerBase
/// <summary>
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// Simple3 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// </summary>
[HttpGet("tasks")]
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
@@ -76,7 +76,7 @@ public sealed class FleetController : ControllerBase
}
/// <summary>
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;Simple3 离线时回退最近记录,
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
/// </summary>
[HttpGet("alarms")]
+16 -16
View File
@@ -9,9 +9,9 @@ namespace MiGu.Server.Controllers;
public class HealthController : ControllerBase
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
public HealthController(Simple3Launcher launcher) => _launcher = launcher;
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
[HttpGet]
@@ -26,28 +26,28 @@ public class HealthController : ControllerBase
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// Simple3 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>Simple3</c> 节点。
/// 含服务器本地路径等敏感信息,要求登录。
/// </summary>
[HttpGet("simplelite")]
[HttpGet("simple3")]
[Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
public IActionResult GetSimple3Diagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/stop")]
/// <summary>关闭本机全部 Simple3 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/stop")]
[Authorize(Policy = "PlatformScope")]
public IActionResult StopSimpleLite()
public IActionResult StopSimple3()
{
var killed = _launcher.StopAll();
var diag = _launcher.GetDiagnostics();
return Ok(new { killed, diagnostics = diag });
}
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/restart")]
/// <summary>关闭并重新拉起 Simple3(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/restart")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3([FromQuery] string launchMode = "webonly")
{
var result = _launcher.Restart(launchMode);
var diag = _launcher.GetDiagnostics();
@@ -55,12 +55,12 @@ public class HealthController : ControllerBase
}
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
/// 关闭 Simple3、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 Simple3 进程并重启,仅 Platform 管理端可调。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[HttpPost("simple3/restart-for-update")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3ForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
+60 -12
View File
@@ -2,18 +2,20 @@ using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// 平台「日志管理」后端:把 Simple3 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// <c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
private static readonly Regex NumericFieldRegex =
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
private readonly LogCleanupService _cleanup;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
public LogsController(
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogsController> log,
LogCleanupService cleanup)
{
_launcher = launcher;
_config = config;
_log = log;
_cleanup = cleanup;
}
// ─────────────────────────────────────────────────────────── 概览 ──
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
{
var (root, workdir, error) = ResolveLogRoot();
if (root == null)
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
if (!Directory.Exists(root))
return Ok(new
{
exists = false, root, workingDirectory = workdir,
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
disk = DiskDto()
});
var files = EnumerateLogFiles(root);
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
totalFiles = files.Count,
totalBytes = files.Sum(f => f.Bytes),
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
days
days,
disk = DiskDto()
});
}
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
[HttpGet("cleanup-config")]
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
[HttpPut("cleanup-config")]
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
[HttpPost("cleanup-now")]
public IActionResult CleanupNow()
{
var r = _cleanup.CleanNow();
if (r.Error != null)
return Problem(r.Error, statusCode: 500);
return Ok(new
{
deletedFiles = r.DeletedFiles,
freedBytes = r.FreedBytes,
freedMB = Math.Round(r.FreedMB, 1)
});
}
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
root = normRoot, exists = false, path = "", parent = (string?)null,
dirCount = 0, fileCount = 0,
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
});
var target = SafeResolveDir(normRoot, path);
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
return PhysicalFile(full, "application/octet-stream", downloadName);
}
private object DiskDto()
{
var d = _cleanup.GetDiskStatus();
return new
{
known = d.Known,
drive = d.Drive,
freeGB = Math.Round(d.FreeGB, 1),
alertEnabled = d.AlertEnabled,
alertGB = d.AlertGB,
belowThreshold = d.BelowThreshold
};
}
// ─────────────────────────────────────────────────────── helpers ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null,
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory" +
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory" +
"或显式设置 Logs:Root 指向日志根目录。");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
@@ -8,8 +8,8 @@ using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
/// 读取 Simple3 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 Simple3 拉取 maps 列表拿到 directory,再读本机同路径文件(与 Simple3 同机部署)。
/// </summary>
[ApiController]
[Authorize]
@@ -18,13 +18,13 @@ public class MapsContentController : ControllerBase
{
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<MapsContentController> _log;
public MapsContentController(
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
ILogger<MapsContentController> log)
{
_httpFactory = httpFactory;
@@ -47,7 +47,7 @@ public class MapsContentController : ControllerBase
{
var directory = await FetchMapsDirectoryAsync(ct);
if (string.IsNullOrWhiteSpace(directory))
return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" });
return StatusCode(503, new { message = "无法从 Simple3 获取地图目录" });
var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
@@ -86,7 +86,7 @@ public class MapsContentController : ControllerBase
using var resp = await client.SendAsync(msg, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"SimpleLite maps 列表返回 {(int)resp.StatusCode}");
throw new InvalidOperationException($"Simple3 maps 列表返回 {(int)resp.StatusCode}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
+262 -44
View File
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发
/// 关于映射:
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public const string CarExecuteOp = "ops.car.execute";
public record ExecuteRequest(
string OpCode,
string TargetId,
string? Reason,
string? IdempotencyKey,
string? Method,
Dictionary<string, string>? Params,
int? SiteId);
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
{
"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.car.manualCharge", CarExecuteOp,
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
{
"Goto", "GotoSite", "WebGotoSite"
};
/// <summary>
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
/// </summary>
private static readonly HashSet<string> MapMonitorQuickMethods = new(StringComparer.Ordinal)
{
"OnlineCar", "OfflineCar", "DisableCar", "EnableCar",
"ForceStopUI", "ForceStop", "UIIntercept"
};
private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
IConfiguration config,
ILogger<OpsController> log)
{
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
// AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。
var opsClaim = User.FindFirst("ops")?.Value ?? "";
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var user = CurrentUsername();
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason));
if (req.OpCode == CarExecuteOp)
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
string result;
string? message;
try
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
// 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(user))
msg.Headers.TryAddWithoutValidation("X-Platform-User", user);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
result = success ? "ok" : "failed";
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}{ExtractMessage(body)}";
}
catch (Exception ex)
{
result = "failed";
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
}
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
var forwarded = await ForwardExecuteAsync(map.Kind, numericId.Value, map.Method, null, user);
return Ok(Done(user, scope, req, forwarded.Result, forwarded.Message, ok: forwarded.Ok));
}
/// <summary>
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
/// </summary>
private async Task<ExecuteResponse> ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req)
{
var method = (req.Method ?? "").Trim();
if (!IsSafeMethodName(method))
return Done(user, scope, req, "failed", "方法名无效", ok: false);
var numericId = ExtractNumericId(req.TargetId);
if (numericId is null)
return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false);
var allowed = await LoadAllowedCarMethodsAsync(numericId.Value);
if (allowed is null)
return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config", ok: false);
allowed.UnionWith(MapMonitorQuickMethods);
if (allowed.Count == 0)
return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false);
if (!allowed.Contains(method))
return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false);
var siteId = req.SiteId;
if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite)
&& int.TryParse(rawSite, out var parsedSite))
siteId = parsedSite;
if (siteId is int sid && GotoMethods.Contains(method))
{
var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user);
if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase))
return Done(user, scope, req, gotoResult.Result,
CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok),
ok: gotoResult.Ok);
}
var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user);
return Done(user, scope, req, forwarded.Result,
CarAuditMessage(method, forwarded.Message, forwarded.Ok),
ok: forwarded.Ok);
}
private static string CarAuditMessage(string method, string? detail, bool ok)
{
if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail;
return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}{detail}";
}
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
@@ -163,6 +211,14 @@ public class OpsController : ControllerBase
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
private string CurrentUsername() =>
User.FindFirst("unique_name")?.Value
?? User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous";
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
@@ -195,4 +251,166 @@ public class OpsController : ControllerBase
catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…";
}
private static bool CanRunOp(string[] userOps, string opCode, string? scope)
{
if (userOps.Contains("*") || userOps.Contains(opCode)) return true;
if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal)))
return true;
// 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。
return opCode == CarExecuteOp
&& string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase);
}
private static bool IsSafeMethodName(string method) =>
method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');
private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message);
private async Task<HashSet<string>?> LoadAllowedCarMethodsAsync(int carId)
{
try
{
var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}");
var config = await GetLiteJsonAsync("/projection/reflection/monitor-config");
if (bundle is null || config is null) return null;
var typeName = ReadString(bundle, "typeName") ?? "";
var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName;
var map = ReadCarActionByType(config.Value);
return LookupCarMethods(map, fullTypeName, typeName);
}
catch (Exception ex)
{
_log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId);
return null;
}
}
private static Dictionary<string, string[]> ReadCarActionByType(JsonElement envelope)
{
var map = new Dictionary<string, string[]>(StringComparer.Ordinal);
if (!TryData(envelope, out var data)) return map;
if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object)
return map;
if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object)
return map;
foreach (var prop in cat.EnumerateObject())
{
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
map[prop.Name] = prop.Value.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Where(x => x.Length > 0)
.ToArray();
}
return map;
}
private static HashSet<string> LookupCarMethods(Dictionary<string, string[]> map, string fullType, string shortType)
{
var shortName = shortType;
var dot = shortType.LastIndexOf('.');
if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..];
if (map.TryGetValue(fullType, out var exact) && exact.Length > 0)
return new HashSet<string>(exact, StringComparer.Ordinal);
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
return new HashSet<string>(byType, StringComparer.Ordinal);
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
return new HashSet<string>(byShort, StringComparer.Ordinal);
foreach (var kv in map)
{
if (kv.Value.Length == 0) continue;
var k = kv.Key;
if (k == fullType || k == shortType || k == shortName) return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
}
return new HashSet<string>(StringComparer.Ordinal);
}
private static bool TryData(JsonElement envelope, out JsonElement data)
{
if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object)
return true;
data = envelope;
return envelope.ValueKind == JsonValueKind.Object;
}
private static string? ReadString(JsonElement? envelope, string name)
{
if (envelope is null) return null;
var root = envelope.Value;
if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String)
return direct.GetString();
return null;
}
private async Task<JsonElement?> GetLiteJsonAsync(string path)
{
var call = await CallLiteAsync(HttpMethod.Get, path, actor: null);
if (!call.Ok) return null;
using var doc = JsonDocument.Parse(call.Body);
return doc.RootElement.Clone();
}
private async Task<ForwardOutcome> ForwardGotoSiteAsync(int carId, int siteId, string actor)
{
var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}";
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> ForwardExecuteAsync(
string kind, int id, string method, Dictionary<string, string>? query, string actor)
{
var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}";
if (query is { Count: > 0 })
{
var qs = string.Join("&", query
.Where(kv => !string.IsNullOrEmpty(kv.Key))
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}"));
if (qs.Length > 0) path += "?" + qs;
}
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> SendLiteExecuteAsync(string path, string actor)
{
try
{
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
var success = call.Ok && ParseSuccess(call.Body);
return success
? new ForwardOutcome(true, "ok", null)
: new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}{ExtractMessage(call.Body)}");
}
catch (Exception ex)
{
_log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path);
return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}");
}
}
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(HttpMethod method, string path, string? actor)
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(method, url);
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
if (method == HttpMethod.Post)
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(actor))
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
}
}
@@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Mvc;
namespace MiGu.Server.Controllers;
/// <summary>
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
/// 投影 API 占位:真实落地时由 YARP 反代到 Simple3 WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 Simple3 运行时的开发联调。
///
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
/// </summary>
+71 -12
View File
@@ -29,6 +29,7 @@ public class RbacController : ControllerBase
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
@@ -61,18 +62,35 @@ public class RbacController : ControllerBase
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog() => Ok(new
public IActionResult Catalog()
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
var actorPages = ActorPlatformPages();
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
return Ok(new
{
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
}
});
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
{
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
},
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
{
[PageCatalog.ScopePlatform] = grantablePlatform,
[PageCatalog.ScopeMonitor] = grantableMonitor,
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
},
platformToMonitor = PageCatalog.PlatformToMonitor
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
.ToList(),
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
});
}
// ───────────────────────── 角色 ─────────────────────────
@@ -80,10 +98,12 @@ public class RbacController : ControllerBase
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.CreateRole(ClampPages(req))));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.UpdateRole(id, ClampPages(req))));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
@@ -136,4 +156,43 @@ public class RbacController : ControllerBase
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
private List<string> ActorPlatformPages()
{
var id = CurrentUserId();
if (string.IsNullOrEmpty(id)) return new();
var user = _store.FindUserById(id);
if (user is null) return new();
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
}
/// <summary>
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
/// </summary>
private SaveRoleRequest ClampPages(SaveRoleRequest req)
{
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
var pages = req.Pages ?? new List<string>();
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
{
var scopeKeys = scope == PageCatalog.Wildcard
? PageCatalog.All.Select(p => p.Key).ToList()
: PageCatalog.KeysForScope(scope).ToList();
if (scopeKeys.All(k => grantable.Contains(k)))
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
return req with
{
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
};
}
var clamped = pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return req with { Pages = clamped };
}
}
@@ -9,7 +9,7 @@ namespace MiGu.Server.Controllers;
/// <summary>
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
/// 文件落在 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不依赖 SimpleLite 进程是否在跑。
/// 文件落在 Simple3 工作目录 <c>Config/Signal/*.json</c>,不依赖 Simple3 进程是否在跑。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
@@ -10,7 +10,7 @@ namespace MiGu.Server.Controllers;
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
///
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// 前端主流程为「刷新」读库、「默认」从 Simple3 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// </summary>
[ApiController]
[Authorize]
+5 -5
View File
@@ -17,7 +17,7 @@ namespace MiGu.Server.Controllers;
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
/// 驱动 Simple3 下次启动选择性加载选定的导航场景插件。
/// </summary>
[ApiController]
[Authorize]
@@ -25,10 +25,10 @@ namespace MiGu.Server.Controllers;
public class WizardController : ControllerBase
{
private readonly ConfigStore _store;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ILogger<WizardController> _log;
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
public WizardController(ConfigStore store, Simple3Launcher launcher, ILogger<WizardController> log)
{
_store = store;
_launcher = launcher;
@@ -81,7 +81,7 @@ public class WizardController : ControllerBase
_store.PutDeployment(profile);
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 平台 → 内核联动:把导航选型写入 Simple3 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signalPLC 握手)。
@@ -107,7 +107,7 @@ public class WizardController : ControllerBase
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
/// </summary>
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
private static object Project(DeploymentProfile dp, Simple3Launcher.ActiveScenesWriteResult? write = null) => new
{
configured = dp.Configured,
platformType = dp.PlatformType,
@@ -17,10 +17,12 @@ public static class DashboardShortcutCatalog
["platform-config"] = "admin-map-editor",
["mission"] = "admin-task-templates",
["cars"] = "admin-cars",
["auth"] = "admin-config-system-center",
["system"] = "admin-config-system-center",
["auth"] = "admin-config-auth",
["system"] = "admin-config-system",
["admin-config-system-center"] = "admin-config-auth",
["ops"] = "admin-config-ops-center",
["tasks"] = "admin-config-strategy",
["tasks"] = "admin-task-templates",
["admin-config-strategy"] = "admin-task-templates",
};
private static readonly ShortcutDef[] PlatformShortcuts =
@@ -28,6 +30,10 @@ public static class DashboardShortcutCatalog
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
new("admin-playback", "admin-playback", PageCatalog.ScopePlatform),
new("admin-ops-log", "admin-ops-log", PageCatalog.ScopePlatform),
new("admin-notes", "admin-notes", PageCatalog.ScopePlatform),
new("admin-logs", "admin-logs", PageCatalog.ScopePlatform),
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
@@ -38,17 +44,17 @@ public static class DashboardShortcutCatalog
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
new("admin-wcs-template-proto", "admin-wcs-template-proto", PageCatalog.ScopePlatform),
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform),
new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform),
new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform),
new("admin-config-system", "admin-config-system", PageCatalog.ScopePlatform),
new("admin-config-auth", "admin-config-auth", PageCatalog.ScopePlatform),
];
private static readonly ShortcutDef[] MonitorShortcuts =
[
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
@@ -80,16 +86,16 @@ public static class DashboardShortcutCatalog
"admin-map-monitor",
"admin-vehicle-hub",
"admin-task-templates",
"admin-config-system-center",
"admin-config-auth",
"admin-config-ops-center",
"admin-config-strategy"
"admin-config-facility"
];
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
private static readonly HashSet<string> ExcludedKeys =
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard" };
public static bool IsValidKey(string key) =>
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
+4 -4
View File
@@ -8,14 +8,14 @@ using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// 单例:轮询 Simple3 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;Simple3 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -28,7 +28,7 @@ public sealed class AlarmCollector
public AlarmCollector(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<AlarmCollector> log)
{
+4 -4
View File
@@ -32,14 +32,14 @@ public sealed class CdmTaskDto
}
/// <summary>
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// 单例:从 Simple3 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「Simple3 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// </summary>
public sealed class CdmTaskSyncer
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<CdmTaskSyncer> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -53,7 +53,7 @@ public sealed class CdmTaskSyncer
public CdmTaskSyncer(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<CdmTaskSyncer> log)
{
+1 -1
View File
@@ -1,6 +1,6 @@
namespace MiGu.Server.Fleet;
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
/// <summary>与 Simple3 GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
public sealed class FleetHealthRowDto
{
public int CarId { get; set; }
+9 -9
View File
@@ -7,13 +7,13 @@ using MiGu.Server.Ota;
namespace MiGu.Server.Fleet;
/// <summary>
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// </summary>
public sealed class FleetHealthService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly WatchDogClient _wd;
private readonly ILogger<FleetHealthService> _log;
@@ -25,7 +25,7 @@ public sealed class FleetHealthService
public FleetHealthService(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
WatchDogClient wd,
ILogger<FleetHealthService> log)
@@ -39,7 +39,7 @@ public sealed class FleetHealthService
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
{
var rows = await FetchSimpleLiteHealthAsync(ct);
var rows = await FetchSimple3HealthAsync(ct);
if (rows.Count == 0)
rows = await BuildRowsFromCarsAsync(ct);
@@ -50,7 +50,7 @@ public sealed class FleetHealthService
{
if (string.IsNullOrWhiteSpace(row.Ip))
{
// 无 IP 时保留 SimpleLite 原探测结果
// 无 IP 时保留 Simple3 原探测结果
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
return;
}
@@ -75,7 +75,7 @@ public sealed class FleetHealthService
}
else
{
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
// WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
{
row.LatencySource = "onboard";
@@ -93,7 +93,7 @@ public sealed class FleetHealthService
return rows;
}
private async Task<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(CancellationToken ct)
private async Task<List<FleetHealthRowDto>> FetchSimple3HealthAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
@@ -114,7 +114,7 @@ public sealed class FleetHealthService
}
catch (Exception ex)
{
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
_log.LogDebug(ex, "fleet/health from Simple3 failed");
return new();
}
}
@@ -4,45 +4,46 @@ using Microsoft.Extensions.Logging;
namespace MiGu.Server.Launcher;
/// <summary>
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
/// 将 <c>obj/Debug</c> 下最新编译的 Simple3 同步到 <c>bin/Debug</c>(仅当 Simple3 未运行时)。
/// </summary>
public static class SimpleLiteBuildSync
public static class Simple3BuildSync
{
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
{
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
return false;
var binDll = Path.Combine(binDir, "SimpleLite.dll");
var binExe = Path.Combine(binDir, "SimpleLite.exe");
var binDll = Path.Combine(binDir, "Simple3.dll");
var binExe = Path.Combine(binDir, "Simple3.exe");
if (!File.Exists(objDll))
{
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
log?.LogDebug("[Simple3BuildSync] obj DLL 不存在: {Path}", objDll);
return false;
}
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
if (Process.GetProcessesByName("Simple3").Any(p => !p.HasExited)
|| Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
{
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
log?.LogWarning("[Simple3BuildSync] Simple3 仍在运行,跳过 DLL 同步。请先关闭内核窗口。");
return false;
}
var objTime = File.GetLastWriteTimeUtc(objDll);
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
{
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
log?.LogDebug("[Simple3BuildSync] bin 已是最新,无需同步");
return false;
}
Directory.CreateDirectory(binDir);
File.Copy(objDll, binDll, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
if (File.Exists(objExe))
{
File.Copy(objExe, binExe, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
}
SyncRuntimeDeps(objDll, binDir, log);
@@ -51,7 +52,7 @@ public static class SimpleLiteBuildSync
}
/// <summary>
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
/// 探测 Simple3 是否带「前往站点」路由(区分新旧 DLL)。
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
@@ -96,7 +97,7 @@ public static class SimpleLiteBuildSync
var dst = Path.Combine(binDir, name);
File.Copy(src, dst, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
log?.LogInformation("[Simple3BuildSync] 已同步依赖 {Name}", name);
}
}
@@ -106,11 +107,11 @@ public static class SimpleLiteBuildSync
binDir = "";
var repo = FindRepoRoot(contentRoot);
if (repo == null) return false;
var sl = Path.Combine(repo, "Simple", "SimpleLite");
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
var sl = Path.Combine(repo, "Simple", "Simple3");
objDll = Path.Combine(sl, "obj", "Debug", "Simple3.dll");
objExe = Path.Combine(sl, "obj", "Debug", "Simple3.exe");
binDir = Path.Combine(sl, "bin", "Debug");
return true;
return Directory.Exists(sl);
}
private static string? FindRepoRoot(string contentRoot)
@@ -118,7 +119,8 @@ public static class SimpleLiteBuildSync
var dir = new DirectoryInfo(contentRoot);
while (dir != null)
{
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "Simple3"))
|| Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
return dir.FullName;
dir = dir.Parent;
}
@@ -7,21 +7,21 @@ using Microsoft.Extensions.Options;
namespace MiGu.Server.Launcher;
/// <summary>
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
/// 平台登录成功后按 LaunchMode 把 Simple3.exe 作为子进程拉起。
///
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
/// 设计要点(与原 <c>Simple3/Platform/PlatformLauncher.cs</c> 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
/// - 默认独立(<see cref="Simple3Options.FollowParent"/> = false):Simple3 与 MiGu.Server 互不影响,
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 Simple3
/// - 命令行透传:把 LaunchMode 翻成 Simple3 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
/// - 启动后阻塞等待 Simple3 Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
/// </summary>
public sealed class SimpleLiteLauncher : IDisposable
public sealed class Simple3Launcher : IDisposable
{
private readonly SimpleLiteOptions _opts;
private readonly ILogger<SimpleLiteLauncher> _log;
private readonly Simple3Options _opts;
private readonly ILogger<Simple3Launcher> _log;
private readonly IHostEnvironment _env;
private readonly object _sync = new();
@@ -30,13 +30,13 @@ public sealed class SimpleLiteLauncher : IDisposable
private string? _lastLaunchMode;
private bool _disposed;
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
public Simple3Launcher(IOptions<Simple3Options> opts, ILogger<Simple3Launcher> log, IHostEnvironment env)
{
_opts = opts.Value;
_log = log;
_env = env;
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— Simple3 是「独立程序」,MiGu.Server 关掉
// 不应该带走 Simple3。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
if (_opts.FollowParent)
{
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
@@ -53,11 +53,11 @@ public sealed class SimpleLiteLauncher : IDisposable
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
/// <summary>外部已存在的 SimpleLiteMiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
/// <summary>外部已存在的 Simple3MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
internal const string ExternalReuseLaunchMode = "external";
/// <summary>
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
/// 按 launchMode 拉起 Simple3(已运行则跳过)。
/// </summary>
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
@@ -67,20 +67,20 @@ public sealed class SimpleLiteLauncher : IDisposable
if (!_opts.Enabled)
{
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
_log.LogInformation("[Simple3] auto-start disabled (appsettings: Simple3.Enabled=false); launchMode={Mode} ignored", launchMode);
return new LaunchResult(false, "Disabled", "appsettings:Simple3:Enabled=false", DisplayMode: null,
Warning: "Simple3 自动拉起已被 appsettings:Simple3:Enabled=false 关闭;登录已成功但 Simple3 未启动,/api/sl/* 反代请求会返回 502。");
}
lock (_sync)
{
if (_proc is { HasExited: false })
{
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
_log.LogInformation("[Simple3] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
? $"Simple3 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 Simple3 后重新登录。"
: null;
return new LaunchResult(true, "AlreadyRunning",
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
@@ -88,44 +88,44 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: warning);
}
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 Simple3 可能仍在跑(因为
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
// 避免「allowMultiple=false 时新 Simple3 检测到多开自杀」+「端口冲突」两类常见崩溃。
//
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
// 注意:探测仅判断「有 Simple3 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 Simple3 再登录。
//
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 Simple3」,避免被某个偶然占
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
{
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
var probeOk = ProbeSimple3Http("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
if (!probeOk)
{
_log.LogWarning(
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
"[Simple3] projection port :{Port} is occupied, but Simple3 HTTP probe failed. Skip launch to avoid port conflict.",
_opts.ProjectionPort);
return new LaunchResult(false, "PortOccupied",
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
DisplayMode: null,
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 Simple3 Projection 服务。" +
"请关闭占用该端口的进程,或调整 Simple3:ProjectionPort / Simple3 配置后重试。");
}
_log.LogInformation(
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
"[Simple3] projection :{Port} already reachable (httpProbe=ok); assume an existing Simple3 is running. " +
"Skip launch. If user picked a different LaunchMode this session, please close the existing Simple3 window and login again.",
_opts.ProjectionPort);
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
_lastLaunchMode = ExternalReuseLaunchMode;
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
var reuseWarning = $"检测到 Simple3 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 Simple3 窗口后重新登录。";
if (Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
{
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
reuseWarning += " 当前 Simple3 版本过旧,缺少「前往站点」API;请关闭 Simple3 窗口后调用 POST /api/health/simple3/restart-for-update,或运行 scripts/redeploy-simple3.ps1。";
}
return new LaunchResult(true, "ReusingExisting",
@@ -134,18 +134,18 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: reuseWarning);
}
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var resolved = ResolveExecutable(_opts.ExecutablePath);
if (resolved == null)
{
_log.LogWarning(
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
"[Simple3] auto-start skipped: Simple3.exe not found. Configure appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server. ContentRoot={Root}",
_env.ContentRootPath);
return new LaunchResult(false, "ExecutableNotFound",
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
"Set appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server.",
DisplayMode: null,
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
Warning: "找不到 Simple3.exe。请在 appsettings:Simple3:ExecutablePath 显式配置,或者把 Simple3.exe 放到 MiGu.Server 同目录。");
}
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
@@ -156,12 +156,12 @@ public sealed class SimpleLiteLauncher : IDisposable
try
{
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
var proc = StartSimple3Process(resolved, arguments, workdir);
if (proc == null)
{
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
_log.LogError("[Simple3] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
Warning: $"启动 Simple3 进程失败;exe={resolved}");
}
WireProcessExitHandler(proc);
@@ -171,20 +171,20 @@ public sealed class SimpleLiteLauncher : IDisposable
if (_opts.FollowParent) AttachToJobObject(proc);
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
_log.LogInformation("[Simple3] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
}
catch (Exception ex)
{
_log.LogError(ex, "[SimpleLite] auto-start failed");
_log.LogError(ex, "[Simple3] auto-start failed");
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
Warning: $"启动 Simple3 时抛异常:{ex.GetType().Name}: {ex.Message}");
}
}
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
// GET /api/health/simplelite 获知就绪状态。
// GET /api/health/simple3 获知就绪状态。
if (!waitForReady)
{
return new LaunchResult(true, "Starting",
@@ -200,12 +200,12 @@ public sealed class SimpleLiteLauncher : IDisposable
DisplayMode: displayMode,
Warning: ready
? null
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
: $"Simple3 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
}
/// <summary>
/// 手动关闭 SimpleLite 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
/// 手动关闭 Simple3 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— Simple3 是独立程序,MiGu.Server 关闭不带走它。
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
/// </summary>
public void Dispose()
@@ -227,7 +227,7 @@ public sealed class SimpleLiteLauncher : IDisposable
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
if (proc is { HasExited: false })
{
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
_log.LogInformation("[Simple3] standalone mode: MiGu.Server stopping — Simple3 pid={Pid} keeps running (FollowParent=false)", proc.Id);
}
try { proc?.Dispose(); } catch { /* ignore */ }
return;
@@ -236,7 +236,7 @@ public sealed class SimpleLiteLauncher : IDisposable
if (proc is { HasExited: false })
{
try { proc.Kill(entireProcessTree: true); }
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
catch (Exception ex) { _log.LogWarning("[Simple3] kill failed: {Msg}", ex.Message); }
try { proc.WaitForExit(2000); } catch { /* ignore */ }
}
@@ -248,21 +248,21 @@ public sealed class SimpleLiteLauncher : IDisposable
private static string NormalizeDisplayMode(string launchMode)
{
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 SimpleLite「内部语言」(web / web+local)。
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 Simple3「内部语言」(web / web+local)。
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
return launchMode?.Trim().ToLowerInvariant() switch
{
"webonly" or "web" or "web-only" => "web",
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
_ => "web+local",
"desktopandweb" or "web+local" or "weblocal" or "local" => "web+local",
// Simple3 无本地桌面端:默认 / 未知一律 web
_ => "web",
};
}
private string BuildArguments(string displayMode, string extra)
{
var args = $"--display-mode={displayMode}";
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 Simple3 WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:Simple3:EmbeddedCanvas=false 关闭。
if (_opts.EmbeddedCanvas) args += " --migu";
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
var sceneArg = ReadActiveScenesArg();
@@ -271,7 +271,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return args;
}
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>
/// <summary>解析 Simple3 工作目录(与拉起时一致):优先显式 <see cref="Simple3Options.WorkingDirectory"/>
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
public string? ResolveWorkingDirectory()
{
@@ -281,7 +281,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
}
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
/// <summary>Simple3 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
public string? ResolvePluginsDir()
{
var wd = ResolveWorkingDirectory();
@@ -289,16 +289,16 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite
/// 把「配置向导选定的导航场景」写入 Simple3 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 Simple3 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 Simple3
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
/// </summary>
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
{
var pluginsDir = ResolvePluginsDir();
if (pluginsDir == null)
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
return new ActiveScenesWriteResult(false, null, "未找到 Simple3 工作目录/可执行文件,无法定位 plugins 目录");
try
{
Directory.CreateDirectory(pluginsDir);
@@ -313,13 +313,13 @@ public sealed class SimpleLiteLauncher : IDisposable
updatedAt = DateTime.UtcNow
};
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
_log.LogInformation("[Simple3] active-scenes.json 写入 {Path}: active=[{Scenes}]",
path, string.Join(",", payload.activeScenes));
return new ActiveScenesWriteResult(true, path, null);
}
catch (Exception ex)
{
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
_log.LogWarning(ex, "[Simple3] 写 active-scenes.json 失败");
return new ActiveScenesWriteResult(false, null, ex.Message);
}
}
@@ -349,11 +349,12 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
/// <summary>终止本机全部 Simple3(及旧名 SimpleLite进程并清理 MiGu.Server 侧托管引用。</summary>
public int StopAll()
{
var killed = 0;
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
foreach (var name in new[] { "Simple3", "SimpleLite" })
foreach (var proc in Process.GetProcessesByName(name))
{
try
{
@@ -361,12 +362,12 @@ public sealed class SimpleLiteLauncher : IDisposable
{
proc.Kill(entireProcessTree: true);
killed++;
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
_log.LogInformation("[Simple3] stop: killed {Name} pid={Pid}", name, proc.Id);
}
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
_log.LogWarning("[Simple3] stop: kill {Name} pid={Pid} failed: {Msg}", name, proc.Id, ex.Message);
}
finally
{
@@ -385,7 +386,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return killed;
}
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
/// <summary>关闭全部 Simple3 后按 launchMode 重新拉起(不同步 DLL)。</summary>
public LaunchResult Restart(string launchMode = "webonly")
{
StopAll();
@@ -393,19 +394,19 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// 关闭所有 Simple3 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
public LaunchResult RestartForUpdate(string launchMode = "webonly")
{
StopAll();
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var synced = Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var result = MaybeStart(launchMode);
if (!synced && result.Warning == null)
{
return result with
{
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 Simple3 仍占用文件)。请先运行 scripts/redeploy-simple3.ps1。"
};
}
@@ -416,12 +417,12 @@ public sealed class SimpleLiteLauncher : IDisposable
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
public SimpleLiteDiagnostics GetDiagnostics()
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simple3 与启动日志)。</summary>
public Simple3Diagnostics GetDiagnostics()
{
var resolved = ResolveExecutable(_opts.ExecutablePath);
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 Simple3 发探测请求。
bool? gotoSite = null;
if (projectionUp)
{
@@ -431,7 +432,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
else
{
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
gotoSite = Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
if (gotoSite is not null)
{
_gotoSiteProbeCache = gotoSite;
@@ -440,9 +441,9 @@ public sealed class SimpleLiteLauncher : IDisposable
}
}
var deployHint = gotoSite == false
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
? "关闭 Simple3 后:运行 Migu2.0/scripts/redeploy-simple3.ps1,或 POST /api/health/simple3/restart-for-update"
: null;
return new SimpleLiteDiagnostics(
return new Simple3Diagnostics(
Enabled: _opts.Enabled,
FollowParent: _opts.FollowParent,
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
@@ -456,26 +457,26 @@ public sealed class SimpleLiteLauncher : IDisposable
ProjectionPortReachable: projectionUp,
GotoSiteApiAvailable: gotoSite,
DeployHint: deployHint,
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 Simple3 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 Simple3。");
}
/// <summary>
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite
/// 拉起 Simple3。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 Simple3
/// </summary>
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
private Process? StartSimple3Process(string resolved, string arguments, string workdir)
{
if (_opts.FollowParent)
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
if (OperatingSystem.IsWindows())
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3DetachedWindows(resolved, arguments, workdir)
?? StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
}
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
private static Process? StartSimple3Direct(string resolved, string arguments, string workdir, bool useShellExecute)
{
var psi = new ProcessStartInfo
{
@@ -493,14 +494,15 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
/// </summary>
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
private Process? StartSimple3DetachedWindows(string resolved, string arguments, string workdir)
{
var exeName = Path.GetFileNameWithoutExtension(resolved);
var beforeIds = new HashSet<int>(
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
.Where(id => id > 0));
var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
// cmd start 的第一个引号参数是「窗口标题」,不是 exe;写死 Simple3 会让 Simple3 控制台标题错名。
var cmdArgs = $"/c start \"{exeName}\" /D \"{workdir}\" \"{resolved}\" {arguments}";
var shimPsi = new ProcessStartInfo
{
FileName = "cmd.exe",
@@ -521,7 +523,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (p.HasExited) continue;
if (beforeIds.Contains(p.Id)) continue;
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
_log.LogInformation("[Simple3] detached start via cmd.exe; new pid={Pid}", p.Id);
return AttachToExistingProcess(p.Id);
}
catch { /* ignore */ }
@@ -529,7 +531,7 @@ public sealed class SimpleLiteLauncher : IDisposable
Thread.Sleep(200);
}
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
_log.LogWarning("[Simple3] detached start: no new {Name} process observed after cmd.exe start", exeName);
return null;
}
@@ -543,7 +545,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
_log.LogWarning("[Simple3] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
return null;
}
}
@@ -554,7 +556,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
int exit;
try { exit = proc.ExitCode; } catch { exit = -1; }
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
_log.LogInformation("[Simple3] process exited code={Code}; next login will relaunch if needed", exit);
lock (_sync)
{
if (ReferenceEquals(_proc, proc))
@@ -577,16 +579,26 @@ public sealed class SimpleLiteLauncher : IDisposable
var cwd = _env.ContentRootPath;
var staticCandidates = new[]
{
Path.Combine(cwd, "Simple3.exe"),
Path.Combine(cwd, "SimpleLite.exe"),
Path.Combine(cwd, "Simple3", "Simple3.exe"),
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple3.exe"),
Path.Combine(cwd, "..", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
// 发布布局(本机常见):D:\MDCS\Release\Simple\Simple3
@"D:\MDCS\Release\Simple\Simple3\Simple3.exe",
// 源码并列:Migu2.0/MiGu.Server → ../../Simple/Simple3OutputPath 常直接写到 Release
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Debug", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Release", "Simple3.exe"),
Path.Combine(cwd, "..", "Simple", "Simple3", "Simple3.exe"),
// 兼容旧 SimpleLite 目录/产物名
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
};
foreach (var c in staticCandidates)
{
@@ -599,6 +611,13 @@ public sealed class SimpleLiteLauncher : IDisposable
{
foreach (var sub in new[]
{
"Simple3/Simple3.exe",
"Simple3/bin/Debug/Simple3.exe",
"Simple3/bin/Release/Simple3.exe",
"Simple/Simple3/Simple3.exe",
"Simple/Simple3/bin/Debug/Simple3.exe",
"Simple/Simple3/bin/Release/Simple3.exe",
"SimpleLite/SimpleLite.exe",
"SimpleLite/bin/Debug/SimpleLite.exe",
"SimpleLite/bin/Release/SimpleLite.exe",
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
@@ -668,14 +687,14 @@ public sealed class SimpleLiteLauncher : IDisposable
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
{
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
_log.LogInformation("[Simple3] projection :{Port} ready", _opts.ProjectionPort);
return true;
}
Thread.Sleep(interval);
}
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
_log.LogWarning("[Simple3] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
return false;
}
@@ -694,10 +713,10 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
/// 在 TCP 通的基础上读取 Simple3 Projection 的强类型 JSON 端点,判别「真的是 Simple3」。
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
/// </summary>
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
private static bool ProbeSimple3Http(string host, int port, TimeSpan timeout)
{
try
{
@@ -718,20 +737,20 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 拉起 SimpleLite 的结果摘要。
/// 拉起 Simple3 的结果摘要。
/// <list type="bullet">
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
/// <item><c>DisplayMode</c>:本次实际生效的 displayModeweb / web+local / external / null)。
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 Simple3,对应模式未知。</item>
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
/// </list>
/// </summary>
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
string? DisplayMode = null, string? Warning = null);
public sealed record SimpleLiteDiagnostics(
public sealed record Simple3Diagnostics(
bool Enabled,
bool FollowParent,
string ConfiguredExecutablePath,
@@ -807,7 +826,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (!OperatingSystem.IsWindows())
{
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
_log.LogInformation("[Simple3] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
return;
}
@@ -818,7 +837,7 @@ public sealed class SimpleLiteLauncher : IDisposable
_job = CreateJobObject(IntPtr.Zero, null);
if (_job == IntPtr.Zero)
{
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] CreateJobObject failed; child may outlive MiGu.Server");
return;
}
@@ -831,17 +850,17 @@ public sealed class SimpleLiteLauncher : IDisposable
{
Marshal.StructureToPtr(info, ptr, false);
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] SetInformationJobObject failed; child may outlive MiGu.Server");
}
finally { Marshal.FreeHGlobal(ptr); }
}
if (!AssignProcessToJobObject(_job, child.Handle))
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] AssignProcessToJobObject failed; child may outlive MiGu.Server");
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
_log.LogWarning("[Simple3] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
}
}
}
@@ -1,31 +1,30 @@
namespace MiGu.Server.Launcher;
/// <summary>
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 Simple3 子进程配置。
///
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
/// 会话 N+1(启动反转):原架构是 Simple3 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 Simple3.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 Simple3 的 Configuration。
///
/// 绑定 <c>appsettings.json:SimpleLite</c>。
/// 绑定 <c>appsettings.json:Simple3</c>。
/// </summary>
public sealed class SimpleLiteOptions
public sealed class Simple3Options
{
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
/// 1) <c>./SimpleLite.exe</c>(合并发布布局SimpleLite 与 MiGu.Server 同目录
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
/// 3) <c>../SimpleLite.exe</c>MiGu.Server 部署到子目录的常见布局)
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
/// Simple3 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
/// 1) <c>./Simple3.exe</c>(合并发布布局;仍兼容探测旧名 <c>./SimpleLite.exe</c>
/// 2) <c>./Simple3/Simple3.exe</c>
/// 3) 开发态:兄弟仓库 <c>../Simple/Simple3/</c> 产出目录或 <c>bin/Debug|Release</c>
/// 4) 发布目录常见布局:<c>D:\MDCS\Release\Simple\Simple3\Simple3.exe</c>(仅当该绝对路径存在
/// 5) 沿父目录上行寻找 Simple3 产出
/// </summary>
public string ExecutablePath { get; set; } = "";
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
public string WorkingDirectory { get; set; } = "";
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
@@ -33,7 +32,7 @@ public sealed class SimpleLiteOptions
/// <summary>
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
/// 0 = 不等待(登录立即返回,前端可能还连不上 Simple3 WebApi);
/// 负值 = 无限等待(直到子进程退出或就绪)。
/// </summary>
public int ReadinessTimeoutMs { get; set; } = 8000;
@@ -41,17 +40,17 @@ public sealed class SimpleLiteOptions
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
public int ReadinessPollIntervalMs { get; set; } = 250;
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
/// <summary>Simple3 Projection WebApi 监听端口。默认与 Simple3 Configuration 的 `port` 一致。用于就绪检测。</summary>
public int ProjectionPort { get; set; } = 8222;
/// <summary>
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
/// 是否把 Simple3 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
///
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false
/// 设计原则:Simple3 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 Simple3
/// MiGu.Server 关闭不应该带走 Simple3,反之亦然。所以 FollowParent 默认 false
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
/// - Simple3 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
///
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
@@ -59,11 +58,11 @@ public sealed class SimpleLiteOptions
public bool FollowParent { get; set; } = false;
/// <summary>
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
/// 平台拉起 Simple3 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
///
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// true:平台 iframe 嵌入场景,Simple3 WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
/// false:平台拉起的 Simple3 web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
///
/// 与 LaunchModeweb / web+local,是否保留本地调试窗口)正交,可任意组合。
/// </summary>
+270
View File
@@ -0,0 +1,270 @@
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Logs;
/// <summary>
/// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。
/// </summary>
public sealed class LogCleanupService : IHostedService, IDisposable
{
private readonly ConfigStore _store;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogCleanupService> _log;
private readonly object _cleanLock = new();
private readonly object _scheduleLock = new();
private Timer? _cleanTimer;
private Timer? _diskTimer;
private bool _diskBelow;
public LogCleanupService(
ConfigStore store,
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogCleanupService> log)
{
_store = store;
_launcher = launcher;
_config = config;
_log = log;
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
var opt = _store.GetLogCleanup();
if (opt.Enabled && opt.RunOnStartup)
RunCleanSafely(opt, "启动清理");
ApplySchedule();
CheckDisk(opt);
}
catch (Exception ex)
{
_log.LogWarning(ex, "LogCleanupService 启动失败");
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
_diskTimer?.Dispose();
_diskTimer = null;
}
return Task.CompletedTask;
}
public void Dispose()
{
_cleanTimer?.Dispose();
_diskTimer?.Dispose();
}
public LogCleanupOptions GetOptions() => _store.GetLogCleanup();
public LogCleanupOptions SaveOptions(LogCleanupOptions options)
{
var saved = _store.PutLogCleanup(options);
_ = saved;
var opt = _store.GetLogCleanup();
ApplySchedule();
CheckDisk(opt, forceReeval: true);
return opt;
}
public void ApplySchedule()
{
var opt = _store.GetLogCleanup();
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
if (opt.Enabled)
{
var hours = opt.CheckIntervalHours <= 0 ? 1 : opt.CheckIntervalHours;
var period = TimeSpan.FromHours(hours);
_cleanTimer = new Timer(_ => RunCleanSafely(_store.GetLogCleanup(), "定时清理"),
null, period, period);
}
_diskTimer?.Dispose();
var mins = opt.DiskCheckIntervalMinutes <= 0 ? 30 : opt.DiskCheckIntervalMinutes;
var diskPeriod = TimeSpan.FromMinutes(mins);
_diskTimer = new Timer(_ => CheckDisk(_store.GetLogCleanup()),
null, diskPeriod, diskPeriod);
}
}
public CleanResult CleanNow()
{
var opt = _store.GetLogCleanup();
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("立即清理失败:{Error}", r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("立即清理删除 {Count} 个日志文件,释放 {Mb:F1} MB", r.DeletedFiles, r.FreedMB);
else
_log.LogInformation("立即清理完成:没有过期日志");
return r;
}
public DiskStatus GetDiskStatus()
{
var opt = _store.GetLogCleanup();
var known = TryGetFreeGB(out var freeGB, out var drive);
var below = known && opt.DiskAlertEnabled && freeGB < opt.DiskFreeAlertGB;
return new DiskStatus(known, drive, freeGB, opt.DiskAlertEnabled, opt.DiskFreeAlertGB, below);
}
private void RunCleanSafely(LogCleanupOptions opt, string reason)
{
if (!opt.Enabled && reason != "立即清理") return;
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("[{Reason}] 失败:{Error}", reason, r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("[{Reason}] 删除 {Count} 个日志文件,释放 {Mb:F1} MB", reason, r.DeletedFiles, r.FreedMB);
}
private CleanResult CleanOnce(LogCleanupOptions opt)
{
opt.Clamp();
lock (_cleanLock)
{
try
{
var (root, _, error) = ResolveLogRoot();
if (root == null)
return new CleanResult(0, 0, error ?? "无法定位日志目录");
if (!Directory.Exists(root))
return new CleanResult(0, 0, null);
var cutoff = DateTime.Now.AddDays(-opt.RetentionDays);
var deleted = 0;
long freed = 0;
foreach (var f in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories))
{
try
{
var fi = new FileInfo(f);
if (fi.LastWriteTime >= cutoff) continue;
var len = fi.Length;
fi.Delete();
deleted++;
freed += len;
}
catch
{
/* 占用/权限:跳过 */
}
}
TryRemoveEmptyDirs(root);
return new CleanResult(deleted, freed, null);
}
catch (Exception ex)
{
return new CleanResult(0, 0, ex.Message);
}
}
}
private static void TryRemoveEmptyDirs(string root)
{
try
{
foreach (var dir in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
try
{
if (!Directory.EnumerateFileSystemEntries(dir).Any())
Directory.Delete(dir, false);
}
catch { /* 忽略单目录失败 */ }
}
}
catch { /* 忽略枚举失败 */ }
}
private void CheckDisk(LogCleanupOptions opt, bool forceReeval = false)
{
if (forceReeval) _diskBelow = false;
if (!opt.DiskAlertEnabled)
{
_diskBelow = false;
return;
}
if (!TryGetFreeGB(out var freeGB, out var drive))
return;
if (freeGB < opt.DiskFreeAlertGB)
{
if (_diskBelow) return;
_diskBelow = true;
_log.LogWarning(
"磁盘剩余空间不足:{Drive} 仅剩 {Free:F1} GB(低于阈值 {Threshold:F0} GB)。请及时清理磁盘,或在系统配置中调低日志保留天数 / 立即清理。",
drive, freeGB, opt.DiskFreeAlertGB);
}
else
{
_diskBelow = false;
}
}
private bool TryGetFreeGB(out double freeGB, out string driveName)
{
freeGB = 0;
driveName = "";
var (root, _, _) = ResolveLogRoot();
if (root == null) return false;
try
{
var driveRoot = Path.GetPathRoot(root);
if (string.IsNullOrEmpty(driveRoot)) return false;
var di = new DriveInfo(driveRoot);
driveName = di.Name;
freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0;
return true;
}
catch
{
return false;
}
}
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
if (!string.IsNullOrWhiteSpace(overrideRoot))
{
var r = Path.GetFullPath(overrideRoot);
return (r, Path.GetDirectoryName(r), null);
}
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null, "未能定位 Simple3 工作目录");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
}
public readonly record struct CleanResult(int DeletedFiles, long FreedBytes, string? Error)
{
public double FreedMB => FreedBytes / 1024.0 / 1024.0;
}
public readonly record struct DiskStatus(
bool Known,
string Drive,
double FreeGB,
bool AlertEnabled,
double AlertGB,
bool BelowThreshold);
}
+3 -5
View File
@@ -1,8 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MiGu.Server</RootNamespace>
@@ -25,13 +24,12 @@
<ItemGroup>
<ProjectReference Include="..\MiGu.DB\MiGu.DB.csproj" />
<ProjectReference Include="..\..\..\StandardSence\StandardScene.Signal\StandardScene.Signal.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
<Content Include="OpenApi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simplelite-projection.json')" />
<Content Include="..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json')" />
<Content Include="OpenApi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simple3-projection.json')" />
<Content Include="..\..\Simple\Simple3\Docs\openapi\simple3-projection.json" Link="OpenApi\simple3-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\Simple3\Docs\openapi\simple3-projection.json')" />
</ItemGroup>
</Project>
@@ -5,10 +5,10 @@ using Swashbuckle.AspNetCore.SwaggerGen;
namespace MiGu.Server.OpenApi;
/// <summary>
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
/// 将 Simple3 EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/Simple3/Docs/openapi/simple3-projection.json
/// </summary>
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
public sealed class Simple3OpenApiDocumentFilter : IDocumentFilter
{
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
{
@@ -175,9 +175,12 @@ public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
foreach (var root in roots)
{
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
// 兼容旧文件名 / 旧目录
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simplelite-projection.json"));
}
}
}
+2 -2
View File
@@ -7,13 +7,13 @@ namespace MiGu.Server.Ota;
public sealed class OtaVehicleSource
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStoreAccessor _token;
private readonly ILogger<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
+22 -23
View File
@@ -124,11 +124,11 @@ builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "MiGu.Server + SimpleLite",
Title = "MiGu.Server + Simple3",
Version = "v1",
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
Description = "咪咕平台后端 API,以及经 YARP 反代的 Simple3 数据 WebApi(标签 Simple3/*)。详见 Simple/Simple3/Docs/MIGU-API.md。"
});
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
c.DocumentFilter<Simple3OpenApiDocumentFilter>();
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
@@ -162,7 +162,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// - InternalTokenStore 管理 Simple3 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// 共享密钥(YARP transform 自动追加)。
builder.Services.AddSingleton<RbacStore>();
builder.Services.AddSingleton<JwtIssuer>(sp =>
@@ -217,7 +217,7 @@ builder.Services.AddAuthorization(opts =>
}));
});
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222AR-1/AR-2 配套)。
// YARP + transform:把 Platform 内部 token 透传给 Simple3 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
@@ -255,16 +255,18 @@ builder.Services.AddReverseProxy()
// 单例配置仓库(内存 + JSON 文件持久化占位)
builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
builder.Services.AddSingleton<Simple3Launcher>();
builder.Services.AddSingleton<SignalDataStore>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
@@ -289,35 +291,32 @@ _ = app.Services.GetRequiredService<JwtIssuer>();
_ = app.Services.GetRequiredService<InternalTokenStore>();
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
_ = app.Services.GetRequiredService<RbacStore>();
// 主动实例化 SimpleLiteLauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
// 主动实例化 Simple3LauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
{
var sl = simpleLiteLauncher.GetDiagnostics();
var sl = simple3Launcher.GetDiagnostics();
app.Logger.LogInformation(
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
"[MiGu.Server] Simple3: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → Simple3",
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
sl.ProjectionPort, sl.ProjectionPortReachable);
}
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
{
app.Lifetime.ApplicationStopping.Register(() =>
{
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
catch { /* shutdown best-effort */ }
});
}
else
{
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
app.Logger.LogInformation("[MiGu.Server] Simple3: FollowParent=false — MiGu.Server 退出不会结束 Simple3");
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseSwagger();
app.UseSwaggerUI();
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
app.Use(async (ctx, next) =>
+34 -34
View File
@@ -5,8 +5,8 @@
>
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web)
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite`Configuration.displayMode`
> 后端 `AuthController.Login` 调用 `Simple3Launcher.MaybeStart(launchMode)` 按所选模式拉起 `Simple3.exe`
> 通过 `--display-mode=web|web+local` 透传给 Simple3`Configuration.displayMode`
>
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
@@ -19,83 +19,83 @@
cd MiGu.Server
dotnet build MiGu.Server.csproj -c Debug
# 2) 启动(登录后按所选模式拉起 SimpleLite
# 2) 启动(登录后按所选模式拉起 Simple3
dotnet run
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
```
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`
## 配置 SimpleLite 路径(必读)
## 配置 Simple3 路径(必读)
> **配置文件位置(无 Web 界面):**
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
> **`MiGu.Server/appsettings.json`** → 搜索 **`"Simple3"`** 节点。
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `Simple3.exe`
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simple3`** 查看路径是否解析成功。
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
登录成功后,MiGu.Server 会按所选启动模式拉起 **Simple3.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
### 本机开发(与 Simple 仓库并列)
仓库默认已在 `appsettings.Development.json` 中写好:
```json
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\Simple3\\bin\\Debug",
"ProjectionPort": 8222,
"ReadinessTimeoutMs": 8000,
"FollowParent": false
}
```
请先编译 SimpleLite(在 Simple 仓库):
请先编译 Simple3(在 Simple 仓库):
```pwsh
cd ..\Simple
dotnet build SimpleLite\SimpleLite.csproj -c Debug
dotnet build Simple3\Simple3.csproj -c Debug
```
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
```json
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug"
```
### 配置项说明
| 键 | 含义 |
|----|------|
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI |
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs` |
| `Enabled` | `false` 时永不拉起 Simple3(只调试平台 UI |
| `ExecutablePath` | `Simple3.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/Simple3Options.cs` |
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini` |
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
| `ReadinessTimeoutMs` | 登录后等待 Simple3 WebAPI 就绪的最长时间(毫秒) |
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 Simple3;默认 `false`(两进程独立) |
### 环境变量覆盖
```pwsh
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
$env:SimpleLite__WorkingDirectory = "D:\apps"
$env:SimpleLite__Enabled = "true"
$env:Simple3__ExecutablePath = "D:\apps\Simple3.exe"
$env:Simple3__WorkingDirectory = "D:\apps"
$env:Simple3__Enabled = "true"
```
### 生产 / 合并发布
`SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
`Simple3.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./Simple3.exe`),或在 `appsettings.Production.json` 写死部署路径。
浏览器访问 `http://localhost:8080/login`
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
2. 选 scope(管理员 / 运营);
3. 选 **SimpleLite 启动模式**
3. 选 **Simple3 启动模式**
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
4. 点登录。`AuthController.Login` 调用 `Simple3Launcher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
> 如果只想跑 MiGu.Server 单进程调试(不拉 Simple3),把 `appsettings.json:Simple3.Enabled` 改为 `false` 即可。
## 数据库启动流程
@@ -198,7 +198,7 @@ SchemaMode = Migrate
>
> 详见代码审核 ISSUE-03`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`)。
>
> **SimpleLite 8222 内部 APIRV-04**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken``MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
> **Simple3 8222 内部 APIRV-04**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken``MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
## 能力清单(本轮)
@@ -208,11 +208,11 @@ SchemaMode = Migrate
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
- YARP 反向代理:
- `/api/sl/{**catch-all}``http://127.0.0.1:8222/`SimpleLite WebAPI;当前 SimpleLite 未启 WebAPI 时返回 502);
- `/api/sl/{**catch-all}``http://127.0.0.1:8222/`Simple3 WebAPI;当前 Simple3 未启 WebAPI 时返回 502);
- `/vr/{**catch-all}``http://127.0.0.1:8223/`webVRender;可用于同源 iframe 解决 X-Frame-Options 限制);
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `SimpleLiteLauncher` 据此拉起子进程;
- SimpleLite 子进程编排:`Launcher/SimpleLiteLauncher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:SimpleLite`
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `Simple3Launcher` 据此拉起子进程;
- Simple3 子进程编排:`Launcher/Simple3Launcher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:Simple3`
- 运维白名单网关占位:`/api/sl/ops/execute``/api/sl/ops/audits`
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`
- 健康检查:`/api/health`
@@ -255,7 +255,7 @@ MiGu.Server/
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../distassets/ 与 index.html 已 ignore
```
## 独立启动(仅当你不通过 SimpleLite 自启时)
## 独立启动(仅当你不通过 Simple3 自启时)
```pwsh
# 1) 还原 + 编译 + 运行
@@ -278,7 +278,7 @@ dotnet run
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...``WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`
> 仓库根 `build-platform-frontend.bat` 仍可用于「打部署包」场景(把 dist robocopy /MIR 同步到 wwwroot)。
> SimpleLite 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist
> Simple3 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
## 与前端联调
@@ -292,7 +292,7 @@ dotnet run
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`Program.cs 会优先挂 dist
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
## YARP 与 SimpleLite
## YARP 与 Simple3
YARP 路由配置见 `appsettings.json`
@@ -312,7 +312,7 @@ YARP 路由配置见 `appsettings.json`
```
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`SimpleLite 未启时会得到 502/连接被拒。
- 其他 `/api/sl/*` 由 YARP 透传到 Simple3 `:8222`Simple3 未启时会得到 502/连接被拒。
## iframe 嵌入 webVRender 的两种方式
+33 -6
View File
@@ -15,8 +15,8 @@ public sealed record SignalTableDef(
IReadOnlyList<SignalColumn> Columns);
/// <summary>
/// 读写 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
/// 表结构优先从 StandardScene.Signal.dll 反射;无插件时才读 signal-tables.json。
/// 读写 Simple3 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
/// 表结构Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射;无插件时才读 signal-tables.json。
/// </summary>
public sealed class SignalDataStore
{
@@ -28,11 +28,11 @@ public sealed class SignalDataStore
private static readonly object FileLock = new();
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private IReadOnlyList<SignalTableDef>? _cachedTables;
private long _cachedSignature;
public SignalDataStore(SimpleLiteLauncher launcher) => _launcher = launcher;
public SignalDataStore(Simple3Launcher launcher) => _launcher = launcher;
public IReadOnlyList<SignalTableDef> GetTables()
{
@@ -55,9 +55,11 @@ public sealed class SignalDataStore
{
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, "未找到 SimpleLite 工作目录,无法定位 Config/Signal");
return (null, "未找到 Simple3 工作目录,无法定位 Config/Signal");
if (!TryResolveUnderSignalDir(wd, table.FileName, out var dest, out var pathError))
return (null, pathError);
var dest = Path.GetFullPath(Path.Combine(wd, "Config", "Signal", table.FileName));
if (File.Exists(dest))
return (dest, null);
@@ -109,4 +111,29 @@ public sealed class SignalDataStore
lock (FileLock)
File.WriteAllText(path, rows.ToJsonString(FileJson));
}
private static bool TryResolveUnderSignalDir(string workingDirectory, string fileName, out string dest, out string error)
{
dest = "";
error = "";
if (string.IsNullOrWhiteSpace(fileName) ||
Path.IsPathRooted(fileName) ||
fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
error = "信号表文件名非法";
return false;
}
var root = Path.GetFullPath(Path.Combine(workingDirectory, "Config", "Signal"));
dest = Path.GetFullPath(Path.Combine(root, fileName));
var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!dest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
error = "信号表路径超出 Config/Signal";
dest = "";
return false;
}
return true;
}
}
+63 -49
View File
@@ -1,13 +1,12 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json.Serialization;
using StandardScene.Signal.Model;
namespace MiGu.Server.Signal;
/// <summary>
/// 从 StandardScene.Signal 程序集反射数据中心表和列。
/// 优先使用 MiGu.Server 编译引用的程序集;否则再从 SimpleLite plugins 加载
/// 从 Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射数据中心表和列。
/// 平台不编译引用该插件工程,避免仓外路径和 Windows TFM 耦合
/// </summary>
public static class SignalModelSchemaResolver
{
@@ -24,18 +23,19 @@ public static class SignalModelSchemaResolver
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
{
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
if (assembly == null)
return Array.Empty<SignalTableDef>();
try
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
return BuildTablesFromAssembly(assembly);
}
catch
{
return Array.Empty<SignalTableDef>();
}
if (assembly == null)
return Array.Empty<SignalTableDef>();
try
{
return BuildTablesFromAssembly(assembly);
}
catch
{
return Array.Empty<SignalTableDef>();
}
});
}
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
@@ -43,61 +43,76 @@ public static class SignalModelSchemaResolver
if (string.IsNullOrWhiteSpace(modelName))
return Array.Empty<SignalColumn>();
var assembly = TryGetSignalAssembly(pluginsDir, workingDirectory);
if (assembly == null)
return Array.Empty<SignalColumn>();
try
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
if (type == null)
if (assembly == null)
return Array.Empty<SignalColumn>();
return DiscoverColumns(type);
}
catch
{
return Array.Empty<SignalColumn>();
}
try
{
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
if (type == null)
return Array.Empty<SignalColumn>();
return DiscoverColumns(type);
}
catch
{
return Array.Empty<SignalColumn>();
}
});
}
private static Assembly? TryGetSignalAssembly(string? pluginsDir, string? workingDirectory)
private static T WithSignalAssembly<T>(string? pluginsDir, string? workingDirectory, Func<Assembly?, T> use)
{
try
{
var referenced = typeof(PlcStationModel).Assembly;
if (HasModelTypes(referenced))
return referenced;
}
catch
{
/* 未引用插件工程时继续走 LoadFrom */
}
var assemblyPath = FindAssemblyPath(pluginsDir);
if (assemblyPath == null)
return null;
return use(null);
var expected = Path.GetFullPath(assemblyPath);
var cached = FindLoadedFrom(expected);
if (cached != null)
return use(cached);
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
ResolveEventHandler? handler = null;
handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
ResolveEventHandler handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
AppDomain.CurrentDomain.AssemblyResolve += handler;
try
{
var loaded = Assembly.LoadFrom(assemblyPath);
return HasModelTypes(loaded) ? loaded : null;
var loaded = Assembly.LoadFrom(expected);
return use(HasModelTypes(loaded) ? loaded : null);
}
catch
{
return null;
return use(null);
}
finally
{
if (handler != null)
AppDomain.CurrentDomain.AssemblyResolve -= handler;
AppDomain.CurrentDomain.AssemblyResolve -= handler;
}
}
private static Assembly? FindLoadedFrom(string expectedFullPath)
{
try
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!string.Equals(assembly.GetName().Name, "StandardScene.Signal", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(assembly.Location))
continue;
if (!string.Equals(Path.GetFullPath(assembly.Location), expectedFullPath, StringComparison.OrdinalIgnoreCase))
continue;
return HasModelTypes(assembly) ? assembly : null;
}
}
catch
{
/* 继续 LoadFrom */
}
return null;
}
private static bool HasModelTypes(Assembly assembly)
{
return SafeGetTypes(assembly).Any(t =>
@@ -119,7 +134,6 @@ public static class SignalModelSchemaResolver
Add(workingDirectory);
if (!string.IsNullOrWhiteSpace(pluginsDir))
Add(Path.GetDirectoryName(pluginsDir));
Add(AppContext.BaseDirectory);
return dirs;
}
@@ -16,7 +16,7 @@ public static class SignalTableManifestLoader
PropertyNameCaseInsensitive = true
};
public static IReadOnlyList<SignalTableDef> Load(SimpleLiteLauncher launcher)
public static IReadOnlyList<SignalTableDef> Load(Simple3Launcher launcher)
{
var workingDirectory = launcher.ResolveWorkingDirectory();
var pluginsDir = launcher.ResolvePluginsDir();
@@ -31,7 +31,7 @@ public static class SignalTableManifestLoader
.ToList();
}
public static long ComputeManifestSignature(SimpleLiteLauncher launcher)
public static long ComputeManifestSignature(Simple3Launcher launcher)
{
long sig = 0;
foreach (var path in ResolveManifestPaths(launcher))
@@ -61,7 +61,7 @@ public static class SignalTableManifestLoader
return sig;
}
private static SignalTableManifest TryLoadManifest(SimpleLiteLauncher launcher)
private static SignalTableManifest TryLoadManifest(Simple3Launcher launcher)
{
foreach (var path in ResolveManifestPaths(launcher))
{
@@ -80,7 +80,7 @@ public static class SignalTableManifestLoader
?? new SignalTableManifest();
}
private static IEnumerable<string> ResolveManifestPaths(SimpleLiteLauncher launcher)
private static IEnumerable<string> ResolveManifestPaths(Simple3Launcher launcher)
{
var wd = launcher.ResolveWorkingDirectory();
if (!string.IsNullOrWhiteSpace(wd))
+3 -3
View File
@@ -9,10 +9,10 @@
"Database": {
"SchemaMode": "EnsureCreated"
},
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
"ProjectionPort": 8222,
"ReadinessTimeoutMs": 8000,
"FollowParent": false
+5 -5
View File
@@ -22,11 +22,11 @@
"Internal": {
"Token": "REPLACE_ME"
},
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
"SimpleLite": {
"_comment_Simple3": "登录后拉起调度内核 Simple3.exe;改路径请编辑下方 Simple3 节点。诊断: http://localhost:8080/api/health/simple3",
"Simple3": {
"Enabled": true,
"ExecutablePath": "D:\\工作\\stand\\SimpleLite\\SimpleLite.exe",
"WorkingDirectory": "D:\\工作\\stand\\SimpleLite",
"ExecutablePath": "D:\\MDCS\\Release\\Simple\\Simple3\\Simple3.exe",
"WorkingDirectory": "D:\\MDCS\\Release\\Simple\\Simple3",
"Arguments": "",
"ReadinessTimeoutMs": 15000,
"ReadinessPollIntervalMs": 250,
@@ -39,7 +39,7 @@
"ops": { "Password": "ops" }
}
},
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method'Method 必须是 SimpleLite 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.csOnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method'Method 必须是 Simple3 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.csOnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
"Ops": {
"Dispatch": {
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"section": "system",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4470202+00:00",
"version": 2,
"updatedAt": "2026-08-21T08:06:13.7109039+00:00",
"payload": {
"dispatchLoopHz": 50,
"log": {
@@ -11,7 +11,7 @@
},
"security": {
"jwtExpireMin": 1440,
"enableSwagger": false,
"enableSwagger": true,
"corsWhitelist": [
"http://localhost:5173"
]
+12 -6
View File
@@ -19,12 +19,18 @@
"id": "role-ops",
"name": "\u8FD0\u8425\u4EBA\u5458",
"description": "\u8FD0\u8425\u76D1\u63A7\u7AEF\u9ED8\u8BA4\u89D2\u8272\uFF1A\u53EF\u6267\u884C\u8FD0\u7EF4\u64CD\u4F5C\u3001\u67E5\u770B\u76D1\u63A7",
"scope": "RCSMonitor",
"scope": "*",
"pages": [
"monitor-dashboard",
"monitor-map",
"monitor-notes",
"admin-dashboard",
"admin-map-monitor",
"admin-tasks",
"admin-alarms",
"admin-config-warehouse",
"monitor-ops",
"monitor-notes"
"admin-config-ops-center",
"admin-logs",
"admin-playback"
],
"ops": [
"ops.car.pause",
@@ -87,8 +93,8 @@
"roleIds": [
"role-ops"
],
"salt": "G6c5\u002BnLEjf3fEor1LgNjyg==",
"passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg="
"salt": "F6wxGwnf/nvdbbAKp6hYoQ==",
"passwordHash": "\u002BFvJCZgUYReNSr4T6xIpZU7FoajVHe067JVaHf/Evb8="
}
]
}
+26 -26
View File
@@ -1,43 +1,43 @@
# 平台 v3 重构 — 阶段 0~6 实际改动
> 范围:把 SimpleLite WebTerminal 由"全功能浮动 Panel"模式收敛到"纯 3D Canvas",所有 UI 改由 Vue 平台前端 (`frontends/apps/simple-platform-vue`) 接管;同时补齐反射 / 编辑 / 监控 / 编排 4 大业务面板与撤销栈、AI 生图、报警 SSE 等关键能力。
> 范围:把 Simple3 WebTerminal 由"全功能浮动 Panel"模式收敛到"纯 3D Canvas",所有 UI 改由 Vue 平台前端 (`frontends/apps/simple-platform-vue`) 接管;同时补齐反射 / 编辑 / 监控 / 编排 4 大业务面板与撤销栈、AI 生图、报警 SSE 等关键能力。
>
> 基线:上一份方案见 `ARCHITECTURE.md` v2/v3 章节;后续以本文件为准。
## 编译验证
- `dotnet build SimpleLite/SimpleLite.csproj` —— 0 警告 0 错误
- `dotnet build Simple3/Simple3.csproj` —— 0 警告 0 错误
- `dotnet build MiGu.Server/MiGu.Server.csproj` —— 0 警告 0 错误
- `build-platform-frontend.bat` —— vite build 46.5s,无 vue-tsc / ts 错误,robocopy /MIR 已同步 MiGu.Server/wwwroot
- 旧 `SimpleComposer` (WinForms) 在 v3 之外,不在本次构建目标里
- 旧 `HttpClientHelper` 提升 LangVersion=latest 修通
## 阶段 0:后端 SimpleLite 平台化(已完成)
## 阶段 0:后端 Simple3 平台化(已完成)
### 新增文件
| 文件 | 作用 |
|------|------|
| `SimpleLite/Web/MapEditApiController.cs` | 写操作 8 路 APIcreate/delete/batch/copy-to/pick/dashboard/upload/ai-generate;阶段 6 又补:**project/save**、**project/load**、**project/current**、**sample-sites-along-track** |
| `SimpleLite/Web/ReflectionApiController.cs` | 只读 + execute APIlistKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
| `SimpleLite/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
| `SimpleLite/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
| `SimpleLite/Web/AiConfigController.cs` | AI 服务配置(endpoint/apiKey/model/system prompt/temperature/maxTokens/timeout)读写 |
| `SimpleLite/Web/AlarmStreamService.cs` | 1s 周期 diff `car.status.enums.AlarmInfo`(语义与 `E:\Work\FRLD\StandardScene\Forklift.cs``Commons.GetCarStatus(this, "AlarmInfo")` 等价),变化时通过 SSE `alarm` 事件推送 raise/update/clear |
| `SimpleLite/Web/ProjectionStreamModule.cs` | 平台 SSE 总线,对外广播 `object-created` / `object-deleted` / `object-patched` / `object-batch-changed` / `alarm` / `pick-result` / `project-saved` / `project-loaded` |
| `SimpleLite/Web/PluginAssemblyScan.cs` | 反射扫描 plugins/*.dll,把 `MethodMember` 标注的方法 + Car/Mission/CarProgram/Site/Track 子类型暴露给反射 API |
| `Simple3/Web/MapEditApiController.cs` | 写操作 8 路 APIcreate/delete/batch/copy-to/pick/dashboard/upload/ai-generate;阶段 6 又补:**project/save**、**project/load**、**project/current**、**sample-sites-along-track** |
| `Simple3/Web/ReflectionApiController.cs` | 只读 + execute APIlistKinds / listObjects / methods-by-type / setField / getBundle / selection 等 |
| `Simple3/Web/ReflectionApiDtos.cs` | 反射 API DTO + 统一信封 `ReflectionEnvelope<T>` |
| `Simple3/Web/AiCompletionService.cs` | OpenAI Function Calling 调用 + map-generate 工具桥(真实 LLM,不再桩) |
| `Simple3/Web/AiConfigController.cs` | AI 服务配置(endpoint/apiKey/model/system prompt/temperature/maxTokens/timeout)读写 |
| `Simple3/Web/AlarmStreamService.cs` | 1s 周期 diff `car.status.enums.AlarmInfo`(语义与 `E:\Work\FRLD\StandardScene\Forklift.cs``Commons.GetCarStatus(this, "AlarmInfo")` 等价),变化时通过 SSE `alarm` 事件推送 raise/update/clear |
| `Simple3/Web/ProjectionStreamModule.cs` | 平台 SSE 总线,对外广播 `object-created` / `object-deleted` / `object-patched` / `object-batch-changed` / `alarm` / `pick-result` / `project-saved` / `project-loaded` |
| `Simple3/Web/PluginAssemblyScan.cs` | 反射扫描 plugins/*.dll,把 `MethodMember` 标注的方法 + Car/Mission/CarProgram/Site/Track 子类型暴露给反射 API |
### 修改文件
| 文件 | 关键改动 |
|------|----------|
| `SimpleLite/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
| `SimpleLite/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
| `SimpleLite/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
| `SimpleLite/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
| `SimpleLite/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
| `SimpleLite/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
| `SimpleLite/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel |
| `Simple3/Startup.cs` | EntryPoint 第一步 `EnsureImguiIniFromEmbedded()`;启动 AlarmStreamService、ProjectionStreamModule、AiConfig 加载 |
| `Simple3/UI/SimpleUI.cs` | `CreateRemotePanelSetForTerminal` 检测 CanvasOnly 旗标:不创建 Workbench / Toolbar / BottomBar / CAD / Welcome;保留 SelectionAction、SceneInput、SceneDrawable、2DMapView |
| `Simple3/Workspace/WorkspaceWebUiContext.cs` | 增加 `IsCanvasOnly(terminal)` 旗标 + `/declareCanvasOnly` 注册器(与 `/declareEmbedUi` 互斥) |
| `Simple3/Web/ProjectionWebHost.cs` | `/declareCanvasOnly` 路由注册 |
| `Simple3/Web/ProjectionWebApiController.cs` | 现有 projection API 上挂 `selection-detail` SSE hook |
| `Simple3/UI/ComposerDockPanel.cs` | CanvasOnly 时 PrepareWelcomePanel 返回空 |
| `Simple3/Props/UIHelpers.cs` | 增加 `UI_Image` / `UI_Text` / `UI_Model` PropType。`UI_Model` 用于 CycleGUI 装饰物挂模型(obj/glb/gltf/cyclemodel |
### 关键 API 路径
@@ -119,7 +119,7 @@ SSE /projection/stream # 所有 SSE 事件
- 报警来源走 `useMapEditStream.onAlarm`(订阅 SSE 的 `alarm` 事件,由 AlarmStreamService 1s diff 推送)
- KPI 3 卡:在线车辆 / 运行中任务 / 排队任务
- 实时列表:左侧 Workspace3D + 右侧 VehicleMonitorPanel + SelectionDetailPanel
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 SimpleLite 3D 高亮该车(视觉上等效 flyTo)
- 「定位」按钮通过 `reflectionApi.setSelection('car', id)` 让 Simple3 3D 高亮该车(视觉上等效 flyTo)
## 阶段 5OrchestrationView 设计与编排(已完成)
@@ -159,9 +159,9 @@ Tab 状态缓存到 sessionStorage。
### 编译相关清理
- `SimpleLite/Recording/`(之前的录制/回放实验目录)误用 `SimpleLite.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
- `SimpleLite/Props/UISite.cs``SimpleLite.Props` namespace 下 `Workspace.AddProp` 被解析成 `SimpleLite.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
- `SimpleLite/Workspace/WorkspaceBottomBar.cs``using SimpleLite.Workspace;` → 补
- `Simple3/Recording/`(之前的录制/回放实验目录)误用 `Simple3.Rendering` namespace、`Car` 找不到、`Start(string, out)` 默认参数顺序等 3 个错误 → 修
- `Simple3/Props/UISite.cs``Simple3.Props` namespace 下 `Workspace.AddProp` 被解析成 `Simple3.Workspace.AddProp` → 改为全限定 `CycleGUI.Workspace.AddProp`
- `Simple3/Workspace/WorkspaceBottomBar.cs``using Simple3.Workspace;` → 补
- `HttpClientHelper.csproj` LangVersion 7.3 → latest
## 已知 TODO(不阻塞 v3 上线)
@@ -176,16 +176,16 @@ Tab 状态缓存到 sessionStorage。
## 启动方式
```
# 1. 后端 buildSimpleLite + MiGu.Server
dotnet build SimpleLite\SimpleLite.csproj
# 1. 后端 buildSimple3 + MiGu.Server
dotnet build Simple3\Simple3.csproj
dotnet build MiGu.Server\MiGu.Server.csproj
# 2. 前端打包并同步到 wwwroot
.\build-platform-frontend.bat
# 3. 启动平台 + SimpleLite
# 3. 启动平台 + Simple3
dotnet run --project MiGu.Server # 监听 0.0.0.0:8080
# SimpleLite 端口 8222/8223 由 SimpleLite 自己启动
# Simple3 端口 8222/8223 由 Simple3 自己启动
# 4. 访问
http://127.0.0.1:8080/admin/map-editor # 地图编辑(v3 四栏壳)
+18 -18
View File
@@ -1,14 +1,14 @@
# Migu2.0
迷毂智能调度平台主仓库(平台后端 + 平台前端)。
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `SimpleLite` 仍在相邻仓库 `../Simple` 中维护。
该仓库承接原 `Simple` 仓库中的平台层能力,当前以 `MiGu.Server` 作为统一入口;调度内核 `Simple3` 仍在相邻仓库 `../Simple` 中维护。
## 仓库定位
- **平台后端**`MiGu.Server`ASP.NET Core 8 + YARP,默认 `:8080`
- **平台前端**`frontends/apps/simple-platform-vue`Vue 3 + Vite
- **调度内核**`SimpleLite`(不在本仓库,位于 `../Simple/SimpleLite`
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `SimpleLite.exe`
- **调度内核**`Simple3`(不在本仓库,位于 `../Simple/Simple3`
- **运行模式**:用户登录后由 `MiGu.Server` 按启动模式拉起 `Simple3.exe`
## 目录结构
@@ -16,11 +16,11 @@
Migu2.0/
├── MiGu.Server/ # 平台后端入口工程
│ ├── Controllers/ # Auth/Config/Ops/Health 等 API
│ ├── Launcher/ # SimpleLite 子进程拉起与健康探测
│ ├── Launcher/ # Simple3 子进程拉起与健康探测
│ ├── Auth/ # JWT / 用户鉴权 / 内部 token
│ ├── Configs/ # 平台配置中心模型与存取
│ ├── wwwroot/ # 已构建前端静态资源(部署用)
│ ├── appsettings.json # 启动、YARP、SimpleLite 路径配置
│ ├── appsettings.json # 启动、YARP、Simple3 路径配置
│ └── README.md # 后端详细说明
├── frontends/ # 平台前端源码 workspace
│ └── apps/simple-platform-vue/
@@ -31,35 +31,35 @@ Migu2.0/
└── Doc/
```
## 与 SimpleLite 的关系
## 与 Simple3 的关系
`Migu2.0``SimpleLite` 是分仓协作关系:
`Migu2.0``Simple3` 是分仓协作关系:
- `MiGu.Server` 提供登录、权限、配置中心、平台 UI 托管与网关能力
- `SimpleLite` 提供调度内核、投影接口、3D/仿真运行时能力
- `Simple3` 提供调度内核、投影接口、3D/仿真运行时能力
- 两者通过以下链路协作:
- **进程编排**`MiGu.Server` 登录后调用 `SimpleLiteLauncher` 拉起 `SimpleLite.exe`
- **进程编排**`MiGu.Server` 登录后调用 `Simple3Launcher` 拉起 `Simple3.exe`
- **接口代理**`/api/sl/*` 通过 YARP 转发到 `http://127.0.0.1:8222/`
- **3D 页面代理**`/vr/*` 转发到 `http://127.0.0.1:8223/`
默认开发路径(可改)在 `MiGu.Server/appsettings.json`
```json
"SimpleLite": {
"Simple3": {
"Enabled": true,
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
"ExecutablePath": "..\\..\\Simple\\Simple3\\bin\\Debug\\Simple3.exe",
"WorkingDirectory": "..\\..\\Simple\\Simple3\\bin\\Debug",
"ProjectionPort": 8222
}
```
## 快速启动(开发机)
### 1) 先编译 SimpleLite
### 1) 先编译 Simple3
```powershell
cd ..\Simple
dotnet build SimpleLite\SimpleLite.csproj -c Debug
dotnet build Simple3\Simple3.csproj -c Debug
```
### 2) 启动 MiGu.Server
@@ -74,7 +74,7 @@ dotnet run
- `admin / admin`
- `ops / ops`
> 仅调试平台页面、不拉起内核:将 `MiGu.Server/appsettings.json``SimpleLite.Enabled` 改为 `false`
> 仅调试平台页面、不拉起内核:将 `MiGu.Server/appsettings.json``Simple3.Enabled` 改为 `false`
## 前端构建与发布
@@ -106,7 +106,7 @@ cd E:\Work\Core\Simple-FR\Migu2.0
- `Jwt.Secret`
- `Internal.Token`
- `Auth.Users.*.Password`
- `SimpleLite.ExecutablePath` / `WorkingDirectory`(按部署实际路径)
- `Simple3.ExecutablePath` / `WorkingDirectory`(按部署实际路径)
推荐通过环境变量覆盖(`__` 表示层级):
@@ -119,8 +119,8 @@ $env:Auth__Users__admin__Password = "<strong-password>"
## 常见检查点
- `http://localhost:8080/api/health`:后端健康
- `http://localhost:8080/api/health/simplelite`SimpleLite 路径与端口诊断
- 访问平台时若出现 `502`:通常表示 `SimpleLite``8222` 未就绪或未启动
- `http://localhost:8080/api/health/simple3`Simple3 路径与端口诊断
- 访问平台时若出现 `502`:通常表示 `Simple3``8222` 未就绪或未启动
## 相关文档
+6 -6
View File
@@ -5,7 +5,7 @@
> 通过路由前缀 `/admin/*``/monitor/*` 切换,后续可按 ARCHITECTURE.md §13 拆分为独立工程。
>
> 落地范围与依据:本目录是 [ARCHITECTURE.md](../ARCHITECTURE.md) v1.7 §6/§9/§13/§17/§18 中前端形态的初版骨架;
> 真实业务接入由 `Platform.Server`(同仓库新增的 .NET 8 工程)反代 SimpleLite 完成。
> 真实业务接入由 `Platform.Server`(同仓库新增的 .NET 8 工程)反代 Simple3 完成。
>
> 品牌主色:默认 **工业紫** `#6a1b9a`(6 套主题可切换),详见 ARCHITECTURE.md §17.7。
> 工程包名 `simple-platform-vue` / 仓库目录 `frontends/apps/simple-platform-vue` 保持不动,避免破坏 pnpm/CI 脚本,仅用户可见文案统一为「迷毂」。
@@ -82,7 +82,7 @@ pnpm dev
## 地图监控页与 webVRender
地图监控页(`/admin/map-monitor``/monitor/map`)通过 `<Workspace3D>` 组件以 iframe 嵌入
SimpleLite WebTerminal 提供的 webVRender,默认 URL
Simple3 WebTerminal 提供的 webVRender,默认 URL
```
http://localhost:8223/?scope=Platform&token=<jwt>&ro=0 # 管理员,可交互
@@ -93,10 +93,10 @@ http://localhost:8223/?scope=RCSMonitor&token=<jwt>&ro=1 # 运营,只读
### iframe 嵌入注意事项(CORS / X-Frame-Options
- 当前 SimpleLite WebTerminal 暂未声明 `X-Frame-Options`/`Content-Security-Policy: frame-ancestors`
- 当前 Simple3 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)。
- 若浏览器控制台报「Refused to display 'http://localhost:8223/' in a frame」,请在 Simple3 WebTerminal
侧加上 `X-Frame-Options: ALLOWALL``Content-Security-Policy: frame-ancestors *`(本轮代码未改动 Simple3)。
- 若需通过 `Platform.Server` 走同源代理,可以让 iframe 指向 `http://localhost:8080/vr/`,由 YARP
转发到 `http://127.0.0.1:8223/`(见 `Platform.Server/appsettings.json``vrender-route`)。
@@ -107,7 +107,7 @@ http://localhost:8223/?scope=RCSMonitor&token=<jwt>&ro=1 # 运营,只读
- `/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/*` → YARP 反代 Simple3 WebAPI `http://127.0.0.1:8222/`
- `/api/sl/ops/execute``/api/sl/ops/audits``Platform.Server` `OpsController`(白名单网关占位)
### Mock 开关:`VITE_USE_MOCK`
-1
View File
@@ -80,7 +80,6 @@ declare module 'vue' {
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']
@@ -3,7 +3,7 @@ import http from './http'
/**
* AI API/ axios fetch SSE
* EventSource GET Authorization headerfetch
* axios Cookie + Bearer + X-Scope SimpleLite `Web/Assistant/AssistantApi.cs`
* axios Cookie + Bearer + X-Scope Simple3 `Web/Assistant/AssistantApi.cs`
*/
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
@@ -120,7 +120,7 @@ export async function streamChat(
if (!resp.ok || !resp.body) {
let msg = `请求失败(HTTP ${resp.status}`
if (resp.status === 403) msg = '无权使用 AI 助手(需要 Platform 权限)。'
else if (resp.status === 502 || resp.status === 504) msg = 'SimpleLite 未连接:请先启动后端(端口 8222)。'
else if (resp.status === 502 || resp.status === 504) msg = 'Simple3 未连接:请先启动后端(端口 8222)。'
else {
try {
const t = await resp.text()
@@ -20,7 +20,7 @@ export async function listDeliveries(opts?: {
return Array.isArray(data) ? data : []
}
/** CDM 任务快照订阅结果:来自平台库 cdm_tasksSimpleLite 关闭时仍可读,含完整历史)。 */
/** CDM 任务快照订阅结果:来自平台库 cdm_tasksSimple3 关闭时仍可读,含完整历史)。 */
export interface CdmTaskFeed {
online: boolean
lastSyncAt: string | null
@@ -32,7 +32,7 @@ export async function fetchFleetHealth(): Promise<FleetHealthRow[]> {
await new Promise((r) => setTimeout(r, 120))
return mockFleetHealth()
}
// 平台侧聚合:SimpleLite 指标 + WatchDog(:9776) TCP RTT。
// 平台侧聚合:Simple3 指标 + WatchDog(:9776) TCP RTT。
// 不再直打 /sl/projection/fleet/health(其探测车载 :8081,现场多数未开导致假超时 2000ms)。
try {
const { data } = await http.get<FleetHealthRow[]>('/fleet/health')
@@ -7,7 +7,7 @@ export interface HealthInfo {
uptimeSec: number
}
export interface SimpleLiteDiagnostics {
export interface Simple3Diagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
@@ -18,7 +18,7 @@ export interface SimpleLiteDiagnostics {
deployHint?: string | null
}
export interface SimpleLiteLaunchResult {
export interface Simple3LaunchResult {
started: boolean
status: string
detail: string
@@ -30,29 +30,26 @@ export function getHealth() {
return http.get<HealthInfo>('/health')
}
export function getSimpleLiteDiagnostics() {
return http.get<SimpleLiteDiagnostics>('/health/simplelite')
export function getSimple3Diagnostics() {
return http.get<Simple3Diagnostics>('/health/simple3')
}
export function stopSimpleLite() {
return http.post<{ killed: number; diagnostics: SimpleLiteDiagnostics }>('/health/simplelite/stop')
export function stopSimple3() {
return http.post<{ killed: number; diagnostics: Simple3Diagnostics }>('/health/simple3/stop')
}
export function restartSimpleLite(launchMode: LaunchMode) {
return http.post<{ restart: SimpleLiteLaunchResult; diagnostics: SimpleLiteDiagnostics }>(
'/health/simplelite/restart',
export function restartSimple3(launchMode: LaunchMode) {
return http.post<{ restart: Simple3LaunchResult; diagnostics: Simple3Diagnostics }>(
'/health/simple3/restart',
null,
{ params: { launchMode }, timeout: 60_000 }
)
}
/** 从诊断/会话 runMode 推断重启时使用的 launchMode。 */
/** Simple3 仅 Web;重启一律 WebOnly(保留函数签名供旧调用方)。 */
export function resolveRestartLaunchMode(
lastLaunchMode: string | null | undefined,
runMode: RunMode | null | undefined
_lastLaunchMode?: string | null,
_runMode?: RunMode | null
): LaunchMode {
const mode = (lastLaunchMode ?? '').toLowerCase()
if (mode === 'web') return 'WebOnly'
if (mode === 'web+local') return 'DesktopAndWeb'
return runMode === 'WebOnly' ? 'WebOnly' : 'DesktopAndWeb'
return 'WebOnly'
}
@@ -31,21 +31,21 @@ http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
})
/**
* axios SimpleLite
* axios Simple3
*
* /api/sl/projection/** Platform.Server YARP SimpleLite EmbedIO :8222
* /api/sl/projection/** Platform.Server YARP Simple3 EmbedIO :8222
*
* SimpleLite / YARP 502 504
* SimpleLite SimpleLite ( 8222)
* Simple3 / YARP 502 504
* Simple3 Simple3 ( 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/')
const isSimple3 = url.startsWith('/sl/') || url.startsWith('sl/')
if (status === 502 || status === 504 || err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK') {
if (isSimpleLite) {
return 'SimpleLite 未连接:请先启动 SimpleLite 后端(监听端口 8222)后重试。'
if (isSimple3) {
return 'Simple3 未连接:请先启动 Simple3 后端(监听端口 8222)后重试。'
}
return `网关无法连接到下游服务(${status ?? err.code ?? 'network'})。`
}
@@ -3,7 +3,7 @@ import http from './http'
/**
* MiGu.Server `LogsController``/api/logs/*`
*
* SimpleLite `Diagnosis.Post / Diagnosis.Log` `log/{日期}/xxx.log`
* Simple3 `Diagnosis.Post / Diagnosis.Log` `log/{日期}/xxx.log`
* DLog / / /
* / /
*
@@ -14,10 +14,10 @@ import http from './http'
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
/** SimpleLite projection 投影 API 统一信封(同 reflection.ts)。 */
/** Simple3 projection 投影 API 统一信封(同 reflection.ts)。 */
interface SlEnvelope<T> { success: boolean; code: number; data: T | null; message: string }
/** 经 YARP 反代到 SimpleLite EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
/** 经 YARP 反代到 Simple3 EmbedIO 的诊断投影端点(/api/sl/projection/diagnosis)。 */
const SL_DIAG = '/sl/projection/diagnosis'
export interface LogEntry {
@@ -47,6 +47,31 @@ export interface LogDayStat {
bytes: number
}
export interface LogDiskStatus {
known: boolean
drive: string
freeGB: number
alertEnabled: boolean
alertGB: number
belowThreshold: boolean
}
export interface LogCleanupConfig {
enabled: boolean
retentionDays: number
checkIntervalHours: number
runOnStartup: boolean
diskAlertEnabled: boolean
diskFreeAlertGB: number
diskCheckIntervalMinutes: number
}
export interface LogCleanupResult {
deletedFiles: number
freedBytes: number
freedMB: number
}
export interface LogOverview {
exists: boolean
root: string | null
@@ -56,6 +81,7 @@ export interface LogOverview {
latestFileTime?: string | null
days?: LogDayStat[]
message?: string
disk?: LogDiskStatus
}
export interface LogFilesResult {
@@ -116,7 +142,7 @@ export interface DigestQuery {
maxPerTag?: number
}
/** 实时诊断单条:对应 SimpleLite 内核 Diagnosis 的内存态 Post/Toast。 */
/** 实时诊断单条:对应 Simple3 内核 Diagnosis 的内存态 Post/Toast。 */
export interface LiveDiagItem {
index: number
time: string
@@ -225,12 +251,25 @@ export interface AnalyzeQuery {
function mockOverview(): LogOverview {
return {
exists: true,
root: 'E:\\...\\SimpleLite\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\SimpleLite\\bin\\Debug',
root: 'E:\\...\\Simple3\\bin\\Debug\\log',
workingDirectory: 'E:\\...\\Simple3\\bin\\Debug',
totalFiles: 3,
totalBytes: 5_233_649,
latestFileTime: new Date().toISOString(),
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }]
days: [{ day: '2026-06-01', files: 3, bytes: 5_233_649 }],
disk: { known: true, drive: 'E:\\', freeGB: 118.7, alertEnabled: true, alertGB: 5, belowThreshold: false }
}
}
function mockCleanupConfig(): LogCleanupConfig {
return {
enabled: true,
retentionDays: 30,
checkIntervalHours: 24,
runOnStartup: true,
diskAlertEnabled: true,
diskFreeAlertGB: 5,
diskCheckIntervalMinutes: 30
}
}
@@ -285,7 +324,7 @@ function mockLiveDiagnosis(): LiveDiagnosis {
{ index: 0, time: iso(2), tag: 'Persistence', tagged: true, content: 'flush 4 entities ok' },
{ index: 1, time: iso(4), tag: 'Dispatch', tagged: true, content: 'dispatch loop 50Hz online' },
{ index: 2, time: iso(6), tag: 'InternalAuth', tagged: true, content: '仅放行本机回环(无 internal token 配置)' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[SimpleLite] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 3, time: iso(1), tag: '', tagged: false, content: '[Simple3] Projection API listening on http://127.0.0.1:8222/projection/' },
{ index: 4, time: iso(8), tag: '', tagged: false, content: 'loaded 12 sites, 18 tracks' }
]
const taggedCount = items.filter((i) => i.tagged).length
@@ -373,7 +412,7 @@ export const logsApi = {
? Promise.resolve(mockDigest())
: http.get<LogDigest>('/logs/digest', { params: q }).then((r) => r.data),
/** 实时内存诊断(SimpleLite Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 SimpleLite 在运行,否则 502。 */
/** 实时内存诊断(Simple3 Diagnosis.GetAllDiagnosis,经 YARP 反代)。需 Simple3 在运行,否则 502。 */
liveDiagnosis: (): Promise<LiveDiagnosis> => MOCK
? Promise.resolve(mockLiveDiagnosis())
: http.get<SlEnvelope<LiveDiagnosis>>(`${SL_DIAG}/all`).then((r) => {
@@ -398,5 +437,17 @@ export const logsApi = {
/** 浏览器直链下载(GET,靠 httpOnly Cookie 鉴权)。 */
downloadUrl: (file: string): string =>
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`
`${API_BASE}/logs/download?file=${encodeURIComponent(file)}`,
cleanupConfig: (): Promise<LogCleanupConfig> => MOCK
? Promise.resolve(mockCleanupConfig())
: http.get<LogCleanupConfig>('/logs/cleanup-config').then((r) => r.data),
saveCleanupConfig: (body: LogCleanupConfig): Promise<LogCleanupConfig> => MOCK
? Promise.resolve({ ...body })
: http.put<LogCleanupConfig>('/logs/cleanup-config', body).then((r) => r.data),
cleanupNow: (): Promise<LogCleanupResult> => MOCK
? Promise.resolve({ deletedFiles: 0, freedBytes: 0, freedMB: 0 })
: http.post<LogCleanupResult>('/logs/cleanup-now').then((r) => r.data)
}
@@ -2,8 +2,8 @@ import type { AxiosError } from 'axios'
import http from './http'
/**
* SimpleLite `MapEditApiController`
* `/sl/projection/map-edit`YARP SimpleLite `/projection/map-edit`
* Simple3 `MapEditApiController`
* `/sl/projection/map-edit`YARP Simple3 `/projection/map-edit`
*
* AI `/sl/projection/ai-config`GET / POST AiConfigController
*
@@ -205,7 +205,7 @@ export const mapEditApi = {
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
/**
* CAD SimpleLite CadAlignService
* CAD Simple3 CadAlignService
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
*/
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
@@ -264,8 +264,15 @@ export const mapEditApi = {
),
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
pick: (opts?: { snap?: boolean }) =>
unwrap<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
pick: (opts?: { snap?: boolean; signal?: AbortSignal }) =>
unwrap<PickResult>(http.post(
`${BASE}/pick`,
{ snap: opts?.snap ?? true },
{ timeout: 0, signal: opts?.signal }
)),
cancelPick: () =>
unwrap<{ cancelled: boolean }>(http.post(`${BASE}/pick/cancel`)),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
@@ -300,9 +307,9 @@ export const mapEditApi = {
}>(http.get(`${BASE}/project/browse`, { params: dir ? { dir } : {} })),
/**
* SimpleLite Windows
* Simple3 Windows
* JSON { path, cancelled }cancelled=true path null
* initialDir SimpleLite
* initialDir Simple3
*/
projectNativePickOpen: (initialDir?: string) =>
unwrap<{ path: string | null; cancelled: boolean }>(
@@ -328,7 +335,7 @@ export const mapEditApi = {
/**
* reactive
* SimpleLite
* Simple3
*/
getViewFilter: () => unwrap<ViewFilterSnapshot>(http.get(`${BASE}/view-filter`)),
@@ -348,7 +355,7 @@ export const aiConfigApi = {
// ──────────────────────────────────────────────────────────────────────────
// 地图管理(固定文件夹 + 按名称列表 / 保存 / 删除 / 使用 / 编辑打开)
// 对应 SimpleLite MapEditApiController 的 /map-edit/maps* 接口。
// 对应 Simple3 MapEditApiController 的 /map-edit/maps* 接口。
// ──────────────────────────────────────────────────────────────────────────
export interface MapListItem {
@@ -11,6 +11,9 @@ export interface OpsExecuteReq {
targetId: string
reason?: string
idempotencyKey?: string
method?: string
params?: Record<string, string>
siteId?: number
}
export interface OpsExecuteResp {
@@ -6,7 +6,7 @@ import type { Mission, MissionStatus } from '@/types/mission'
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
/** true 使 Mock YARP SimpleLite :8222
/** true 使 Mock YARP Simple3 :8222
* auth.ts / config.ts / ops.ts VITE_USE_MOCK VITE_PROJECTION_MOCK */
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
@@ -114,7 +114,7 @@ async function enrichCarsRuntime(cars: Car[]): Promise<Car[]> {
)
}
/** 优先走 SimpleLite /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
/** 优先走 Simple3 /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
export async function listCars(): Promise<Car[]> {
if (MOCK) return mockCars()
try {
@@ -19,10 +19,10 @@ import {
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/**
* SimpleLite `ReflectionApiController`
* `/sl/projection/reflection` YARP SimpleLite EmbedIO
* Simple3 `ReflectionApiController`
* `/sl/projection/reflection` YARP Simple3 EmbedIO
*
* SimpleLite plugins/*.dll Standard
* Simple3 plugins/*.dll Standard
* `MethodMember` Car/Mission/CarProgram/Site/Track
*
*/
@@ -39,7 +39,7 @@ export type ReflectionKind =
| 'map'
| 'special'
| 'script'
// 顶级合成 kind:等于 site + track + special 的并集,由 SimpleLite 后端 /objects/scene 直接返回,
// 顶级合成 kind:等于 site + track + special 的并集,由 Simple3 后端 /objects/scene 直接返回,
// 每行带 subKind 字段,前端 bundle / execute / setField 走对应底层 kind。
| 'scene'
@@ -167,7 +167,7 @@ export interface ScriptExceptionStatusPayload {
// 地图监控可见性配置(与后端 MonitorVisibilityConfig / MonitorVisibilityForKind 对应)
//
// `config` = 管理员勾选的白名单。空数组 = 显示全部;非空 = 仅显示列表中的 key。
// `available` = 当前 SimpleLite 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
// `available` = 当前 Simple3 进程里能扫描到的全部可勾选项(基类 + 已加载子类 +
// 运行时实例的 Prop.fields / status 反射键)。是 GET 返回的副产物,
// POST 写入时不需要、也不该回传。
// ──────────────────────────────────────────────────────────────────────────
@@ -253,7 +253,7 @@ export function formatReflectionExecuteMessage(
): string {
if (result.returnValue) return `已执行:${result.returnValue}`
if (result.accepted && result.completed === false) {
return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)`
return `已受理:${label}(后台继续执行,请稍后在 Simple3 查看结果)`
}
if (result.accepted) return `已执行 ${label}`
return `已执行 ${label}`
@@ -367,7 +367,7 @@ export const reflectionApi = {
: del<{ kind: string; id: number; deleted: boolean }>(`/objects/${kind}/${id}`),
/**
* SimpleLite ./plugins dll UiDiscoveryCache
* Simple3 ./plugins dll UiDiscoveryCache
* dll
* dll assembly mission/car
*/
@@ -515,7 +515,7 @@ export const reflectionApi = {
return data.data as MonitorConfigSaveBody
},
// 选中同步:让 SimpleLite 3D 场景同步高亮被点击对象
// 选中同步:让 Simple3 3D 场景同步高亮被点击对象
getSelection: () => MOCK
? Promise.resolve<ReflectionSelection>({ kind: null, id: 0, name: '' })
: get<ReflectionSelection>('/selection'),
@@ -685,7 +685,7 @@ export const reflectionApi = {
: post<{ path: string }>('/car-style/save')
}
/** 与 SimpleLite 视口样式对话框 / Configuration.conf.viewport 同步。 */
/** 与 Simple3 视口样式对话框 / Configuration.conf.viewport 同步。 */
export interface ViewportStyleSite {
drawScale: number
dotRadiusNormal: number
@@ -34,7 +34,7 @@ export async function listSignalTables(): Promise<SignalDataListDto> {
if (MOCK) {
return {
signalEnabled: true,
workingDirectory: 'D:\\工作\\stand\\SimpleLite',
workingDirectory: 'D:\\工作\\stand\\Simple3',
tables: [
{ id: 'stations', title: 'PLC机构', category: 'PLC数据管理', fileName: 'stations.json' },
{ id: 'docks', title: '机构工位', category: 'PLC数据管理', fileName: 'station-docks.json' },
@@ -1,7 +1,16 @@
import { reflectionApi } from './reflection'
import { executeOp } from './ops'
import { ReflectionApiError, reflectionApi } from './reflection'
export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
/** 地图监控车辆列表快捷动作 */
export type VehicleQuickAction =
| 'online'
| 'offline'
| 'endTask'
| 'disable'
| 'enable'
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
@@ -9,6 +18,29 @@ const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
blown: 'Blown'
}
/** 结束任务:按车型依次尝试(Dummy=ForceStopClumsy=ForceStopUI,通用=UIIntercept */
const END_TASK_METHODS = ['ForceStopUI', 'ForceStop', 'UIIntercept'] as const
const QUICK_METHOD_MAP: Record<Exclude<VehicleQuickAction, 'endTask'>, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
disable: 'DisableCar',
enable: 'EnableCar'
}
export const VEHICLE_QUICK_ACTIONS: {
value: VehicleQuickAction
label: string
danger?: boolean
confirm: string
}[] = [
{ value: 'online', label: '上线', confirm: '确认将车辆上线?' },
{ value: 'offline', label: '下线', confirm: '确认将车辆下线?下线后不再被任务选中。' },
{ value: 'endTask', label: '结束任务', danger: true, confirm: '确认结束该车当前任务?' },
{ value: 'disable', label: '禁用车辆', danger: true, confirm: '确认禁用该车?禁用后需先启用才能重新上线。' },
{ value: 'enable', label: '启用车辆', confirm: '确认启用该车?启用后仍需上线才能参与调度。' }
]
export async function setVehicleMaintenance(
carId: number,
mode: VehicleMaintenanceMode
@@ -23,6 +55,53 @@ export async function setVehicleMaintenance(
}
}
function isUnsupportedMethodError(err: unknown): boolean {
if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true
const msg = err instanceof Error ? err.message : String(err)
return /method|not\s*found|unsupported|not\s*supported/i.test(msg)
}
async function executeCarMethodViaOps(carId: number, method: string): Promise<void> {
const resp = await executeOp({
opCode: 'ops.car.execute',
targetId: String(carId),
method
})
if (resp.ok) return
throw new Error(resp.message || `执行 ${method} 未成功`)
}
/** 执行车辆快捷动作;失败抛错,由调用方提示。经运维网关以便写入运维记录。 */
export async function executeVehicleQuickAction(
carId: number,
action: VehicleQuickAction
): Promise<void> {
if (!Number.isFinite(carId) || carId <= 0) {
throw new Error('无效车辆 ID')
}
if (action === 'endTask') {
let lastErr: unknown
for (const method of END_TASK_METHODS) {
try {
await executeCarMethodViaOps(carId, method)
return
} catch (err) {
lastErr = err
if (!isUnsupportedMethodError(err) && !isUnconfiguredMethodError(err)) throw err
}
}
if (lastErr) throw lastErr
throw new Error('当前车型不支持结束任务')
}
const method = QUICK_METHOD_MAP[action]
await executeCarMethodViaOps(carId, method)
}
function isUnconfiguredMethodError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err)
return /不在该车型已配置|未配置动作/.test(msg)
}
export function openOnboardWeb(url?: string | null, ip?: string | null): void {
const target = url ?? (ip ? `http://${ip}:8081` : null)
if (!target) return
@@ -1,14 +1,14 @@
import http from './http'
/**
* API SimpleLite `WorkspaceToolbarApiController`
* API Simple3 `WorkspaceToolbarApiController`
*
* 33 SimpleLite iframe ImGui (`Panel_4` /
* 33 Simple3 iframe ImGui (`Panel_4` /
* `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`) 8 iframe
* Vue
* `WorkspaceCanvasToolbar.vue` HTTP API
*
* `/sl/projection/toolbar` (Platform.Server YARP SimpleLite EmbedIO :8222)
* `/sl/projection/toolbar` (Platform.Server YARP Simple3 EmbedIO :8222)
*/
const BASE = '/sl/projection/toolbar'
@@ -155,7 +155,7 @@ export const workspaceToolbarApi = {
/**
* + 2D
* SimpleLite `WorkspaceToolbarApiController.LocateCamera` = +
* Simple3 `WorkspaceToolbarApiController.LocateCamera` = +
*/
locateCamera: (carId: number) =>
unwrap<ToolbarState>(
@@ -1,6 +1,18 @@
<template>
<PermissionGuard widget-id="ConfigCenter">
<el-card shadow="never">
<slot
v-if="$slots.chrome"
name="chrome"
:payload="payload"
:update="update"
:save="save"
:reload="reload"
:saving="saving"
:loading="loading"
:envelope="envelope"
:relative-time="relativeTime"
/>
<el-card v-else shadow="never">
<template #header>
<div class="cpb-header">
<span class="cpb-title">{{ title }}</span>
@@ -9,7 +21,7 @@
<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>
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save()">保存</el-button>
</div>
</template>
@@ -50,11 +62,11 @@ const props = defineProps<{
defaults: T
/** 加载/保存前规范化 payload(如 ops 补全 monitor 字段) */
normalizePayload?: (payload: T) => T
/** 加载完成后二次合并(如从 SimpleLite 拉 monitor */
/** 加载完成后二次合并(如从 Simple3 拉 monitor */
afterLoad?: (payload: T, update: (next: T) => void) => void | Promise<void>
/** 写入 Platform 之前先执行(如先落盘 SimpleLite monitor */
/** 写入 Platform 之前先执行(如先落盘 Simple3 monitor */
beforeSave?: (payload: T) => void | Promise<void>
/** 保存成功后副作用(如再次同步 SimpleLite */
/** 保存成功后副作用(如再次同步 Simple3 */
afterSave?: (payload: T) => void | Promise<void>
}>()
@@ -98,7 +110,8 @@ async function reload(force = false) {
await reloadFromServer(force, force)
}
async function save() {
async function save(opts?: { toast?: boolean }): Promise<boolean> {
const showToast = typeof opts === 'object' && opts && 'toast' in opts ? opts.toast !== false : true
saving.value = true
let body = payload.value
if (props.normalizePayload) body = props.normalizePayload(body)
@@ -109,7 +122,7 @@ async function save() {
} catch (e) {
ElMessage.error(`地图监控配置保存失败:${e instanceof Error ? e.message : String(e)}`)
saving.value = false
return
return false
}
}
@@ -122,14 +135,16 @@ async function save() {
try {
await props.afterSave(body)
} catch (e) {
monitorSyncWarn = `(平台配置已保存,但 SimpleLite 同步失败:${e instanceof Error ? e.message : String(e)}`
monitorSyncWarn = `(平台配置已保存,但 Simple3 同步失败:${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}`)
const extra = props.beforeSave || props.afterSave ? ',地图监控配置已写入 Simple3' : ''
if (showToast) ElMessage.success(`已保存 v${env.version}${extra}${monitorSyncWarn}`)
return true
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
return false
} finally {
saving.value = false
}
@@ -9,18 +9,26 @@
<el-empty v-else class="pg-hidden">
<template #description>
<p class="pg-hidden-title">当前账号无权查看此功能</p>
<p class="pg-hidden-hint">控件{{ widgetId }}若应为管理员可见请尝试顶部切换管理员/运营或刷新页面</p>
<p class="pg-hidden-hint">控件{{ widgetId }}请在权限与角色中为该账号开放对应页面后重新登录</p>
</template>
</el-empty>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{ widgetId: string }>()
const auth = useAuthStore()
const visibility = computed(() => auth.widgetOf(props.widgetId))
const route = useRoute()
/** 页面已授权则具备该页管理能力,不再被 ConfigCenter 等控件 hidden 整页挡住。 */
const visibility = computed(() => {
const name = typeof route.name === 'string' ? route.name : ''
if (name && auth.hasPage(name)) return 'interactive' as const
return auth.widgetOf(props.widgetId)
})
</script>
<style scoped>
@@ -1,42 +0,0 @@
<template>
<el-radio-group :model-value="current" size="small" @change="onChange">
<el-radio-button value="Platform">管理员</el-radio-button>
<el-radio-button value="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()
// + @change v-model set @change
// switchScope /
// scope effectivePermissions PermissionGuard
const current = computed<Scope>(() => auth.scope ?? 'Platform')
let switching = false
async function onChange(v: string | number | boolean | undefined) {
if (switching) return
switching = true
try {
await switchAndNavigate(v as Scope)
} finally {
switching = false
}
}
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>
@@ -26,7 +26,7 @@
<div v-if="!loaded" class="workspace-3d-mask">
<el-icon class="is-loading" size="32"><Loading /></el-icon>
<span>{{ loadingHint }}</span>
<span class="muted">如长时间未加载请确认 SimpleLite 已以 Web-Enabled 模式启动且 8223 端口可达</span>
<span class="muted">如长时间未加载请确认 Simple3 已以 Web-Enabled 模式启动且 8223 端口可达</span>
</div>
</div>
</div>
@@ -45,7 +45,7 @@ const props = withDefaults(defineProps<{
scope?: Scope
token?: string
readOnly?: boolean
/** 嵌入平台页时传 trueSimpleLite 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
/** 嵌入平台页时传 trueSimple3 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
embedUi?: boolean
/**
* 地图编辑器纯画布模式 embedUi 更裸连底栏都不创建
@@ -116,34 +116,73 @@ function declareEndpoint() {
return null
}
/** 单次 HTTP 探测(带超时),用于 declare / vrenderReady。 */
async function pingVrender(path: string): Promise<boolean> {
try {
const res = await fetch(`${vrenderBaseUrl()}${path}`, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
})
return res.ok
} catch {
return false
/** 单次 HTTP 探测(带超时)。Simple3 把 declare* 挂在 Projection :8222,不再走 FairyView :8223。 */
async function pingDeclare(path: string): Promise<boolean> {
const token = localStorage.getItem('simple.auth.token')
// YARP :8222 /projection/ui 退 Simple3 :8223 LeastServer
const urls = [
`/api/sl${path}`,
`/api/sl/projection/ui${path}`,
`http://${resolvedHost.value}${path}`
]
for (const url of urls) {
try {
const headers: Record<string, string> = {}
if (token && url.startsWith('/')) headers.Authorization = `Bearer ${token}`
const res = await fetch(url, {
method: 'GET',
cache: 'no-store',
credentials: url.startsWith('/') ? 'include' : 'omit',
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
})
if (res.ok) return true
} catch {
/* try next */
}
}
return false
}
/** 画布就绪探测:仍优先打 FairyView :8223iframe 同源),失败再试 Projection 镜像。 */
async function pingVrender(path: string): Promise<boolean> {
const candidates = [
`http://${resolvedHost.value}${path}`,
`/api/sl${path}`,
`/api/sl/projection/ui${path}`
]
const token = localStorage.getItem('simple.auth.token')
for (const url of candidates) {
try {
const headers: Record<string, string> = {}
if (token && url.startsWith('/')) headers.Authorization = `Bearer ${token}`
const res = await fetch(url, {
method: 'GET',
cache: 'no-store',
credentials: url.startsWith('/') ? 'include' : 'omit',
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
})
if (res.ok) return true
} catch {
/* try next */
}
}
return false
}
/**
* 尽力 declare不阻塞 iframeSimpleLite WebTerminal 已默认 canvas-only
* declare 主要用于多 tab 显式标记失败也不影响平台纯画布模式
* 尽力 declareSimple3 须打 Projection :8222 /api/sl不能只打 :8223
*/
async function postDeclareBestEffort() {
const endpoint = declareEndpoint()
if (!endpoint) return
for (let attempt = 0; attempt < DECLARE_MAX_ATTEMPTS; attempt++) {
if (await pingVrender(endpoint)) return
if (await pingDeclare(endpoint)) return
await sleep(80 * (attempt + 1))
}
console.warn(`[Workspace3D] ${endpoint} 未成功,将依赖 SimpleLite 默认 canvas-only 模式`)
console.warn(`[Workspace3D] ${endpoint} 未成功,将依赖内核默认 canvas-only 模式`)
}
/** 非嵌入模式:短轮询等待 8223 就绪后再加载 iframe。 */
@@ -169,7 +208,7 @@ function startLoadWatchdog(generation: number) {
clearLoadWatchdog()
loadWatchdog = setTimeout(() => {
if (generation !== prepareGeneration || loaded.value) return
loadingHint.value = `连接超时 (http://${resolvedHost.value}),请确认 SimpleLite 已启动`
loadingHint.value = `连接超时 (http://${resolvedHost.value}),请确认 Simple3 已启动`
}, LOAD_WATCHDOG_MS)
}
@@ -189,11 +228,17 @@ async function prepareAndLoad(forceReload = false) {
const embedded = needsDeclare()
if (embedded) {
// iframe declare
void postDeclareBestEffort()
// embed declaremigu canvas-only /declareEmbedUi
// canvas-only
if (props.embedUi) {
await postDeclareBestEffort()
if (generation !== prepareGeneration) return
} else {
void postDeclareBestEffort()
}
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
} else {
loadingHint.value = '正在等待 SimpleLite webVRender 就绪 ...'
loadingHint.value = '正在等待 Simple3 webVRender 就绪 ...'
await waitForVrenderReady()
if (generation !== prepareGeneration) return
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
File diff suppressed because it is too large Load Diff
@@ -257,6 +257,7 @@ async function send() {
</script>
<style scoped>
/* Shares --ai-* tokens with AiAssistantDrawer (A/E paper · C amethyst) */
.ai-assistant-panel {
position: absolute;
top: 0;
@@ -265,11 +266,10 @@ async function send() {
z-index: 30;
display: flex;
flex-direction: column;
/* 实底(高不透明),解决「太透明看不清」 */
background: linear-gradient(180deg, rgba(28, 12, 56, 0.98) 0%, rgba(16, 6, 34, 0.99) 100%);
border-left: 1px solid rgba(190, 140, 240, 0.28);
box-shadow: -10px 0 30px rgba(8, 2, 16, 0.55);
color: rgba(236, 224, 250, 0.95);
background: var(--ai-panel-bg);
border-left: 1px solid var(--ai-panel-border);
box-shadow: var(--ai-panel-shadow);
color: var(--ai-bot-fg);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
@@ -281,7 +281,6 @@ async function send() {
visibility: visible;
}
/* 左沿拖拽手柄:覆盖在 border-left 上方,hover 高亮提示可拖拽。 */
.aap-resizer {
position: absolute;
left: 0;
@@ -294,7 +293,7 @@ async function send() {
transition: background 0.15s ease;
touch-action: none;
}
.aap-resizer:hover { background: rgba(190, 140, 240, 0.45); }
.aap-resizer:hover { background: var(--ai-accent-soft); }
.aap-header {
flex: none;
@@ -302,35 +301,46 @@ async function send() {
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.5) 0%, rgba(255, 90, 200, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
padding: 14px 16px;
background: var(--ai-head-bg);
border-bottom: 1px solid var(--ai-head-border);
}
.aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.aap-glyph {
font-size: 20px;
color: #fff;
text-shadow: 0 0 10px rgba(255, 200, 250, 0.7);
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex: none;
color: var(--ai-avatar-bot-fg);
background: var(--ai-avatar-bot-bg);
border: 1px solid var(--ai-avatar-bot-border);
}
.aap-title-text { min-width: 0; }
.aap-title-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; }
.aap-title-main { font-size: 15px; font-weight: 700; color: var(--ai-title); line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: var(--ai-muted); margin-top: 2px; }
.aap-close {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--ai-muted);
width: 28px;
height: 28px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
line-height: 1;
flex: none;
transition: background 0.15s ease;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.aap-close:hover {
background: var(--ai-close-hover-bg);
color: var(--ai-close-hover-fg);
border-color: var(--ai-accent-soft-border);
}
.aap-close:hover { background: rgba(255, 255, 255, 0.24); }
.aap-alert { margin: 10px 12px 0; }
@@ -338,30 +348,33 @@ async function send() {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
scrollbar-width: thin;
scrollbar-color: var(--ai-scrollbar) transparent;
}
.aap-messages::-webkit-scrollbar { width: 6px; }
.aap-messages::-webkit-scrollbar-thumb { background: var(--ai-scrollbar); border-radius: 3px; }
.aap-empty { padding: 8px 2px; display: flex; flex-direction: column; gap: 8px; }
.aap-empty-title { font-size: 12px; color: rgba(210, 188, 240, 0.7); }
.aap-empty-title { font-size: 12px; color: var(--ai-muted); }
.aap-example {
appearance: none;
text-align: left;
border: 1px dashed rgba(190, 140, 240, 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.9);
border-radius: 8px;
padding: 8px 10px;
border: 1px solid var(--ai-example-border);
background: var(--ai-example-bg);
color: var(--ai-example-fg);
border-radius: 10px;
padding: 9px 11px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.aap-example:hover {
background: rgba(150, 90, 230, 0.22);
border-color: rgba(190, 140, 240, 0.7);
color: #fff;
border-color: var(--ai-accent);
transform: translateY(-1px);
}
.aap-msg { display: flex; }
@@ -369,38 +382,38 @@ async function send() {
.aap-msg--assistant { justify-content: flex-start; }
.aap-bubble {
max-width: 86%;
padding: 8px 11px;
border-radius: 12px;
padding: 9px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
line-height: 1.55;
}
.aap-msg--user .aap-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.95) 0%, rgba(120, 70, 220, 0.95) 100%);
color: #fff;
background: var(--ai-user-bg);
color: var(--ai-user-fg);
border-bottom-right-radius: 4px;
}
.aap-msg--assistant .aap-bubble {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.95);
background: var(--ai-bot-bg);
border: 1px solid var(--ai-bot-border);
color: var(--ai-bot-fg);
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.aap-bubble-text { white-space: pre-wrap; word-break: break-word; }
.aap-meta {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.14);
border-top: 1px dashed var(--ai-bot-border);
font-size: 11.5px;
color: rgba(210, 188, 240, 0.8);
color: var(--ai-muted);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-meta-ic { color: #6ee7b7; margin-right: 4px; vertical-align: -2px; }
.aap-meta b { color: var(--ai-accent); }
.aap-meta-ic { color: #10b981; margin-right: 4px; vertical-align: -2px; }
/* assistant 消息 Markdown 渲染 */
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: rgba(236, 224, 250, 0.95); }
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: var(--ai-bot-fg); }
.aap-md :deep(p) { margin: 0 0 7px; }
.aap-md :deep(p:last-child) { margin-bottom: 0; }
.aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: #fff; }
.aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: var(--ai-title); }
.aap-md :deep(h3.mmd-h) { font-size: 14px; }
.aap-md :deep(h4.mmd-h) { font-size: 13px; }
.aap-md :deep(.mmd-ul), .aap-md :deep(.mmd-ol) { margin: 4px 0 7px; padding-left: 18px; }
@@ -408,16 +421,17 @@ async function send() {
.aap-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--ai-md-code-bg);
color: var(--ai-md-code-fg);
border: 1px solid var(--ai-example-border);
border-radius: 5px;
padding: 1px 5px;
}
.aap-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 9px 11px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--ai-md-pre-bg);
border: 1px solid var(--ai-md-pre-border);
border-radius: 8px;
overflow: auto;
max-height: 280px;
@@ -425,22 +439,24 @@ async function send() {
.aap-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
color: var(--ai-bot-fg);
white-space: pre;
}
.aap-md :deep(.mmd-link) { color: var(--mg-accent, #c4a4ff); text-decoration: underline; }
.aap-md :deep(.mmd-link) { color: var(--ai-md-link); text-decoration: underline; }
.aap-md :deep(.mmd-quote) {
margin: 6px 0;
padding: 3px 10px;
border-left: 3px solid rgba(190, 140, 240, 0.6);
color: rgba(220, 205, 250, 0.85);
border-left: 3px solid var(--ai-md-blockquote-border);
color: var(--ai-md-blockquote-fg);
}
.aap-md :deep(strong) { color: #fff; }
.aap-md :deep(strong) { color: var(--ai-title); }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(210, 188, 240, 0.85);
color: var(--ai-thinking-fg);
background: var(--ai-thinking-bg) !important;
border: 1px solid var(--ai-thinking-border) !important;
}
.aap-toolbar {
@@ -448,21 +464,51 @@ async function send() {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 0;
padding: 8px 14px 0;
flex-wrap: wrap;
border-top: 1px solid var(--ai-composer-border);
background: var(--ai-composer-bg);
}
.aap-toolbar-label { font-size: 11.5px; color: rgba(210, 188, 240, 0.7); flex: none; }
.aap-toolbar-label { font-size: 11.5px; color: var(--ai-muted); flex: none; }
.aap-toolbar :deep(.el-radio-button__inner) {
padding: 5px 9px;
font-size: 12px;
background: var(--ai-input-bg);
color: var(--ai-input-fg);
border-color: var(--ai-input-border);
}
.aap-toolbar :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: var(--ai-accent) !important;
border-color: var(--ai-accent) !important;
color: #fff !important;
box-shadow: none !important;
}
.aap-input {
flex: none;
padding: 8px 12px 12px;
padding: 8px 14px 14px;
display: flex;
flex-direction: column;
gap: 8px;
background: var(--ai-composer-bg);
}
.aap-input :deep(.el-textarea__inner) {
background: var(--ai-input-bg) !important;
border: 1px solid var(--ai-input-border) !important;
color: var(--ai-input-fg) !important;
box-shadow: none !important;
border-radius: 10px;
}
.aap-input :deep(.el-textarea__inner::placeholder) {
color: var(--ai-input-placeholder) !important;
}
.aap-input :deep(.el-textarea__inner:focus) {
border-color: var(--ai-accent) !important;
}
.aap-send { align-self: flex-end; min-width: 84px; }
.aap-send.el-button--primary {
background: var(--ai-send-bg) !important;
border-color: transparent !important;
color: var(--ai-send-fg) !important;
}
</style>
@@ -96,7 +96,7 @@
<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" :class="{ 'is-synced': syncFlash }" :title="syncFlash ? '已同步到画布' : '改动会立即同步到 Simple3 画布'">
<span class="prop-card-flag-dot" />
<span class="prop-card-flag-text">{{ syncFlash ? '已同步' : '同步到画布' }}</span>
</span>
@@ -166,7 +166,8 @@
<button
class="prop-add-field-btn"
:disabled="!addableFieldOptions.length"
:disabled="fieldCatalogLoading"
type="button"
@click="openAddFieldDialog"
>
添加字段
@@ -180,12 +181,24 @@
width="420px"
append-to-body
destroy-on-close
align-center
class="add-field-dialog"
>
<p v-if="fieldCatalogError" class="add-field-hint is-warn">
字段目录加载失败{{ fieldCatalogError }}仍可手动输入字段名
</p>
<p v-else-if="!addableFieldOptions.length" class="add-field-hint">
目录中暂无可添加项可能尚未在字段管理配置或已全部加到该对象可手动输入字段名
</p>
<el-select
v-model="addFieldPickKey"
filterable
placeholder="选择要添加的字段"
allow-create
default-first-option
clearable
placeholder="选择或输入字段名"
class="add-field-select"
teleported
>
<el-option
v-for="opt in addableFieldOptions"
@@ -196,7 +209,7 @@
</el-select>
<template #footer>
<el-button @click="addFieldDialogOpen = false">取消</el-button>
<el-button type="primary" :disabled="!addFieldPickKey" @click="confirmAddField">
<el-button type="primary" :disabled="!addFieldPickKey.trim()" @click="confirmAddField">
确定
</el-button>
</template>
@@ -248,7 +261,7 @@
<el-tab-pane label="类型默认" name="defaults">
<div class="defaults-pane">
<el-alert type="info" :closable="false" class="defaults-alert">
视口样式与 SimpleLite视口样式对话框同步修改后即时重绘画布下方新建默认仅影响下次创建对象
视口样式与 Simple3视口样式对话框同步修改后即时重绘画布下方新建默认仅影响下次创建对象
</el-alert>
<div v-if="!selectionKind" class="empty-hint defaults-empty">
@@ -525,9 +538,10 @@ watch(
)
watch(
() => props.selectionKind,
(k) => {
if (k === 'site' || k === 'track') tab.value = 'defaults'
() => [props.selectionKind, props.primary?.id] as const,
([k, id]) => {
// / /
if (id != null && (k === 'site' || k === 'track' || k === 'car')) tab.value = 'object'
}
)
@@ -607,6 +621,8 @@ const carTypeRef = computed(() => props.carType ?? null)
const {
loading: fieldCatalogLoading,
records: fieldCatalogRecords,
lastError: fieldCatalogError,
reload: reloadFieldCatalog,
labelForKey,
defaultValueForKey
} = useSimpleFieldCatalog(selectionKindRef, carTypeRef)
@@ -626,20 +642,18 @@ function customFieldDisplayLabel(key: string, chinese: string): string {
return `${key} - ${cn}`
}
/** 展示已在对象上存在、且在 simple_fields 目录中的扩展字段 */
/** 展示对象上已有的扩展字段;目录有中文名则带上,目录外字段仍可编辑 */
const customFieldRows = computed(() =>
otherFields.value
.filter((f) => catalogKeySet.value.has(f.key))
.map((f) => {
const chinese = labelForKey(f.key)
return {
key: f.key,
value: f.value,
dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
chinese,
label: customFieldDisplayLabel(f.key, chinese)
}
})
otherFields.value.map((f) => {
const chinese = catalogKeySet.value.has(f.key) ? labelForKey(f.key) : ''
return {
key: f.key,
value: f.value,
dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
chinese,
label: customFieldDisplayLabel(f.key, chinese || '')
}
})
)
const addableFieldOptions = computed(() => {
@@ -714,14 +728,23 @@ function onCustomFieldCommit(key: string, value: string) {
onFieldChange(key, value)
}
function openAddFieldDialog() {
async function openAddFieldDialog() {
await reloadFieldCatalog()
addFieldPickKey.value = addableFieldOptions.value[0]?.key ?? ''
addFieldDialogOpen.value = true
}
function confirmAddField() {
const key = addFieldPickKey.value
const key = addFieldPickKey.value.trim()
if (!key) return
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
ElMessage.warning('字段名仅允许字母/数字/下划线,且不能以数字开头')
return
}
if (customFieldRows.value.some((r) => r.key === key) || props.fields.some((f) => f.key === key)) {
ElMessage.warning(`字段 ${key} 已存在`)
return
}
const value = defaultValueForKey(key)
customFieldDrafts.value = { ...customFieldDrafts.value, [key]: value }
addFieldDialogOpen.value = false
@@ -1276,6 +1299,13 @@ function onDelete() {
0 0 0 3px rgba(var(--mg-primary-rgb), 0.14) !important;
}
.add-field-select { width: 100%; }
.add-field-hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.45;
color: var(--me-text-muted);
}
.add-field-hint.is-warn { color: #b45309; }
/* 运行状态:胶囊行 */
.prop-status-list {
@@ -13,14 +13,14 @@
吸附<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b>
</span>
<span class="spacer" />
<span class="seg">历史 {{ undoSize }} / {{ redoSize }}</span>
<span class="seg">可撤销 {{ undoSize }}/{{ undoLimit }} · 可重做 {{ redoSize }}</span>
<span v-if="connected" class="seg ok"> 实时通道在线</span>
<span v-else class="seg warn"> 实时通道断开</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
withDefaults(defineProps<{
selectedCount: number
mouseX: number
mouseY: number
@@ -28,8 +28,11 @@ defineProps<{
snapOn: boolean
undoSize: number
redoSize: number
undoLimit?: number
connected: boolean
}>()
}>(), {
undoLimit: 20
})
function formatXY(v: number) {
if (!Number.isFinite(v)) return '--'
@@ -50,6 +50,8 @@ interface ToolGroup { id: string; title: string; tools: ToolDef[] }
const props = defineProps<{
activeTool: EditToolId
/** 左侧「仅*」互斥过滤模式;与 activeTool 独立,可与绘制工具同时生效。 */
activeSelectOnly?: 'site' | 'straight' | 'curve' | 'decor' | null
}>()
const emit = defineEmits<{
@@ -62,17 +64,10 @@ 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: 'select.byType.site', label: '仅站点', glyph: '·', tip: '画布只能选中站点;再点一次取消' },
{ id: 'select.byType.straight', label: '仅直线', glyph: '─', tip: '仅 UITrack(直线/折线);再点一次取消' },
{ id: 'select.byType.curve', label: '仅曲线', glyph: '∽', tip: '贝塞尔 / 弧 / NURBS;再点一次取消' },
{ id: 'select.byType.decor', label: '仅装饰', glyph: '', tip: '画布只能选中装饰;再点一次取消' }
]
},
{
@@ -80,29 +75,23 @@ const groups: ToolGroup[] = [
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: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或右键退出' },
{ id: 'draw.track.polyline', label: '线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.tracks.continuous', label: '连续直线', glyph: '', tip: '连续点两端站点建直线;右键退出' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 输入中间点个数 → 按邻边自动生成' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点,按邻边自动生成圆弧' },
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T', tip: '输入内容后在画布单击落点' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼', tip: '选择图片后在画布单击落点' }
]
},
{
id: 'transform',
title: '变换',
tools: [
{ id: 'transform.move', label: '移动', glyph: '✥' },
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
{ id: 'transform.scale', label: '缩放', glyph: '⤡' },
{ 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: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V,相对原位置偏移 200mm' },
{ id: 'transform.duplicate', label: '复制副本', glyph: '', tip: 'Ctrl+D' },
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' }
]
},
{
@@ -111,7 +100,7 @@ const groups: ToolGroup[] = [
tools: [
{ id: 'align.left', label: '左对齐', glyph: '⫷' },
{ id: 'align.right', label: '右对齐', glyph: '⫸' },
{ id: 'align.top', label: '上对齐', glyph: '' },
{ id: 'align.top', label: '上对齐', glyph: '' },
{ id: 'align.bottom', label: '下对齐', glyph: '⫶' },
{ id: 'align.centerH', label: '水平居中', glyph: '⇔' },
{ id: 'align.centerV', label: '垂直居中', glyph: '⇕' },
@@ -143,7 +132,13 @@ const groups: ToolGroup[] = [
}
]
function isActive(id: EditToolId) { return id === props.activeTool }
function isActive(id: EditToolId) {
if (id.startsWith('select.byType.')) {
const t = id.replace('select.byType.', '')
return props.activeSelectOnly === t
}
return id === props.activeTool
}
function onClick(id: EditToolId) { emit('select-tool', id) }
</script>
@@ -10,14 +10,10 @@
</el-button>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn"></el-button>
<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.png"> 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>
@@ -25,109 +21,67 @@
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">编辑</el-button>
<el-button text class="topbar-btn">
撤销
<span class="topbar-count">{{ undoCount }}/{{ undoLimit }}</span>
</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>
<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-item command="edit.undo" :disabled="!canUndo">
撤销 (Ctrl+Z) · {{ undoCount }}/{{ undoLimit }}
</el-dropdown-item>
<el-dropdown-item command="edit.redo" :disabled="!canRedo">
重做 (Ctrl+Y) · {{ redoCount }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 编辑 / 视图 / 图层 / 过滤 已从顶栏移除过滤由左侧* MapEditorView 驱动 -->
<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 :content="`撤销 (Ctrl+Z) · ${undoCount}/${undoLimit}`" placement="bottom">
<el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')">
<span v-if="undoCount > 0" class="topbar-badge">{{ undoCount }}</span>
</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 :content="`重做 (Ctrl+Y) · ${redoCount}`" placement="bottom">
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')">
<span v-if="redoCount > 0" class="topbar-badge">{{ redoCount }}</span>
</el-button>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
export interface ViewFilterState {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
selectFilter: {
sites: boolean
tracks: boolean
cars: boolean
decor: boolean
trackMode?: 'all' | 'straight' | 'curve'
}
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
}
defineProps<{
withDefaults(defineProps<{
canUndo: boolean
canRedo: boolean
saving?: boolean
// MapEditorView
viewFilter: ViewFilterState
}>()
undoCount?: number
redoCount?: number
undoLimit?: number
/** 保留类型兼容;顶栏「过滤」菜单已移除。 */
viewFilter?: ViewFilterState
}>(), {
undoCount: 0,
redoCount: 0,
undoLimit: 20
})
const emit = defineEmits<{
(e: 'cmd', cmd: string): void
@@ -136,10 +90,6 @@ const emit = defineEmits<{
function onCmd(cmd: string) {
emit('cmd', cmd)
}
function mark(on: boolean): string {
return on ? '☑' : '☐'
}
</script>
<style scoped>
@@ -154,7 +104,6 @@ function mark(on: boolean): string {
box-shadow: 0 2px 8px rgba(var(--mg-shadow-rgb, 22, 58, 74), 0.08);
}
.spacer { flex: 1; }
.el-divider--vertical { margin: 0 6px; background: var(--me-border); }
.topbar-btn {
color: var(--me-text) !important;
@@ -169,23 +118,43 @@ function mark(on: boolean): string {
color: var(--mg-primary) !important;
background: var(--me-hover) !important;
}
.topbar-btn:focus {
background: var(--me-hover) !important;
.topbar-count {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
opacity: 0.72;
font-variant-numeric: tabular-nums;
}
.topbar-icon-btn {
position: relative;
color: var(--me-text) !important;
background: var(--me-surface) !important;
border: 1px solid var(--me-border) !important;
font-size: 15px;
min-width: 36px;
}
.topbar-icon-btn:hover:not(.is-disabled) {
background: var(--me-hover) !important;
border-color: rgba(var(--mg-primary-rgb), 0.55) !important;
color: var(--mg-primary) !important;
}
.topbar-icon-btn.is-disabled {
opacity: 0.4;
.topbar-icon-btn.is-disabled { opacity: 0.4; }
.topbar-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--mg-primary);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.topbar-save-btn {
@@ -204,15 +173,4 @@ function mark(on: boolean): string {
--el-button-text-color: #fff !important;
--el-button-hover-text-color: #fff !important;
}
.topbar-filter-menu :deep(.topbar-filter-header) {
font-size: 11px;
color: var(--me-text-muted);
cursor: default;
pointer-events: none;
padding-top: 6px;
padding-bottom: 2px;
text-transform: none;
letter-spacing: 0.4px;
}
</style>
@@ -18,7 +18,7 @@
<button class="proj-icon-btn" title="刷新" @click="refresh">
<span class="proj-icon"></span>
</button>
<button class="proj-icon-btn" title="SimpleLite 主目录" @click="goCwd">
<button class="proj-icon-btn" title="Simple3 主目录" @click="goCwd">
<span class="proj-icon"></span>
</button>
<el-input
@@ -52,7 +52,7 @@
<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>
<span class="proj-side-text">Simple3 主目录</span>
</button>
<div v-if="parent" class="proj-side-section">
<button class="proj-side-btn" @click="goUp">
@@ -167,7 +167,7 @@ const props = defineProps<{
modelValue: boolean
/** 'load' 用于「加载文件」对话框;'save' 用于「另存为」对话框(可选中 .json 或新建文件名)。 */
mode: 'load' | 'save'
/** 上次记住的路径,用于打开时初始定位。空字符串则用 SimpleLite CWD。 */
/** 上次记住的路径,用于打开时初始定位。空字符串则用 Simple3 CWD。 */
initialPath?: string
}>()
@@ -12,7 +12,7 @@
<div v-if="!collapsed">
<el-empty
v-if="!loading && plugins.length === 0"
description="尚未通过 PluginManager 加载任何插件 (重启 SimpleLite 后会自动迁移现有 plugins/ 内的 dll)"
description="尚未通过 PluginManager 加载任何插件 (重启 Simple3 后会自动迁移现有 plugins/ 内的 dll)"
/>
<el-table
v-else
@@ -44,7 +44,7 @@
<el-tag v-if="row.collectible" size="small" type="success">可卸载</el-tag>
<el-tooltip
v-else
content="此插件被加到 default ALC(旧路径加载),重启 SimpleLite 后会自动迁移到 collectible"
content="此插件被加到 default ALC(旧路径加载),重启 Simple3 后会自动迁移到 collectible"
placement="top"
>
<el-tag size="small" type="warning">不可卸载</el-tag>
@@ -98,7 +98,7 @@ async function refresh() {
try {
plugins.value = await reflectionApi.listPlugins()
} catch (err) {
ElMessage.warning(`插件列表加载失败:${(err as Error).message}(旧版 SimpleLite 可能未带 /plugins 接口,请重启)`)
ElMessage.warning(`插件列表加载失败:${(err as Error).message}(旧版 Simple3 可能未带 /plugins 接口,请重启)`)
} finally {
loading.value = false
}
@@ -150,7 +150,7 @@
>
查看对象
</el-button>
<!-- 脚本管理专用 SimpleLite 工作台脚本表格行内按钮组对齐
<!-- 脚本管理专用 Simple3 工作台脚本表格行内按钮组对齐
选不选中都能直接点行内查看脚本 / 查看异常状态弹窗 -->
<template v-if="showScriptActions && kind === 'script'">
<el-button
@@ -492,7 +492,7 @@
* site/track/image/text/model 走弹窗表单收集 x/y/siteA/siteB
* - 删除reflectionApi.deleteObject
* - 方法调用reflectionApi.execute带参时弹 prompt 逐项收集
* - 3D 高亮reflectionApi.setSelection SimpleLite 工作台同步选中
* - 3D 高亮reflectionApi.setSelection Simple3 工作台同步选中
*
* SSE 订阅object-created/deleted/patched/batch-changed 都会自动 refresh
*/
@@ -556,7 +556,7 @@ const props = defineProps<{
* 列表状态列取自运行状态反射中的 key如进程 Mission status
*/
statusReflectionKey?: string
/** 脚本管理专用:在动作区追加 SimpleLite 工作台同款「查看脚本 / 查看异常状态」。 */
/** 脚本管理专用:在动作区追加 Simple3 工作台同款「查看脚本 / 查看异常状态」。 */
showScriptActions?: boolean
/** 增删改后是否自动写回项目 JSON。默认车辆/场景/进程启用。 */
autoSaveProject?: boolean
@@ -0,0 +1,115 @@
<template>
<div v-if="visible" class="wz-setup-bar" role="status">
<div class="bar-left">
<span class="bar-kicker">配置向导</span>
<span class="bar-step">{{ current.index }}/{{ total }} · {{ current.title }}</span>
<span class="bar-hint">{{ current.hint }}</span>
</div>
<div class="bar-right">
<button type="button" class="bar-btn" @click="backToWizard">返回向导</button>
<button v-if="nextId" type="button" class="bar-btn primary" @click="goNext">下一步</button>
<button v-else type="button" class="bar-btn primary" @click="backToWizard">完成向导</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
isWizardSetupActive,
loadWizardDraft,
nextStep,
saveWizardDraft,
stepDef,
WIZARD_STEPS
} from '@/utils/wizardSetup'
const route = useRoute()
const router = useRouter()
const visible = ref(false)
const draft = ref(loadWizardDraft())
const total = WIZARD_STEPS.length
const current = computed(() => stepDef(draft.value.step))
const nextId = computed(() => nextStep(draft.value.step))
function refresh() {
visible.value = isWizardSetupActive()
draft.value = loadWizardDraft()
}
function backToWizard() {
router.push({ name: 'wizard' })
}
function goNext() {
const n = nextId.value
if (!n) {
backToWizard()
return
}
const next = { ...draft.value, step: n, inSetup: true }
saveWizardDraft(next)
draft.value = next
router.push({ name: 'wizard' })
}
onMounted(refresh)
watch(() => route.fullPath, refresh)
</script>
<style scoped>
.wz-setup-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 14px;
margin: 0 8px 8px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(117, 67, 232, 0.16), rgba(155, 124, 255, 0.1));
border: 1px solid rgba(117, 67, 232, 0.28);
color: #28213a;
}
.bar-left {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.bar-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: #7543e8;
text-transform: uppercase;
}
.bar-step { font-size: 13px; font-weight: 650; }
.bar-hint {
font-size: 12px;
color: #756d85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-right { display: flex; gap: 8px; flex: none; }
.bar-btn {
appearance: none;
height: 28px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(40, 33, 58, 0.12);
background: #fff;
color: #28213a;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.bar-btn.primary {
background: #7543e8;
border-color: transparent;
color: #fff;
}
</style>
@@ -97,42 +97,25 @@
<section class="card card--actions">
<header class="card-hd">
<span>{{ readOnly ? '运维动作' : '动作' }}</span>
<span v-if="!readOnly && carActions.length" class="meta">{{ carActions.length }}</span>
<span v-else-if="readOnly && opsCarActions.length" class="meta">{{ opsCarActions.length }}</span>
<span>动作</span>
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
</header>
<template v-if="readOnly">
<div v-if="!opsCarActions.length" class="muted-line">当前账号无可执行的运维动作</div>
<div v-else class="action-grid action-grid--vehicle">
<button
v-for="op in opsCarActions"
:key="op.code"
type="button"
class="action-btn"
:class="{ 'action-btn--goto': op.needConfirm }"
:disabled="executing === op.code"
:title="op.description"
@click="onOpsExecute(op)">
{{ op.label }}
</button>
</div>
</template>
<template v-else>
<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>
</template>
<div v-if="readOnly && !canRunConfiguredCarOps" class="muted-line">当前账号无可执行的运维动作</div>
<div v-else-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"
:title="m.description || m.hint || undefined"
@click="onExecute(m)">
{{ m.label || m.methodName }}
</button>
</div>
</section>
</template>
@@ -208,7 +191,7 @@ import {
pickTargetSiteOnMap,
type SitePickOption
} from '@/utils/carActionExecute'
import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
import { OPS_WHITELIST } from '@/types/ops'
import { executeOp } from '@/api/ops'
import { useAuthStore } from '@/stores/auth'
import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache'
@@ -217,6 +200,7 @@ import {
extractSocFromKv,
socToPercent
} from '@/utils/batterySoc'
import { formatTrafficStatus as formatTrafficStatusText } from '@/utils/carRuntime'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
@@ -228,7 +212,7 @@ const props = defineProps<{
refreshKey?: number
cars?: Car[]
missions?: Mission[]
/** 运营端只读模式3D 不可编辑,车辆动作改用运维白名单(executeOp + 审计),并跳过 reflection 动作/配置加载。 */
/** 只读3D 不可编辑,站点/路径编辑区隐藏。车辆动作无论是否只读都走运维网关并记审计。 */
readOnly?: boolean
}>()
@@ -406,36 +390,8 @@ function formatCarPosition(): string {
}
function formatTrafficStatus(): string {
const tc = pickStatus('TCStat') || pickField('TCStat')
if (tc && tc !== '/' && tc.trim() !== '') return tc
const blockedRaw = pickStatus('blockedBy')
if (blockedRaw && blockedRaw !== '[]' && blockedRaw !== '/') {
try {
const arr = JSON.parse(blockedRaw) as Array<{ carid?: number; type?: number }>
if (Array.isArray(arr) && arr.length > 0) {
const ids = arr.map((b) => b.carid).filter((id): id is number => id != null)
if (ids.length) return `被车辆 ${ids.join('、')} 交管`
return '交管阻塞'
}
} catch {
return blockedRaw.length > 48 ? `${blockedRaw.slice(0, 48)}` : blockedRaw
}
}
const stopped = pickStatus('trafficStopped')
if (stopped && stopped !== '(0, 0)' && stopped !== '(0,0)' && stopped !== '/') {
return `交管暂停 ${stopped}`
}
const holding = pickStatus('holdingLocks')
const pending = pickStatus('pendingLocks')
const parts: string[] = []
if (holding && holding !== '[]') parts.push(`已占 ${holding}`)
if (pending && pending !== '[]') parts.push(`待锁 ${pending}`)
if (parts.length) return parts.join('')
return '正常'
// / · · X
return formatTrafficStatusText(statusRows.value)
}
function formatAlarmStatus(): { text: string; warn: boolean } {
@@ -495,36 +451,11 @@ const carActions = computed(() => {
return allMethods.value.filter((m) => set.has(m.methodName))
})
/** 运营端只读模式下车辆动作改用运维白名单(按当前账号权限过滤)。 */
const opsCarActions = computed<OpsAction[]>(() =>
OPS_WHITELIST.filter((o) => o.target === 'car' && auth.hasOp(o.code))
)
async function onOpsExecute(op: OpsAction) {
const idNum = Number(props.selection?.id)
if (!Number.isFinite(idNum)) return
if (op.needConfirm) {
try {
await ElMessageBox.confirm(
`确认执行 [${op.label}]\n目标车辆:${props.selection?.id}`,
'二次确认',
{ type: 'warning' }
)
} catch {
return
}
}
executing.value = op.code
try {
const resp = await executeOp({ opCode: op.code, targetId: String(idNum), reason: '' })
if (resp.ok) ElMessage.success(`已执行 ${op.label}auditId=${resp.auditId}`)
else ElMessage.warning(resp.message || `执行未成功:${op.label}`)
} catch (e) {
ElMessage.error(`执行失败:${(e as Error).message}`)
} finally {
executing.value = null
}
}
const canRunConfiguredCarOps = computed(() => {
if (!props.readOnly) return true
if (auth.hasOp('*')) return true
return OPS_WHITELIST.some((o) => o.target === 'car' && auth.hasOp(o.code))
})
const carActionHint = computed(() => {
if (!monitorConfigLoaded.value) return '加载配置中…'
@@ -625,15 +556,6 @@ async function loadAll() {
else actionsLoading.value = true
try {
if (props.readOnly) {
// bundle reflection /
// findCarInList() cars
const bundle = await reflectionApi.getBundle(rk, idNum)
applyBundle(bundle, props.selection.name ?? '')
allMethods.value = []
hydrated.value = true
return
}
await loadMonitorRuntimeConfig(false)
const bundle = await reflectionApi.getBundle(rk, idNum)
applyBundle(bundle, props.selection.name ?? '')
@@ -661,12 +583,14 @@ async function onExecute(m: ReflectionMethod) {
sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}`
executing.value = m.methodName
try {
const siteId = await pickTargetSiteOnMap()
if (siteId != null) {
await onSitePickConfirm(siteId)
return
// map-edit PlatformScope
if (!props.readOnly) {
const siteId = await pickTargetSiteOnMap()
if (siteId != null) {
await onSitePickConfirm(siteId)
return
}
}
// 退
sitePickSites.value = await loadSitePickOptions()
sitePickOpen.value = true
} catch (e) {
@@ -679,19 +603,83 @@ async function onExecute(m: ReflectionMethod) {
executing.value = m.methodName
try {
const ok = await executeReflectionMethod(rk, idNum, m)
// readOnly / 3D
const ok = rk === 'car'
? await executeViaOpsGateway(idNum, m)
: await executeReflectionMethod(rk, idNum, m)
if (ok) await loadAll()
} finally {
executing.value = null
}
}
async function executeViaOpsGateway(
idNum: number,
m: ReflectionMethod,
extra?: { siteId?: number; params?: Record<string, string> }
): Promise<boolean> {
const label = m.label || m.methodName
if (m.requiresPlatformConfirm) {
try {
await ElMessageBox.confirm(m.confirmMessage?.trim() || `确认执行 [${label}]`, '二次确认', {
type: 'warning'
})
} catch {
return false
}
}
const params: Record<string, string> = { ...(extra?.params ?? {}) }
for (const p of m.params ?? []) {
if (params[p.name] != null) continue
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 resp = await executeOp({
opCode: 'ops.car.execute',
targetId: String(idNum),
method: m.methodName,
params,
siteId: extra?.siteId
})
if (resp.ok) {
ElMessage.success(`已执行 ${label}`)
return true
}
ElMessage.warning(resp.message || `执行未成功:${label}`)
return false
} catch (e) {
ElMessage.error(`执行失败:${(e as Error).message}`)
return false
}
}
async function onSitePickConfirm(siteId: number) {
const idNum = Number(props.selection?.id)
if (!Number.isFinite(idNum)) return
executing.value = pendingGotoMethod.value?.methodName ?? 'goto'
const pending = pendingGotoMethod.value
executing.value = pending?.methodName ?? 'goto'
try {
const ok = await executeCarGotoSite(idNum, siteId)
const ok = pending
? await executeViaOpsGateway(idNum, pending, {
siteId,
params: { siteId: String(siteId) }
})
: await executeCarGotoSite(idNum, siteId)
if (ok) await loadAll()
} finally {
executing.value = null
@@ -700,6 +688,7 @@ async function onSitePickConfirm(siteId: number) {
}
async function onSitePickOnMap() {
if (props.readOnly) return
const siteId = await pickTargetSiteOnMap()
sitePickOpen.value = false
if (siteId != null) await onSitePickConfirm(siteId)
@@ -363,7 +363,7 @@ async function loadActions() {
async function onExecute(m: ReflectionMethod) {
// methodName = workbench action""
if (!m.methodName) {
ElMessage.info(`动作「${m.label || m.methodName}」需在 SimpleLite 工作台执行(当前对象类型未接入反射 execute)。`)
ElMessage.info(`动作「${m.label || m.methodName}」需在 Simple3 工作台执行(当前对象类型未接入反射 execute)。`)
return
}
const rk = reflectionKind.value
@@ -1,5 +1,5 @@
<template>
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }">
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table, 'is-lite': lite }">
<div v-if="!compact && !table" class="overview-row">
<div class="overview-item">
<div class="overview-num">{{ cars.length }}</div>
@@ -28,7 +28,7 @@
v-model="search"
size="small"
clearable
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
:placeholder="tablePlaceholder"
class="search"
/>
<el-select
@@ -65,29 +65,92 @@
</div>
<div v-if="table" class="table-wrap">
<div class="table-head" aria-hidden="true">
<span class="col-id">车辆编号</span>
<span class="col-state">状态</span>
<span class="col-bat">电量</span>
<span class="col-pos">当前位置</span>
</div>
<div class="vehicle-list table-body">
<button
v-for="car in filteredCars"
:key="car.id"
type="button"
class="table-row"
:class="{ 'is-selected': car.id === selectedId }"
@click="onSelect(car)"
>
<span class="col-id" :title="car.name">{{ shortId(car) }}</span>
<span class="col-state">
<span class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</span>
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span>
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</span>
</button>
<div class="dense-scroll">
<table class="dense-table" aria-label="车辆列表">
<thead>
<tr>
<th class="col-vehicle">车辆</th>
<th class="col-state">状态</th>
<th class="col-bat">电量</th>
<th class="col-mission">任务</th>
<th v-if="!lite" class="col-alarm">报警</th>
<th class="col-tc">交管</th>
<th v-if="!lite" class="col-lock">锁点</th>
<th v-if="!lite" class="col-ops">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="car in filteredCars"
:key="car.id"
class="dense-row"
:class="{
'is-selected': car.id === selectedId,
'has-alarm': !lite && hasAlarm(car),
'is-busy': !lite && !!actingId && actingId === carKey(car)
}"
tabindex="0"
@click="onSelect(car)"
@keydown.enter.prevent="onSelect(car)"
>
<td class="col-vehicle">
<div class="veh-cell">
<span class="veh-id">{{ shortId(car) }}</span>
<span class="veh-name" :title="car.name">{{ car.name || `车辆 ${shortId(car)}` }}</span>
</div>
<div class="veh-meta" :title="positionMeta(car)">{{ positionMeta(car) }}</div>
</td>
<td class="col-state">
<span class="state-pill" :class="`pill-${car.state}`">
<i class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</span>
</td>
<td class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</td>
<td class="col-mission" :class="{ muted: !missionForCar(car) }" :title="missionTitle(car)">
{{ missionCell(car) }}
</td>
<td v-if="!lite" class="col-alarm" :class="{ warn: hasAlarm(car) }" :title="alarmText(car)">
{{ alarmCell(car) }}
</td>
<td class="col-tc" :class="{ warn: isTrafficBlocked(car) }" :title="trafficText(car)">
{{ trafficCell(car) }}
</td>
<td v-if="!lite" class="col-lock mono" :title="lockText(car)">{{ lockCell(car) }}</td>
<td v-if="!lite" class="col-ops" @click.stop>
<el-dropdown
trigger="click"
placement="bottom-end"
:disabled="!!actingId"
@command="(cmd: VehicleQuickAction) => onQuickAction(car, cmd)"
>
<button
type="button"
class="ops-btn"
:disabled="!!actingId"
title="车辆操作"
>
操作
<el-icon class="ops-caret"><ArrowDown /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="op in quickActions"
:key="op.value"
:command="op.value"
:divided="op.value === 'disable'"
:class="{ 'is-danger-item': op.danger }"
>
{{ op.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</td>
</tr>
</tbody>
</table>
<div v-if="!filteredCars.length" class="empty muted">无匹配车辆</div>
</div>
</div>
@@ -157,9 +220,17 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Car, CarState } from '@/types/car'
import type { Mission, MissionStatus } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
import {
VEHICLE_QUICK_ACTIONS,
executeVehicleQuickAction,
type VehicleQuickAction
} from '@/api/vehicleOps'
import { isTrafficCongested } from '@/utils/carRuntime'
const props = defineProps<{
cars: Car[]
@@ -167,14 +238,27 @@ const props = defineProps<{
selectedId?: string | null
/** 紧凑模式:隐藏顶部概览条 */
compact?: boolean
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局 */
/** 表格模式:筛选芯片 + 紧凑数据表(地图监控) */
table?: boolean
/** 双栏精简:隐藏报警 / 锁点 / 操作列 */
lite?: boolean
}>()
const emit = defineEmits<{
select: [ref: SelectedObjectRef]
'action-done': []
}>()
const quickActions = VEHICLE_QUICK_ACTIONS
const actingId = ref<string | null>(null)
const tablePlaceholder = computed(() => {
if (!props.table) return '搜索 ID / 名称 / 任务'
return props.lite
? '搜索编号 / 名称 / 交管 / 任务'
: '搜索编号 / 名称 / 报警 / 交管 / 任务'
})
const search = ref('')
const filterState = ref<CarState | ''>('')
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
@@ -326,6 +410,63 @@ function positionText(car: Car): string {
return `${Math.round(car.x)}, ${Math.round(car.y)}`
}
function positionMeta(car: Car): string {
const parts = [positionText(car)]
if (car.siteId != null) parts.push(`站点 #${car.siteId}`)
if (car.lstatus) parts.push(car.lstatus)
return parts.filter((p) => p && p !== '-').join(' · ') || '—'
}
function hasAlarm(car: Car): boolean {
return (car.alarmLevel != null && car.alarmLevel > 0) || !!car.alarmInfo || car.state === 'fault'
}
function alarmText(car: Car): string {
if (car.alarmInfo) return car.alarmInfo
if (car.state === 'fault') return car.lstatus || '故障'
return '无'
}
function alarmCell(car: Car): string {
const t = alarmText(car)
if (t === '无') return '—'
return t.length > 10 ? `${t.slice(0, 10)}` : t
}
function trafficText(car: Car): string {
return (car.trafficStatus || '—').trim() || '—'
}
function isTrafficBlocked(car: Car): boolean {
return isTrafficCongested(trafficText(car))
}
function trafficCell(car: Car): string {
const t = trafficText(car)
return t.length > 14 ? `${t.slice(0, 14)}` : t
}
function lockText(car: Car): string {
return (car.lockPoint || '—').trim() || '—'
}
function lockCell(car: Car): string {
const t = lockText(car)
return t.length > 10 ? `${t.slice(0, 10)}` : t
}
function missionCell(car: Car): string {
const m = missionForCar(car)
if (!m) return car.missionId ? '有任务' : '空闲'
return missionStatusLabel(m.status)
}
function missionTitle(car: Car): string {
const m = missionForCar(car)
if (!m) return car.missionId ? `任务 ${car.missionId}` : '空闲'
return `${missionStatusLabel(m.status)} · ${m.name}`
}
const filteredCars = computed(() => {
const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
@@ -346,6 +487,11 @@ const filteredCars = computed(() => {
c.typeName ?? '',
c.group ?? '',
c.ip ?? '',
c.lstatus ?? '',
c.alarmInfo ?? '',
c.trafficStatus ?? '',
c.lockPoint ?? '',
c.siteId != null ? String(c.siteId) : '',
stateLabel(c.state),
mission?.name ?? '',
mission ? missionStatusLabel(mission.status) : '',
@@ -365,6 +511,51 @@ function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId)
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
}
function carKey(car: Car): string {
return String(car.rawId ?? car.id)
}
function rawIdOf(car: Car): number | null {
if (car.rawId != null && Number.isFinite(car.rawId)) return car.rawId
const n = Number(detailIdForCar(car))
return Number.isFinite(n) && n > 0 ? n : null
}
async function onQuickAction(car: Car, action: VehicleQuickAction) {
const rawId = rawIdOf(car)
if (rawId == null) {
ElMessage.warning('无法解析车辆 ID')
return
}
const op = quickActions.find((x) => x.value === action)
if (!op) return
try {
await ElMessageBox.confirm(
`${op.confirm}\n目标:${car.name || shortId(car)}#${rawId}`,
op.label,
{
type: op.danger ? 'warning' : 'info',
confirmButtonText: '确定',
cancelButtonText: '取消',
distinguishCancelAndClose: true
}
)
} catch {
return
}
actingId.value = carKey(car)
try {
await executeVehicleQuickAction(rawId, action)
ElMessage.success(`已执行「${op.label}`)
emit('action-done')
} catch (err) {
ElMessage.error(`${op.label}」失败:${(err as Error).message}`)
} finally {
actingId.value = null
}
}
</script>
<style scoped>
@@ -377,7 +568,12 @@ function detailIdForCar(car: Car): string {
overflow: hidden;
}
.vehicle-monitor.is-compact { gap: 8px; }
.vehicle-monitor.is-table { gap: 8px; }
.vehicle-monitor.is-table { gap: 6px; }
.vehicle-monitor.is-lite .col-vehicle { width: 36%; }
.vehicle-monitor.is-lite .col-state { width: 86px; }
.vehicle-monitor.is-lite .col-bat { width: 48px; }
.vehicle-monitor.is-lite .col-mission { width: 72px; }
.vehicle-monitor.is-lite .col-tc { width: auto; }
.vehicle-monitor.is-compact .vehicle-row {
border-radius: 8px;
@@ -450,7 +646,7 @@ function detailIdForCar(car: Car): string {
color: var(--mg-text-muted);
font-size: 11px;
font-weight: 550;
height: 26px;
height: 24px;
padding: 0 8px;
border-radius: 6px;
cursor: pointer;
@@ -480,80 +676,190 @@ function detailIdForCar(car: Car): string {
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--mg-veil-border);
border-radius: 8px;
background: #fff;
}
.table-head,
.table-row {
display: grid;
grid-template-columns: 56px 64px 48px minmax(0, 1fr);
gap: 6px;
align-items: center;
padding: 0 8px;
}
.table-head {
flex: none;
height: 28px;
font-size: 11px;
font-weight: 600;
color: var(--mg-text-muted);
border-bottom: 1px solid var(--mg-veil-border);
}
.table-body {
.dense-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
gap: 0;
padding-right: 0;
overflow: auto;
}
.table-row {
appearance: none;
.dense-table {
width: 100%;
border: 0;
border-bottom: 1px solid var(--mg-veil-border);
background: transparent;
color: var(--mg-text-light);
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
font-size: 12px;
}
.dense-table thead th {
position: sticky;
top: 0;
z-index: 2;
height: 30px;
padding: 0 8px;
text-align: left;
height: 40px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.table-row:hover {
background: var(--mg-veil-1);
}
.table-row.is-selected {
background: rgba(var(--mg-primary-rgb), 0.1);
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.col-id {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-weight: 650;
font-size: 12px;
}
.col-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 650;
color: var(--mg-text-muted);
background: var(--mg-veil-1, #f6f3fb);
border-bottom: 1px solid var(--mg-veil-border);
white-space: nowrap;
}
.col-bat {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 11px;
font-weight: 600;
.dense-row {
cursor: pointer;
transition: background 0.12s ease;
}
.col-bat.low { color: var(--mg-status-danger); }
.col-bat.mid { color: var(--mg-status-warning); }
.col-bat.high { color: var(--mg-status-success); }
.col-pos {
font-size: 11px;
color: var(--mg-text-dim);
.dense-row td {
height: 40px;
padding: 4px 8px;
vertical-align: middle;
border-bottom: 1px solid rgba(40, 33, 58, 0.06);
color: var(--mg-text-light);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dense-row:hover td {
background: rgba(var(--mg-primary-rgb), 0.04);
}
.dense-row.is-selected td {
background: rgba(var(--mg-primary-rgb), 0.08);
}
.dense-row.is-selected td:first-child {
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.dense-row.has-alarm td.col-alarm {
font-weight: 650;
}
.dense-row.is-busy {
opacity: 0.65;
pointer-events: none;
}
.col-vehicle { width: 22%; }
.col-state { width: 88px; }
.col-bat { width: 52px; }
.col-mission { width: 72px; }
.col-alarm { width: 14%; }
.col-tc { width: 14%; }
.col-lock { width: 72px; }
.col-ops { width: 64px; text-align: right; }
.veh-cell {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.veh-id {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 750;
color: var(--mg-text-light);
flex: none;
}
.veh-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 550;
color: var(--mg-text-light);
}
.veh-meta {
margin-top: 1px;
font-size: 10.5px;
color: var(--mg-text-faint);
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-pill {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
background: var(--mg-veil-1);
color: var(--mg-text-muted);
border: 1px solid var(--mg-veil-border);
}
.state-pill.pill-running {
color: var(--mg-status-success);
background: color-mix(in srgb, var(--mg-status-success) 12%, #fff);
border-color: color-mix(in srgb, var(--mg-status-success) 28%, transparent);
}
.state-pill.pill-fault {
color: var(--mg-status-danger);
background: color-mix(in srgb, var(--mg-status-danger) 10%, #fff);
border-color: color-mix(in srgb, var(--mg-status-danger) 28%, transparent);
}
.state-pill.pill-offline {
color: var(--mg-text-faint);
}
.state-pill.pill-charging {
color: var(--mg-primary);
background: rgba(var(--mg-primary-rgb), 0.08);
border-color: rgba(var(--mg-primary-rgb), 0.28);
}
.state-pill.pill-paused {
color: var(--mg-status-warning);
background: color-mix(in srgb, var(--mg-status-warning) 12%, #fff);
}
.ops-btn {
appearance: none;
height: 24px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid rgba(var(--mg-primary-rgb), 0.35);
background: rgba(var(--mg-primary-rgb), 0.08);
color: var(--mg-primary);
font-size: 11px;
font-weight: 650;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 1px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.ops-btn:hover:not(:disabled) {
background: rgba(var(--mg-primary-rgb), 0.14);
border-color: rgba(var(--mg-primary-rgb), 0.55);
}
.ops-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.ops-caret { font-size: 11px; margin-left: 1px; }
.col-bat,
.col-mission,
.col-alarm,
.col-tc,
.col-lock {
font-variant-numeric: tabular-nums;
}
.col-bat.low,
.col-alarm.warn,
.col-tc.warn { color: var(--mg-status-danger); font-weight: 650; }
.col-bat.mid { color: var(--mg-status-warning); font-weight: 650; }
.col-bat.high { color: var(--mg-status-success); font-weight: 650; }
.col-mission.muted { color: var(--mg-text-faint); }
.mono {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
}
:deep(.is-danger-item) {
color: var(--mg-status-danger) !important;
}
.state-dot {
width: 6px;
height: 6px;
@@ -65,7 +65,7 @@ const search = ref('')
const selectedId = ref<string | null>(null)
const navHints: Record<WorkbenchNav, string> = {
map: '地图/装饰对象列表(对齐 SimpleLite 工作台「地图」)',
map: '地图/装饰对象列表(对齐 Simple3 工作台「地图」)',
process: '自定义进程 Mission 列表',
vehicle: '场景车辆列表(ID / 地址 / 名称 / 概况)',
scene: '站点、路径、装饰物',
@@ -260,10 +260,10 @@ function publishState(next: ToolbarState | null) {
}
/**
* 平台 iframe 画布工具栏对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`
* 平台 iframe 画布工具栏对应 Simple3 `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`
*
* 设计原则
* - 所有状态都通过 `/sl/projection/toolbar/*` SimpleLite 全局状态 tab 同源
* - 所有状态都通过 `/sl/projection/toolbar/*` Simple3 全局状态 tab 同源
* - `state` 是后端权威单一真值本地不维护"乐观"切换每次 toggle 都拿后端最新返回回写
* - 录制 / 回放期间 1Hz 轮询刷新 elapsed / 帧计数避免页面打开后停留在初值
* - 错误用 ElMessage.error 提示不阻断画布主操作
@@ -386,7 +386,7 @@ async function toggleLayer(name: string, visible: boolean) {
}
function onContextMenuPlaceholder() {
ElMessage.info('寻路右键动作尚未接入 SimpleLite;请使用 SegmentPlan.FindRoute / 工作台脚本。')
ElMessage.info('寻路右键动作尚未接入 Simple3;请使用 SegmentPlan.FindRoute / 工作台脚本。')
}
async function toggleRecording() {
@@ -1,11 +1,11 @@
import { onBeforeUnmount, onMounted } from 'vue'
/**
* Workspace3D iframeSimpleLite WebTerminal postMessage
* Workspace3D iframeSimple3 WebTerminal postMessage
*
* SimpleLite WebTerminal window.postMessage `workspace.pick`
* Simple3 WebTerminal window.postMessage `workspace.pick`
* `workspace.select` Workspace3D.vue emit
* vueiframe SimpleLite APIHTTP/SSE
* vueiframe Simple3 APIHTTP/SSE
* ** / / hover** iframevue
*/
@@ -6,7 +6,7 @@ import { ref, computed } from 'vue'
*
*
* - **** Vue + + pick API
* - **** API `/execute/...` SimpleLite CAD action / /
* - **** API `/execute/...` Simple3 CAD action / /
*
* composable CAD action
*/
@@ -46,6 +46,7 @@ export type EditToolId =
| 'transform.scale'
| 'transform.copy'
| 'transform.paste'
| 'transform.duplicate'
| 'transform.delete'
| 'transform.copyFields'
// 对齐组
@@ -136,6 +137,7 @@ export function useEditTool() {
'transform.scale': '缩放',
'transform.copy': '复制',
'transform.paste': '粘贴',
'transform.duplicate': '复制副本',
'transform.delete': '删除',
'transform.copyFields': '复制字段',
'align.left': '左对齐',
@@ -2,7 +2,7 @@ import { onBeforeUnmount, onMounted } from 'vue'
import { useProjectionStream, type StreamEvent } from './useProjectionStream'
/**
* / SimpleLite SSE 5
* / Simple3 SSE 5
*
* - `alarm` ( AlarmStreamService)//
* - `object-created` ( MapEditApiController)
@@ -38,19 +38,19 @@ export interface PickResultEvent {
}
export interface SelectionDetailEvent {
/** 主选中 kind (site|track|car|null);当多选时取首个非空集合的 kind。 */
kind: 'site' | 'track' | 'car' | null
/** 主选中 kind;当多选时取首个非空集合的 kind。 */
kind: 'site' | 'track' | 'car' | 'special' | null
/** 主选中 id;多选时取首个。 */
id: number
siteIds: number[]
trackIds: number[]
carIds: number[]
/** 完整对象名列表,例如 ["UISite-12", "UITrack-7", "Car-3"]。 */
/** 完整对象名列表,例如 ["UISite-12", "UITrack-7", "Car-3", "UIText-5"]。 */
names: string[]
}
export interface WorkspaceShortcutEvent {
action: 'undo' | 'redo' | 'delete'
action: 'undo' | 'redo' | 'delete' | 'copy' | 'paste' | 'duplicate'
}
export interface MapEditStreamHandlers {
@@ -61,7 +61,7 @@ export interface MapEditStreamHandlers {
onObjectBatchChanged?: (e: { count: number; source?: string }) => void
onPickResult?: (e: PickResultEvent) => void
onSelectionDetail?: (e: SelectionDetailEvent) => void
/** iframe 画布聚焦时由 SimpleLite 经 SSE 转发的撤销/重做/删除。 */
/** iframe 画布聚焦时由 Simple3 经 SSE 转发的撤销/重做/删除。 */
onWorkspaceShortcut?: (e: WorkspaceShortcutEvent) => void
/** 视口样式(站点/路径 Painter)在 Web 或桌面对话框中变更后广播。 */
onViewportStyleUpdated?: () => void
@@ -1,7 +1,7 @@
import { onUnmounted, ref, type Ref } from 'vue'
/**
* SimpleLite `/projection/stream`SSE
* Simple3 `/projection/stream`SSE
*
* `text/event-stream` JSON
* ```
@@ -33,7 +33,7 @@ export interface ProjectionStreamOptions {
}
/**
* SimpleLite 广 SSE
* Simple3 广 SSE
* EventSource alarm / object-*
*/
export const KNOWN_EVENT_KINDS = [
@@ -123,7 +123,7 @@ export function useProjectionStream(opts: ProjectionStreamOptions = {}) {
// 命名事件:EventSource 默认只把"无 event: 头"的消息派发给 onmessage
// 任何 `event: foo` 都必须通过 addEventListener('foo', ...) 才能收到。
// 必须覆盖 SimpleLite 后端可能广播的所有事件名 —— 漏一个就等于该事件被丢弃。
// 必须覆盖 Simple3 后端可能广播的所有事件名 —— 漏一个就等于该事件被丢弃。
// 当前后端事件源:
// - ProjectionStreamModule: snapshot-tick (1Hz 心跳/计数)
// - SimpleUI / ReflectionApiController: selection-detail
@@ -5,8 +5,7 @@ import type { ReflectionObject, ReflectionKind } from '@/api/reflection'
* +
* `(kind, id)` `${kind}:${id}` key
*
* SimpleLite selection ReflectionApi 3D
* API
* postMessage / SSE watch SetSelection
*/
export interface SelectionItem extends ReflectionObject {
@@ -43,6 +43,7 @@ export function useSimpleFieldCatalog(
) {
const loading = ref(false)
const records = ref<SimpleFieldRecord[]>([])
const lastError = ref<string | null>(null)
const fieldType = computed(() => fieldCategoryForMapKind(kind.value))
@@ -56,9 +57,11 @@ export function useSimpleFieldCatalog(
const ft = fieldType.value
if (!ft) {
records.value = []
lastError.value = null
return
}
loading.value = true
lastError.value = null
try {
// 车辆:为兼容 shortName / assembly.shortName 等不同 car_type 存储格式,先拉 carFields 再前端匹配当前车型。
if (ft === 'carFields') {
@@ -68,14 +71,22 @@ export function useSimpleFieldCatalog(
const rows = await listSimpleFields(ft)
records.value = dedupeByKey(rows)
}
} catch {
} catch (err) {
records.value = []
lastError.value = (err as Error)?.message || '字段目录加载失败'
} finally {
loading.value = false
}
}
watch(fieldType, () => { void reload() }, { immediate: true })
// 选中车后 typeName 异步到齐时需重滤 carFields
watch(
() => carType?.value,
() => {
if (fieldType.value === 'carFields') void reload()
}
)
function labelForKey(key: string): string {
const row = byKey.value.get(key)
@@ -89,6 +100,7 @@ export function useSimpleFieldCatalog(
return {
loading,
records,
lastError,
fieldType,
byKey,
reload,
@@ -3,13 +3,13 @@ import {
Collection,
Connection,
Cpu,
DataBoard,
Document,
DocumentCopy,
EditPen,
Files,
Grid,
Histogram,
Link,
List,
MapLocation,
Monitor,
@@ -19,6 +19,7 @@ import {
Promotion,
SetUp,
Setting,
Tickets,
Tools,
User,
Van,
@@ -43,8 +44,12 @@ export const ADMIN_MENU: NavMenuItem[] = [
path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览',
children: [
{ path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
{ path: '/admin/playback', label: '调度回放', icon: VideoCamera, key: 'admin-playback', group: '概览' },
{ path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' },
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' }
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' },
{ path: '/admin/ops-log', label: '运维记录', icon: Promotion, key: 'admin-ops-log', group: '概览' },
{ path: '/admin/notes', label: '运维备注', icon: Notebook, key: 'admin-notes', group: '概览' },
{ path: '/admin/logs', label: '日志管理', icon: Tickets, key: 'admin-logs', group: '概览' }
]
},
{
@@ -75,23 +80,21 @@ export const ADMIN_MENU: NavMenuItem[] = [
{
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
children: [
{ path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' },
{ path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' },
{ path: '/admin/config/facility', label: '设备接入', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' },
{ path: '/admin/config/warehouse', label: '仓储管理', icon: OfficeBuilding, key: 'admin-config-warehouse', group: '平台配置中心' },
{ path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' },
{ path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' },
{ path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' }
{ path: '/admin/config/ops-center', label: '监控配置', icon: DataBoard, key: 'admin-config-ops-center', group: '平台配置中心' },
{ path: '/admin/config/system', label: '系统配置', icon: Setting, key: 'admin-config-system', group: '平台配置中心' },
{ path: '/admin/config/auth', label: '权限与角色', icon: User, key: 'admin-config-auth', group: '平台配置中心' }
]
}
]
export const MONITOR_MENU: NavMenuItem[] = [
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' },
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' },
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' },
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
{ path: '/monitor/notes', label: '运备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
{ path: '/monitor/ops', label: '运维记录', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
{ path: '/monitor/notes', label: '运备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
]
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
@@ -20,17 +20,19 @@ export interface QuickEntryDef {
}
/** 当前页即总览,不作为快捷入口候选 */
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard'])
/** 旧版别名 key -> 菜单 key */
const LEGACY_KEY_ALIASES: Record<string, string> = {
'platform-config': 'admin-map-editor',
mission: 'admin-task-templates',
cars: 'admin-cars',
auth: 'admin-config-system-center',
system: 'admin-config-system-center',
auth: 'admin-config-auth',
system: 'admin-config-system',
'admin-config-system-center': 'admin-config-auth',
ops: 'admin-config-ops-center',
tasks: 'admin-config-strategy'
tasks: 'admin-task-templates',
'admin-config-strategy': 'admin-task-templates'
}
/** Platform 域下固定保留、不可删除的快捷入口 */
@@ -46,9 +48,9 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */
export const DEFAULT_PLATFORM_QUICK_KEYS = [
...MANDATORY_PLATFORM_QUICK_KEYS,
'admin-config-system-center',
'admin-config-auth',
'admin-config-ops-center',
'admin-config-strategy'
'admin-config-facility'
] as const
export const DEFAULT_MONITOR_QUICK_KEYS = [
@@ -70,7 +70,6 @@
</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">
@@ -83,15 +82,6 @@
<el-dropdown-item v-if="auth.scope === 'Platform'" command="setup">初始配置</el-dropdown-item>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
<el-dropdown-item command="status">服务状态</el-dropdown-item>
<el-dropdown-item divided disabled class="legacy-theme-label">高级 · 兼容主题</el-dropdown-item>
<el-dropdown-item
v-for="t in ui.legacyThemes"
:key="t.id"
:command="`theme:${t.id}`"
:class="{ 'is-legacy-active': t.id === ui.themeId }">
<span class="legacy-swatch" :style="{ background: t.preview }" />
{{ t.name }}
</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -100,6 +90,7 @@
</el-header>
<el-main class="main mg-content">
<WizardSetupBar />
<!-- 不用 mode="out-in"懒加载子路由切换时易出现离场后新组件未挂载 主区全白刷新才恢复 -->
<router-view v-slot="{ Component, route: rv }">
<component :is="Component" v-if="Component" :key="rv.fullPath" class="route-page" />
@@ -124,9 +115,9 @@ import { Fold, Expand, CaretBottom } from '@element-plus/icons-vue'
import { ADMIN_MENU, MONITOR_MENU, type NavMenuItem } from '@/config/navMenu'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import ScopeSwitcher from '@/components/ScopeSwitcher.vue'
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
import AiAssistantDrawer from '@/components/assistant/AiAssistantDrawer.vue'
import WizardSetupBar from '@/components/wizard/WizardSetupBar.vue'
const route = useRoute()
const router = useRouter()
@@ -138,13 +129,13 @@ const apiBase = computed(() => (import.meta.env.VITE_API_BASE as string | undefi
const initial = computed(() => (auth.user?.displayName ?? auth.user?.username ?? '?').slice(0, 1).toUpperCase())
const scopeLabel = computed(() => (auth.scope === 'Platform' ? '管理员 (Platform)' : '运营 (RCSMonitor)'))
const scopeLabel = computed(() => (auth.scope === 'Platform' ? '管理' : '运营'))
const runModeLabel = computed(() => {
switch (auth.runMode) {
case 'WebOnly': return 'Web-Only'
case 'WebEnabled': return 'Web + Desktop'
case 'Detached': return 'SimpleLite 未连接'
case 'Detached': return 'Simple3 未连接'
default: return String(auth.runMode ?? 'Unknown')
}
})
@@ -185,10 +176,6 @@ const activePath = computed(() => {
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
function onUserCommand(cmd: string) {
if (cmd.startsWith('theme:')) {
ui.setThemeId(cmd.slice('theme:'.length))
return
}
if (cmd === 'logout') {
auth.logout()
router.push('/login')
@@ -511,7 +498,7 @@ function onUserCommand(cmd: string) {
border-color: rgba(var(--mg-primary-hover-rgb), 0.45);
color: var(--mg-accent);
}
/* 会话 N+1 增量:SimpleLite 未拉起时的降级展示,用警示色而非成功色,引导用户感知"实际只有 Platform"。 */
/* 会话 N+1 增量:Simple3 未拉起时的降级展示,用警示色而非成功色,引导用户感知"实际只有 Platform"。 */
.runmode-tag.Detached {
background: rgba(var(--mg-status-warning-rgb), 0.18);
border-color: rgba(var(--mg-status-warning-rgb), 0.45);
@@ -571,25 +558,3 @@ function onUserCommand(cmd: string) {
flex-direction: column;
}
</style>
<style>
/* 用户菜单「兼容主题」项(teleport 到 body */
.legacy-theme-label {
font-size: 11px !important;
opacity: 0.65;
cursor: default !important;
}
.legacy-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.el-dropdown-menu__item.is-legacy-active {
color: var(--mg-primary) !important;
font-weight: 600;
}
</style>
@@ -3,10 +3,10 @@ import type { Car } from '@/types/car'
const NOW = new Date().toISOString()
export const CARS: Car[] = [
{ id: 'C01', rawId: 1, 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', onboardUrl: 'http://10.0.1.11:8081', lstatus: '运行' },
{ id: 'C02', rawId: 2, 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', onboardUrl: 'http://10.0.1.12:8081', lstatus: '运行' },
{ id: 'C03', rawId: 3, 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', onboardUrl: 'http://10.0.1.13:8081', lstatus: '充电' },
{ id: 'C04', rawId: 4, 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', onboardUrl: 'http://10.0.1.14:8081', lstatus: '空闲' },
{ id: 'C05', rawId: 5, 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', onboardUrl: 'http://10.0.1.15:8081', lstatus: '故障' },
{ id: 'C06', rawId: 6, 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', onboardUrl: 'http://10.0.1.16:8081', lstatus: '下线' }
{ id: 'C01', rawId: 1, name: 'AGV-001', typeName: 'Simple3.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', onboardUrl: 'http://10.0.1.11:8081', lstatus: '运行' },
{ id: 'C02', rawId: 2, name: 'AGV-002', typeName: 'Simple3.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', onboardUrl: 'http://10.0.1.12:8081', lstatus: '运行' },
{ id: 'C03', rawId: 3, name: 'AGV-003', typeName: 'Simple3.RCS.CarTypes.DummyCar', x: 1000, y: 4900, theta: 180, batterySoc: 1.0, state: 'charging', lastUpdate: NOW, group: 'A 区', ip: '10.0.1.13', onboardUrl: 'http://10.0.1.13:8081', lstatus: '充电' },
{ id: 'C04', rawId: 4, name: 'AGV-004', typeName: 'Simple3.RCS.CarTypes.GhostCar', x: 7000, y: 2050, theta: 0, batterySoc: 0.71, state: 'idle', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.14', onboardUrl: 'http://10.0.1.14:8081', lstatus: '空闲' },
{ id: 'C05', rawId: 5, name: 'AGV-005', typeName: 'Simple3.RCS.CarTypes.GhostCar', x: 7050, y: 4000, theta: 270, batterySoc: 0.18, state: 'fault', lastUpdate: NOW, group: 'B 区', ip: '10.0.1.15', onboardUrl: 'http://10.0.1.15:8081', lstatus: '故障' },
{ id: 'C06', rawId: 6, name: 'AGV-006', typeName: 'Simple3.RCS.CarTypes.DummyCar', x: 9000, y: 2000, theta: 0, batterySoc: 0.95, state: 'offline', lastUpdate: NOW, group: '维护', ip: '10.0.1.16', onboardUrl: 'http://10.0.1.16:8081', lstatus: '下线' }
]
@@ -17,9 +17,6 @@ import type {
} 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 = {
@@ -4,7 +4,7 @@ const NOW = new Date().toISOString()
export const MISSIONS: Mission[] = [
{
id: 'M01', name: 'A 区送料 #1', typeName: 'SimpleLite.RCS.Missions.MoveMission',
id: 'M01', name: 'A 区送料 #1', typeName: 'Simple3.RCS.Missions.MoveMission',
priority: 50, status: 'running', carId: 'C01', createdAt: NOW,
steps: [
{ id: 'S1', action: 'pickup', targetSiteId: 'S001', status: 'completed' },
@@ -13,7 +13,7 @@ export const MISSIONS: Mission[] = [
]
},
{
id: 'M02', name: 'A→B 缓存搬运', typeName: 'SimpleLite.RCS.Missions.MoveMission',
id: 'M02', name: 'A→B 缓存搬运', typeName: 'Simple3.RCS.Missions.MoveMission',
priority: 60, status: 'running', carId: 'C02', createdAt: NOW,
steps: [
{ id: 'S1', action: 'pickup', targetSiteId: 'S002', status: 'completed' },
@@ -21,7 +21,7 @@ export const MISSIONS: Mission[] = [
]
},
{
id: 'M03', name: 'B 区工位 W1 投料', typeName: 'SimpleLite.RCS.Missions.MoveMission',
id: 'M03', name: 'B 区工位 W1 投料', typeName: 'Simple3.RCS.Missions.MoveMission',
priority: 70, status: 'queued', createdAt: NOW,
steps: [
{ id: 'S1', action: 'pickup', targetSiteId: 'S003', status: 'queued' },
@@ -29,7 +29,7 @@ export const MISSIONS: Mission[] = [
]
},
{
id: 'M04', name: 'W2→维护检修', typeName: 'SimpleLite.RCS.Missions.MaintenanceMission',
id: 'M04', name: 'W2→维护检修', typeName: 'Simple3.RCS.Missions.MaintenanceMission',
priority: 30, status: 'paused', carId: 'C05', createdAt: NOW,
steps: [
{ id: 'S1', action: 'goto', targetSiteId: 'S010', status: 'paused' }
@@ -254,7 +254,7 @@ export function mockReflectionKinds(): { kinds: ReflectionKindMeta[]; subKinds:
export function mockReflectionAssemblies(): ReflectionAssembly[] {
return [
{ name: 'SimpleCore', version: '1.0.0' },
{ name: 'SimpleLite', version: '1.0.0' },
{ name: 'Simple3', version: '1.0.0' },
{ name: 'StandardScene', version: '1.4.0', location: 'plugins/StandardScene.dll' },
{ name: 'CustomFlightDeck', version: '0.1.0', location: 'plugins/CustomFlightDeck.dll' }
]
@@ -273,7 +273,7 @@ export function mockReflectionMethodsByType(kind: ReflectionKind): ReflectionTyp
if (kind === 'car' || kind === 'vehicle' || kind === 'script') {
return [
{ typeName: 'GhostCar', assemblyName: 'StandardScene', methods: ms },
{ typeName: 'DummyCar', assemblyName: 'SimpleLite', methods: ms.slice(0, 2) }
{ typeName: 'DummyCar', assemblyName: 'Simple3', methods: ms.slice(0, 2) }
]
}
if (kind === 'mission' || kind === 'process') {
@@ -282,7 +282,7 @@ export function mockReflectionMethodsByType(kind: ReflectionKind): ReflectionTyp
{ typeName: 'PatrolMission', assemblyName: 'CustomFlightDeck', methods: ms.slice(0, 2) }
]
}
return [{ typeName: kind, assemblyName: 'SimpleLite', methods: ms }]
return [{ typeName: kind, assemblyName: 'Simple3', methods: ms }]
}
export function mockReflectionStatus(kind: ReflectionKind, id: number) {
@@ -13,8 +13,12 @@ const PAGES: PageDef[] = [
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
{ key: 'admin-setup', label: '初始配置', group: '概览', scope: 'Platform' },
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
{ key: 'admin-playback', label: '调度回放', group: '概览', scope: 'Platform' },
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
{ key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' },
{ key: 'admin-ops-log', label: '运维记录', group: '概览', scope: 'Platform' },
{ key: 'admin-notes', label: '运维备注', group: '概览', scope: 'Platform' },
{ key: 'admin-logs', label: '日志管理', group: '概览', scope: 'Platform' },
{ key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
@@ -25,18 +29,16 @@ const PAGES: PageDef[] = [
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-wcs-template-proto', label: 'WCS模板原型', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-facility', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-warehouse', label: '库位管理', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-business', label: '业务与集成', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-ops-center', label: '运维与回放', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-system-center', label: '系统与权限', group: '平台配置中心', scope: 'Platform' },
{ key: 'monitor-dashboard', label: '运营总览', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'admin-config-ops-center', label: '监控配置', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-system', label: '系统配置', group: '平台配置中心', scope: 'Platform' },
{ key: 'admin-config-auth', label: '权限与角色', group: '平台配置中心', scope: 'Platform' },
{ key: 'monitor-vehicle-hub', label: '车辆运维', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-map', label: '地图监控', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-ops', label: '运维操作', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-notes', label: '运备注', group: '运营监控', scope: 'RCSMonitor' }
{ key: 'monitor-ops', label: '运维记录', group: '运营监控', scope: 'RCSMonitor' },
{ key: 'monitor-notes', label: '运备注', group: '运营监控', scope: 'RCSMonitor' }
]
const OPS: OpDef[] = [
@@ -80,7 +82,7 @@ function seed(): RbacState {
{
id: 'role-ops', name: '运营人员', description: '运营监控端默认角色:可执行运维操作、查看监控',
scope: 'RCSMonitor',
pages: ['monitor-dashboard', 'monitor-map', 'monitor-ops', 'monitor-notes', 'monitor-vehicle-hub'],
pages: ['monitor-map', 'monitor-ops', 'monitor-notes', 'monitor-vehicle-hub'],
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',
@@ -107,19 +109,26 @@ function seed(): RbacState {
function load(): RbacState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as RbacState
if (raw) {
const s = JSON.parse(raw) as RbacState
for (const r of s.roles ?? []) {
r.pages = sanitizePages(r.pages ?? [])
}
persist(s)
return s
}
} catch { /* ignore */ }
const s = seed()
persist(s)
return s
}
let state: RbacState = load()
function persist(s: RbacState = state) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)) } catch { /* ignore */ }
}
let state: RbacState = load()
function scopesOf(roleIds: string[]): string[] {
const set = new Set<string>()
for (const id of roleIds) {
@@ -144,35 +153,77 @@ function sanitizePages(pages: string[]): string[] {
'admin-config-vehicle': 'admin-vehicle-hub',
'admin-config-fleet': 'admin-vehicle-hub',
// 会话 16:配置中心收敛为 6 个聚合页,旧页面 Key 迁移到对应聚合页 Key。
'admin-config-routing': 'admin-config-strategy',
'admin-config-task': 'admin-config-strategy',
'admin-config-traffic': 'admin-config-strategy',
'admin-config-charge': 'admin-config-strategy',
'admin-config-device': 'admin-config-facility',
'admin-config-location': 'admin-config-warehouse',
'admin-config-integrations': 'admin-config-business',
'admin-config-scenario': 'admin-config-business',
'admin-config-widget': 'admin-config-business',
'admin-playback': 'admin-config-ops-center',
'admin-config-ops': 'admin-config-ops-center',
'admin-config-logs': 'admin-config-ops-center',
'admin-config-logs': 'admin-logs',
'admin-config-map-monitor': 'admin-config-ops-center',
'admin-config-system': 'admin-config-system-center',
'admin-config-auth': 'admin-config-system-center'
'admin-config-system-center': 'admin-config-system',
'monitor-dashboard': 'monitor-map'
}
return [...new Set(pages.map((p) => legacy[p] ?? p).filter((p) => valid.has(p)))]
const hadSystemHub = pages.includes('admin-config-system-center')
const mapped = [...new Set(pages.map((p) => legacy[p] ?? p).filter((p) => valid.has(p)))]
if (
mapped.includes('admin-config-ops-center')
&& !mapped.includes('*')
) {
if (!mapped.includes('admin-logs')) mapped.push('admin-logs')
if (!mapped.includes('admin-playback')) mapped.push('admin-playback')
}
if (hadSystemHub && !mapped.includes('*')) {
if (!mapped.includes('admin-config-system')) mapped.push('admin-config-system')
if (!mapped.includes('admin-config-auth')) mapped.push('admin-config-auth')
}
return mapped
}
const PLATFORM_TO_MONITOR: Record<string, string> = {
'admin-map-monitor': 'monitor-map',
'admin-vehicle-hub': 'monitor-vehicle-hub',
'admin-ops-log': 'monitor-ops',
'admin-notes': 'monitor-notes'
}
const MONITOR_ONLY = ['monitor-ops', 'monitor-notes']
function grantableFromPlatform(platformPages: string[]): { Platform: string[]; RCSMonitor: string[]; '*': string[] } {
const platformKeys = PAGES.filter((p) => p.scope === 'Platform').map((p) => p.key)
const monitorKeys = PAGES.filter((p) => p.scope === 'RCSMonitor').map((p) => p.key)
const hasAll = platformPages.includes('*') || platformKeys.every((k) => platformPages.includes(k))
const platform = hasAll ? platformKeys : platformPages.filter((k) => platformKeys.includes(k))
const monitor = new Set(MONITOR_ONLY)
if (hasAll) monitorKeys.forEach((k) => monitor.add(k))
else {
for (const p of platform) {
const m = PLATFORM_TO_MONITOR[p]
if (m) monitor.add(m)
}
}
const mon = [...monitor]
return { Platform: platform, RCSMonitor: mon, '*': [...new Set([...platform, ...mon])] }
}
export function mockRbacCatalog(): RbacCatalog {
const me = (() => {
try {
const raw = localStorage.getItem('simple.auth.user')
const u = raw ? JSON.parse(raw) as { username?: string } : null
return u?.username?.trim() || 'admin'
} catch { return 'admin' }
})()
const platformPages = mockComputeAllowedPages(me, 'Platform')
const grantable = grantableFromPlatform(platformPages)
return {
pages: PAGES,
ops: OPS,
widgets: WIDGETS,
scopes: [
{ value: 'Platform', label: '管理 (Platform)' },
{ value: 'RCSMonitor', label: '运营 (RCSMonitor)' },
{ value: 'Platform', label: '管理权限 (Platform)' },
{ value: 'RCSMonitor', label: '运营权限 (RCSMonitor)' },
{ value: '*', label: '通用 (全部域)' }
]
],
grantablePages: grantable,
platformToMonitor: Object.entries(PLATFORM_TO_MONITOR).map(([platform, monitor]) => ({ platform, monitor })),
monitorOnlyPages: MONITOR_ONLY
}
}
@@ -267,16 +318,34 @@ export async function mockRbacDeleteUser(id: string): Promise<void> {
persist()
}
/** 登录按账号角色自动选域:能进管理则管理,否则运营。未知用户默认管理。 */
export function mockResolveLoginScope(username: string): 'Platform' | 'RCSMonitor' {
const u = state.users.find((x) => x.username.toLowerCase() === username.toLowerCase())
if (!u) return 'Platform'
const scopes = scopesOf(u.roleIds)
if (scopes.includes('Platform')) return 'Platform'
if (scopes.includes('RCSMonitor')) return 'RCSMonitor'
return 'Platform'
}
/** 供 mockLogin 计算 allowedPages:按 username + scope 取角色 pages 并集;未知用户给该 scope 全部页面。 */
export function mockComputeAllowedPages(username: string, scope: 'Platform' | 'RCSMonitor'): string[] {
const scopeKeys = PAGES.filter((p) => p.scope === scope).map((p) => p.key)
const u = state.users.find((x) => x.username.toLowerCase() === username.toLowerCase())
if (!u) return scopeKeys
const roles = state.roles.filter((r) => u.roleIds.includes(r.id) && (r.scope === '*' || r.scope === scope))
const reverse: Record<string, string> = {}
for (const [platform, monitor] of Object.entries(PLATFORM_TO_MONITOR)) reverse[monitor] = platform
const set = new Set<string>()
for (const r of roles) {
if (r.pages.includes('*')) scopeKeys.forEach((k) => set.add(k))
else r.pages.filter((p) => scopeKeys.includes(p)).forEach((k) => set.add(k))
else {
for (const p of r.pages) {
if (scopeKeys.includes(p)) set.add(p)
const twin = scope === 'Platform' ? reverse[p] : PLATFORM_TO_MONITOR[p]
if (twin && scopeKeys.includes(twin)) set.add(twin)
}
}
}
return [...set]
}
@@ -9,7 +9,7 @@ import { TRACKS } from './data/tracks'
import { CARS } from './data/cars'
import { MISSIONS } from './data/missions'
import { CONFIG_DEFAULTS } from './data/configs'
import { mockComputeAllowedPages } from './rbac'
import { mockComputeAllowedPages, mockResolveLoginScope } from './rbac'
const PLATFORM_OPS = ['*']
@@ -57,21 +57,21 @@ function buildPerm(scope: 'Platform' | 'RCSMonitor', username: string): Effectiv
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'
const scope = mockResolveLoginScope(req.username)
const token = `mock-jwt.${scope}.${req.username}.${Date.now()}`
// mock 无法真正拉起内核;Simple3 仅 Web,默认 WebOnly
const runMode: LoginResponse['runMode'] =
req.launchMode === 'DesktopAndWeb' ? 'WebEnabled' : 'WebOnly'
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']
roles: scope === 'Platform' ? ['role-admin'] : ['role-ops']
},
scope: req.scope,
effectivePermissions: buildPerm(req.scope, req.username),
scope,
effectivePermissions: buildPerm(scope, req.username),
runMode
}
}
@@ -135,18 +135,42 @@ export async function mockPutConfig<T>(section: ConfigSection, payload: T): Prom
return next
}
function currentMockUsername(): string {
try {
const raw = localStorage.getItem('simple.auth.user')
const u = raw ? JSON.parse(raw) as { username?: string } : null
return u?.username?.trim() || 'mock-user'
} catch {
return 'mock-user'
}
}
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' }
{ id: 'A002', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.task.cancel', target: 'M03', result: 'ok' },
{ id: 'A003', ts: nowIso(), user: 'admin', scope: 'Platform', opCode: 'ops.car.resetSession', target: 'C02', result: 'failed', message: '示例:他人记录,运营账号不可见' }
]
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 })
audits.unshift({
id,
ts: nowIso(),
user: currentMockUsername(),
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] }
export async function mockOpsAudits(): Promise<OpsAuditEntry[]> {
await delay(60)
const me = currentMockUsername().toLowerCase()
return audits.filter((a) => a.user.toLowerCase() === me)
}
function delay(ms: number) { return new Promise((r) => setTimeout(r, ms)) }
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { isWizardSetupActive, WIZARD_SETUP_ROUTES } from '@/utils/wizardSetup'
const routes: RouteRecordRaw[] = [
{
@@ -33,6 +34,10 @@ const routes: RouteRecordRaw[] = [
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
{ path: 'ops-log', name: 'admin-ops-log', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维记录' } },
{ path: 'notes', name: 'admin-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运维备注' } },
{ path: 'logs', name: 'admin-logs', component: () => import('@/views/admin/LogManagementView.vue'), meta: { title: '日志管理' } },
{ path: 'playback', name: 'admin-playback', component: () => import('@/views/admin/PlaybackView.vue'), meta: { title: '调度回放' } },
{ path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } },
{ path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } },
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
@@ -51,33 +56,33 @@ const routes: RouteRecordRaw[] = [
meta: { title: '数据中心' }
},
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
{ path: 'config/facility', name: 'admin-config-facility', component: () => import('@/views/admin/config/FacilityConfigView.vue'), meta: { title: '设备接入' } },
{ path: 'config/warehouse', name: 'admin-config-warehouse', component: () => import('@/views/admin/WarehouseManagementView.vue'), meta: { title: '仓储管理' } },
{ path: 'config/business', name: 'admin-config-business', component: () => import('@/views/admin/config/BusinessConfigView.vue'), meta: { title: '业务与集成' } },
{ path: 'config/ops-center', name: 'admin-config-ops-center', component: () => import('@/views/admin/config/OpsCenterView.vue'), meta: { title: '运维与回放' } },
{ path: 'config/system-center', name: 'admin-config-system-center', component: () => import('@/views/admin/config/SystemCenterView.vue'), meta: { title: '系统与权限' } },
{ path: 'config/ops-center', name: 'admin-config-ops-center', component: () => import('@/views/admin/config/OpsCenterView.vue'), meta: { title: '监控配置' } },
{ path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统配置' } },
{ path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限与角色' } },
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
{ path: 'config/ops-center/playback', redirect: '/admin/playback' },
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
{ path: 'config/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } },
{ path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
{ path: 'config/charge', redirect: { path: '/admin/config/strategy', query: { tab: 'charge' } } },
{ path: 'config/routing', redirect: '/admin/dashboard' },
{ path: 'config/task', redirect: '/admin/dashboard' },
{ path: 'config/traffic', redirect: '/admin/dashboard' },
{ path: 'config/charge', redirect: '/admin/dashboard' },
{ path: 'config/strategy', redirect: '/admin/dashboard' },
{ path: 'config/device', redirect: '/admin/config/facility' },
{ path: 'config/location', redirect: '/admin/config/warehouse' },
{ path: 'config/integrations', redirect: { path: '/admin/config/business', query: { tab: 'integrations' } } },
{ path: 'config/scenario', redirect: { path: '/admin/config/business', query: { tab: 'scenario' } } },
{ path: 'config/widget', redirect: { path: '/admin/config/business', query: { tab: 'widget' } } },
{ path: 'config/business', redirect: '/admin/dashboard' },
{ path: 'config/integrations', redirect: '/admin/dashboard' },
{ path: 'config/scenario', redirect: '/admin/dashboard' },
{ path: 'config/widget', redirect: '/admin/dashboard' },
{ path: 'config/ops', redirect: { path: '/admin/config/ops-center', query: { tab: 'ops' } } },
{ path: 'config/logs', redirect: { path: '/admin/config/ops-center', query: { tab: 'logs' } } },
{ path: 'config/logs', redirect: '/admin/logs' },
{ path: 'config/map-monitor', redirect: { path: '/admin/config/ops-center', query: { tab: 'map-monitor' } } },
{ path: 'config/system', redirect: { path: '/admin/config/system-center', query: { tab: 'system' } } },
{ path: 'config/auth', redirect: { path: '/admin/config/system-center', query: { tab: 'auth' } } }
{ path: 'config/system-center', redirect: (to) => (to.query.tab === 'auth' ? '/admin/config/auth' : '/admin/config/system') },
]
},
{
@@ -86,11 +91,11 @@ const routes: RouteRecordRaw[] = [
meta: { scope: 'RCSMonitor' },
redirect: '/monitor/map',
children: [
{ path: 'dashboard', name: 'monitor-dashboard', component: () => import('@/views/monitor/MonitorDashboardView.vue'), meta: { title: '运营总览' } },
{ path: 'dashboard', redirect: '/monitor/map' },
{ path: 'vehicle-hub', name: 'monitor-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.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: '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' },
@@ -162,27 +167,29 @@ router.beforeEach(async (to) => {
}
}
// 部署配置向导:首次部署(deployment.Configured=false)时,强制先完成平台选型再进入业务页
// 已在 /wizard 则放行,避免自跳死循环;保存成功后 store.markWizardDone() 解除拦截
// 首次部署:未完成向导时默认拦在 /wizard
// 选型完成后的「进入配置」允许跳到车辆 / 地图 / 设备等白名单页(inSetup=true
if (auth.needsWizard && to.name !== 'wizard') {
return { name: 'wizard' }
const dest = typeof to.name === 'string' ? to.name : ''
if (!(isWizardSetupActive() && WIZARD_SETUP_ROUTES.has(dest))) {
return { name: 'wizard' }
}
}
// 会话 45 AR-6switchScope 改为后端发起 ——
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
// 账号角色决定工作区:管理账号走 /admin,运营账号走 /monitor。
// 不再自动 switchScope 切到另一端——配置的运营页只在运营权限账号登录后可见
const needScope: 'Platform' | 'RCSMonitor' | null =
to.path.startsWith('/admin')
? 'Platform'
: to.path.startsWith('/monitor') ? 'RCSMonitor'
: null
if (needScope && auth.scope !== needScope) {
try {
await auth.switchScope(needScope)
} catch (_) {
// 偶发失败(网络/超时)重试一次:避免带着「另一个域」的 effectivePermissions 渲染当前页,
// 否则配置页会被 PermissionGuard 误判为隐藏 → 整页空白(刷新后重新 switch 才恢复)。
try { await auth.switchScope(needScope) } catch (_2) { /* 仍失败:维持原 scopeUI 多为 readonly */ }
const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
const fallback = list.find((p) => auth.hasPage(p.name))?.path
?? (auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
if (fallback !== to.path) {
ElMessage.warning('当前账号不能进入该区域,已回到可访问页面')
return fallback
}
}
@@ -29,6 +29,18 @@ const SCOPE_KEY = 'simple.auth.scope'
const RUN_MODE_KEY = 'simple.auth.runMode'
const PERM_KEY = 'simple.auth.perm'
/** 管理页 ↔ 运营页:权限勾选任一端,菜单都认。 */
const PAGE_TWINS: Record<string, string> = {
'admin-ops-log': 'monitor-ops',
'admin-notes': 'monitor-notes',
'admin-map-monitor': 'monitor-map',
'admin-vehicle-hub': 'monitor-vehicle-hub',
'monitor-ops': 'admin-ops-log',
'monitor-notes': 'admin-notes',
'monitor-map': 'admin-map-monitor',
'monitor-vehicle-hub': 'admin-vehicle-hub'
}
// 会话 45 HC-2:每个字段独立 try/catch,避免一个 corrupt 字段把整个 state 干净化。
function safeJsonParse<T>(key: string): T | null {
try {
@@ -96,14 +108,16 @@ export const useAuthStore = defineStore('auth', {
return ops.includes('*') ? 'interactive' : 'readonly'
},
/**
* scope 访route.name
* '*'
* 访route.name
* '*' monitor-ops admin-ops-log
*/
hasPage:
(s) =>
(key: string): boolean => {
const pages = s.effectivePermissions?.allowedPages ?? []
return pages.includes('*') || pages.includes(key)
if (pages.includes('*') || pages.includes(key)) return true
const twin = PAGE_TWINS[key]
return !!twin && pages.includes(twin)
}
},
actions: {
@@ -142,6 +156,10 @@ export const useAuthStore = defineStore('auth', {
}
try {
const me = await apiGetMe()
if (me.token) {
this.token = me.token
localStorage.setItem(TOKEN_KEY, me.token)
}
this.user = me.user
this.scope = me.scope
this.runMode = me.runMode
@@ -54,7 +54,6 @@ function loadInitial(): UiState {
const schema = Number(localStorage.getItem(THEME_SCHEMA_KEY) || '0')
const migrated = schema < THEME_SCHEMA_VERSION
if (migrated) {
// ponytail: 一次强制 outpost-light,兼容主题仍可从高级菜单切回
themeId = DEFAULT_THEME_ID
localStorage.setItem(THEME_SCHEMA_KEY, String(THEME_SCHEMA_VERSION))
}

Some files were not shown because too many files have changed in this diff Show More