Compare commits
10
Commits
4962d768d4
...
3968f5dc1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3968f5dc1f | ||
|
|
c9e835aaf7 | ||
|
|
c5d5f7a726 | ||
|
|
59ccd7934a | ||
|
|
79c9e36d89 | ||
|
|
4180140ae4 | ||
|
|
3686abdc78 | ||
|
|
912c44c9bb | ||
|
|
5d277cf17d | ||
|
|
7af29fe07f |
+2
-2
@@ -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
@@ -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 HA;MySQL 用 InnoDB Cluster;PostgreSQL 用 Patroni;SQL Server 用 Always On AG。
|
||||
|
||||
4. **统一协议层**
|
||||
所有进程间调用统一 **WebAPI(REST/JSON)+ WebSocket 事件流**;Vue 端直接对接 Platform 后端的 REST + WS;Platform 后端用 **YARP** 反代 SimpleLite 的设计/调度 API。
|
||||
所有进程间调用统一 **WebAPI(REST/JSON)+ WebSocket 事件流**;Vue 端直接对接 Platform 后端的 REST + WS;Platform 后端用 **YARP** 反代 Simple3 的设计/调度 API。
|
||||
|
||||
5. **统一身份**
|
||||
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
|
||||
`SimpleShared.Auth` 提供 JWT + RBAC;Platform 是主权限源(在线时),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 拉起**:用户只启动 SimpleLite;Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
|
||||
- **只有两个后端可执行文件**:`Simple3.exe` 与 `MiGu.Server.exe`;RCSMonitor 不再有独立后端。
|
||||
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3;Web-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` | 单例,注册所有 SystemMission;Web-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` | 单例,注册所有 SystemMission;Web-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 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
|
||||
| 3D 视口 | 本地 OpenGL(CycleGUI 原生) | **嵌入 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 形态**:**WSFC(Windows Server Failover Cluster)+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
|
||||
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
|
||||
- **认证**:建议混合身份认证;MiGu.Server 用 SQL Login,SimpleLite 桌面端可走 Windows 集成认证。
|
||||
- **认证**:建议混合身份认证;MiGu.Server 用 SQL Login,Simple3 桌面端可走 Windows 集成认证。
|
||||
- **TempDB**:每节点分摊;MultipleActiveResultSets(MARS)建议关闭,EF Core 已能避免。
|
||||
- **备份**:`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
|
||||
- **RTO / RPO**:RTO ≈ 10–30s(AG 自动 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 | WSS(webVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe) |
|
||||
| Vue Workspace3D ↔ Simple3 WebTerminal | WSS(webVRender 协议) | 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` 做乐观锁
|
||||
- Swagger:Platform / SimpleLite 都暴露 `/swagger`(生产可关)
|
||||
- Swagger:Platform / 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 WebTerminal(webVRender,Vue 嵌入用) | Web-Enabled |
|
||||
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
|
||||
| 8223 | Simple3 WebTerminal(webVRender,Vue 嵌入用) | 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 → IConfigSnapshotProvider;EF 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 Hub;JWT |
|
||||
| **M2 - WebAPI 协议层** | 1.5 周 | Simple3 集成 Kestrel;定义 `/api/projection/*`、`/api/ops/*`、`/api/config/*`、`/api/map/*` 等;WS Hub;JWT |
|
||||
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`:Workspace3D(嵌入 webVRender)+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
|
||||
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.Server(WebAPI + 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 Service;MiGu.Server 启动时校验父进程必须是 SimpleLite |
|
||||
| 10 | **Platform / RCSMonitor 启动归属(v1.4)** | **由 Simple3 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows Service;MiGu.Server 启动时校验父进程必须是 Simple3 |
|
||||
| 11 | **持久层多 Provider(v1.5)** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF Migrations,CI 全矩阵跑通;运行期由 `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 走原有 CycleGUI;Web-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 走原有 CycleGUI;Web-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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
|
||||
///
|
||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||
/// 设计动机:Simple3 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
|
||||
/// (减少 Simple3 复杂度),采用一个轻量约定:
|
||||
///
|
||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)Simple3 直接放行(开发机直连不受影响);
|
||||
/// - <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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;<= 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;<= 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// M1:waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
|
||||
// M1:waitForReady=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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-4:JWT 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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.signal(PLC 握手)。
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+19
-17
@@ -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;
|
||||
}
|
||||
+128
-109
@@ -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>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||
/// <summary>外部已存在的 Simple3(MiGu.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+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||||
// 会话 N+2:MiGu.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/Simple3(OutputPath 常直接写到 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>:本次实际生效的 displayMode(web / 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-23
@@ -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.ini,CWD 选错会出意外。</summary>
|
||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</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 调试)。
|
||||
///
|
||||
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
||||
/// </summary>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+8
-5
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 8222(AR-1/AR-2 配套)。
|
||||
// YARP + transform:把 Platform 内部 token 透传给 Simple3 8222(AR-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 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
|
||||
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
|
||||
builder.Services.AddSingleton<Simple3Launcher>();
|
||||
builder.Services.AddSingleton<SignalDataStore>();
|
||||
|
||||
// OTA(WatchDog 编排):包库 / 任务 / 出站客户端
|
||||
@@ -289,35 +291,32 @@ _ = app.Services.GetRequiredService<JwtIssuer>();
|
||||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
|
||||
_ = app.Services.GetRequiredService<RbacStore>();
|
||||
// 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。
|
||||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||||
// 主动实例化 Simple3Launcher(FollowParent=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
@@ -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 内部 API(RV-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 内部 API(RV-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/.../dist;assets/ 与 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 的两种方式
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.cs:OnlineCar/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.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
|
||||
"Ops": {
|
||||
"Dispatch": {
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
@@ -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 路 API:create/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 API;listKinds / 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 路 API:create/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 API;listKinds / 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)
|
||||
|
||||
## 阶段 5:OrchestrationView 设计与编排(已完成)
|
||||
|
||||
@@ -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. 后端 build(SimpleLite + MiGu.Server)
|
||||
dotnet build SimpleLite\SimpleLite.csproj
|
||||
# 1. 后端 build(Simple3 + 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 四栏壳)
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 header。fetch 这里手动对齐
|
||||
* 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_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
|
||||
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(Simple3 关闭时仍可读,含完整历史)。 */
|
||||
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/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
|
||||
/** 优先走 Simple3 /projection/cars;502 或空列表时回退 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=ForceStop,Clumsy=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
|
||||
/** 嵌入平台页时传 true:SimpleLite 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
|
||||
/** 嵌入平台页时传 true:Simple3 仅显示底栏(对齐/选择/右击/图层/内容/录制回放) */
|
||||
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 :8223(iframe 同源),失败再试 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(不阻塞 iframe)。SimpleLite 侧 WebTerminal 已默认 canvas-only,
|
||||
* declare 主要用于多 tab 显式标记;失败也不影响平台纯画布模式。
|
||||
* 尽力 declare。Simple3 须打 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 必须先 declare:migu 默认是 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)
|
||||
|
||||
+1072
-279
File diff suppressed because it is too large
Load Diff
+108
-62
@@ -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>
|
||||
|
||||
+53
-23
@@ -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>
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
+3
-3
@@ -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>
|
||||
+103
-114
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+393
-87
@@ -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: '站点、路径、装饰物',
|
||||
|
||||
+3
-3
@@ -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 iframe(SimpleLite WebTerminal)的 postMessage 双向桥。
|
||||
* 与 Workspace3D iframe(Simple3 WebTerminal)的 postMessage 双向桥。
|
||||
*
|
||||
* 当前 SimpleLite WebTerminal 默认会通过 window.postMessage 抛出 `workspace.pick`
|
||||
* 当前 Simple3 WebTerminal 默认会通过 window.postMessage 抛出 `workspace.pick`
|
||||
* 与 `workspace.select` 事件给父窗口(Workspace3D.vue 收到后再 emit 给业务页)。
|
||||
* 反向的 vue→iframe 通道(设置工具、应用对齐等)走 SimpleLite 反射 API(HTTP/SSE),
|
||||
* 反向的 vue→iframe 通道(设置工具、应用对齐等)走 Simple3 反射 API(HTTP/SSE),
|
||||
* 这里的桥只承担**坐标 / 选中 / hover** 三类 iframe→vue 数据。
|
||||
*/
|
||||
|
||||
|
||||
@@ -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-6:switchScope 改为后端发起 ——
|
||||
// 必须 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) { /* 仍失败:维持原 scope,UI 多为 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
Reference in New Issue
Block a user