feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+21
-8
@@ -1,11 +1,24 @@
|
|||||||
# ---> Vue
|
# .NET
|
||||||
# gitignore template for Vue.js projects
|
**/bin/
|
||||||
#
|
**/obj/
|
||||||
# Recommended template: Node.gitignore
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.dll
|
||||||
|
*.pdb
|
||||||
|
!**/wwwroot/**/*.dll
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
# MiGu.Server 运行时数据(含敏感 token)
|
||||||
docs/_book
|
MiGu.Server/data/*.json
|
||||||
|
MiGu.Server/data/.internal-token
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
# 可选:若希望 wwwroot 仅 CI 产出、不入库,取消下行注释
|
||||||
test/
|
# MiGu.Server/wwwroot/assets/
|
||||||
|
# MiGu.Server/wwwroot/index.html
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|||||||
+1946
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
# 未提交代码审核 — 待优化问题清单
|
||||||
|
|
||||||
|
> **用途**:供其他 AI 模型或开发者按项修复。
|
||||||
|
> **仓库路径**:`E:\Work\Core\Simple-FR\Simple`
|
||||||
|
> **分支**:`dev_simpleLite`(审核时)
|
||||||
|
> **审核日期**:2026-05-29
|
||||||
|
> **审核范围**:后端 C#(MiGu.Server、SimpleLite、SimpleCore 相关 diff);**不含**前端 Vue 全量审阅与 `dotnet build` 验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用说明(给优化 AI)
|
||||||
|
|
||||||
|
1. 按 **优先级** 从高到低处理:`P0` → `P1` → `P2`。
|
||||||
|
2. 每项包含:**问题 ID**、**文件**、**现象**、**建议改法**、**验收标准**。
|
||||||
|
3. 修改时遵循仓库现有风格;**不要**扩大无关重构范围。
|
||||||
|
4. 修复后在对应项下可勾选 `[x]`(本文件由人工维护勾选状态)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题总表(快速索引)
|
||||||
|
|
||||||
|
| ID | 优先级 | 状态 | 标题 | 主要文件 |
|
||||||
|
|----|--------|------|------|----------|
|
||||||
|
| ISSUE-10 | P0 | ✅ 已修复 | CarRepository 反序列化白名单可能过严 | `SimpleLite/Persistence/Repositories/CarRepository.cs` |
|
||||||
|
| ISSUE-05 | P1 | ✅ 已修复 | 项目/应用配置 API 未校验 writableViaApi | `SimpleLite/Web/ReflectionApiController.cs` |
|
||||||
|
| ISSUE-04 | P1 | 🔧 部分处理 | Launcher 等新文件未纳入版本库 | `MiGu.Server/Launcher/*` 等 |
|
||||||
|
| ISSUE-01 | P2 | 📝 已评估·可配置缓解 | 登录仍可能阻塞至 Readiness 超时 | `MiGu.Server/Controllers/AuthController.cs` |
|
||||||
|
| ISSUE-02 | P2 | 📝 已评估·维持现状 | Me/SwitchScope RunMode 复用实例时偏保守 | `MiGu.Server/Controllers/AuthController.cs` |
|
||||||
|
| ISSUE-03 | P2 | ✅ 已处理·README | 默认 admin 凭据(部署) | `MiGu.Server/appsettings.json` |
|
||||||
|
| ISSUE-06 | P2 | ✅ 已修复 | GET monitor-config 写副作用 | `SimpleLite/Web/ReflectionApiController.cs` |
|
||||||
|
| ISSUE-07 | P2 | 📝 已评估·待产品 | POST monitor-config 立即写盘 | `SimpleLite/Web/ReflectionApiController.cs` |
|
||||||
|
| ISSUE-08 | P2 | ✅ 已修复 | Toolbar GetState 响应格式不一致 | `SimpleLite/Web/WorkspaceToolbarApiController.cs` |
|
||||||
|
| ISSUE-09 | P2 | 📝 已评估·技术债 | 录制/图层 API 重复维护 | `MapEditApiController` + `WorkspaceToolbarApiController` |
|
||||||
|
| ISSUE-11 | P2 | 📝 已评估·观察项 | 高频 redraw 仍可能偶发旧帧 | `SimpleLite/Rendering/SimpleSceneRenderer.cs` |
|
||||||
|
| ISSUE-12 | P2 | 📝 已评估·待产品 | Online/Offline 远程 MethodMember 权限 | `SimpleLite/RCS/CarTypes/Car.cs` + 鉴权链 |
|
||||||
|
| ISSUE-13 | P2 | 📝 已评估·依赖升级 | GetPosition realtime 未启用 | `SimpleLite/Workspace/WorkspacePickSession.cs` |
|
||||||
|
| ISSUE-14 | P2 | 📝 已评估·环境项 | 2D 平移绑定依赖 CycleGUI 版本 | `SimpleLite/Workspace/WorkspaceViewModeService.cs` |
|
||||||
|
| ISSUE-15 | P2 | 📝 已评估·低优维持 | 表格双击为时间窗口模拟 | `SimpleLite/UI/WorkbenchTableRowUi.cs` |
|
||||||
|
| ISSUE-16 | P2 | 📝 已评估·待产品 | 底栏 CAD 入口已移除(产品确认) | `WorkspaceBottomBar.cs` 等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — 合并前建议先验证/修复
|
||||||
|
|
||||||
|
### ISSUE-10 — CarRepository 反序列化白名单可能过严
|
||||||
|
|
||||||
|
- **状态**:[x] 已修复(2026-05-29)
|
||||||
|
- **文件**:`SimpleLite/Persistence/Repositories/CarRepository.cs`(`CarTypeBinder`、`JsonOpts`)
|
||||||
|
- **现象**:`TypeNameHandling.Auto` + 自定义 `ISerializationBinder` 白名单未包含 `System.String`、`System.Int32`、`System.Boolean` 等 BCL 基础类型。`customProps` 由 `JsonConvert.SerializeObject(car, JsonOpts)` 序列化**整辆车**,JSON 中泛型容器(如 `Dictionary<string, int>`)的 `$type` 可能含 `System.*`,反序列化时 `BindToType` 抛 `SerializationException`。
|
||||||
|
- **风险**:升级后 `LoadAllIntoSimpleLib` 部分车辆加载失败。
|
||||||
|
- **建议改法**(择一或组合):
|
||||||
|
1. 在白名单中增加安全 BCL 类型前缀/精确名:`System.String`、`System.Int32`、`System.Int64`、`System.Boolean`、`System.Single`、`System.Double`、`System.Decimal`、`System.DateTime` 等;或允许 `System` 下仅 primitive 与 `System.Collections.Generic` 的封闭列表。
|
||||||
|
2. 缩小序列化范围:勿对整个 `car` 使用 `TypeNameHandling.Auto`,仅对已知 DTO/字段序列化。
|
||||||
|
3. 提供 `RegisterAllowedNamespacePrefix("System")` 的文档说明(不推荐无限制放行整个 System)。
|
||||||
|
- **验收标准**:
|
||||||
|
- 用**现有生产/测试库**中真实 `customProps` JSON 跑 `LoadAllIntoSimpleLib`,无 SerializationException。
|
||||||
|
- 恶意 `$type`(如 `System.Windows.Forms.*` gadget)仍被拒绝。
|
||||||
|
- **回归命令**(示例):加载含多车型、多插件的程序集后启动 SimpleLite 并打开车辆列表。
|
||||||
|
- **修复说明**(2026-05-29):在 `CarTypeBinder` 新增精确类型白名单 `_allowedExactTypeNames`,放行安全的 BCL 基础类型(string/int/long/bool/float/double/decimal/DateTime/DateTimeOffset/TimeSpan/Guid/char/byte/short/Object/Enum/Nullable<T> 等);`IsAllowedRawName` 在命名空间前缀匹配前先做「精确全名」匹配。修复后泛型容器实参(如 Dictionary<string,int>、Dictionary<string,object>)在字符串层 `IsAllowedSerializedTypeName` 与已解析层 `IsAllowedResolvedType` 两条路径均不再误抛 `SerializationException`。因采用精确全名而非放行整个 `System.*` 前缀,`System.Windows.Forms.*` 等 gadget 链仍被拒绝。**仍需**用真实生产/测试库回归 `LoadAllIntoSimpleLib`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — 建议本轮一并处理
|
||||||
|
|
||||||
|
### ISSUE-05 — 项目/应用配置 API 未校验 writableViaApi
|
||||||
|
|
||||||
|
- **状态**:[x] 已修复(2026-05-29)
|
||||||
|
- **文件**:`SimpleLite/Web/ReflectionApiController.cs`
|
||||||
|
- `SetProjectField`(`POST /project/fields/{field}`)
|
||||||
|
- `SetAppConfigField`(`POST /app-config/fields/{field}`)
|
||||||
|
- **现象**:仅检查 `FieldMember.locked` 与 `MapObjectFactory.IsSensitiveFieldNamePublic`;**未**检查 `FieldMember.writableViaApi`(`SimpleCore/Scene.cs` 已新增,默认 `true`)。
|
||||||
|
- **对比**:`MapObjectFactory.TryApplyField` 已拒绝 `writableViaApi == false`。
|
||||||
|
- **建议改法**:在两处 setter 中读取 `FieldMember`,若 `!fm.writableViaApi` 返回 403 及明确文案;与 `TryApplyField` 日志风格一致。
|
||||||
|
- **验收标准**:对标记 `writableViaApi=false` 的字段,Web POST 返回 403,内存值不变。
|
||||||
|
- **修复说明**(2026-05-29):在 `SetProjectField`(`Scene.conf`)与 `SetAppConfigField`(`Configuration.conf`)的 `fm.locked` 检查之后新增 `if (!fm.writableViaApi)` 拦截,命中即 `Diagnosis.Post` 记录并返回 403(内存值不变),与 `MapObjectFactory.TryApplyField` 的双层防御保持一致。注:前端字段列举(`ListFieldMembers` / 字段记录)暂未暴露 `writableViaApi`,如需「预先禁用不可写输入框」可后续在响应中增列该布尔值(属前端契约增量,未纳入本次范围)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-04 — 新增文件未纳入 Git(提交遗漏)
|
||||||
|
|
||||||
|
- **状态**:[~] 部分处理(2026-05-29):`.gitignore` 已补;编译必需的后端源文件待人工 `git add` + `commit`
|
||||||
|
- **路径**(审核时为 untracked):
|
||||||
|
- `MiGu.Server/Launcher/SimpleLiteLauncher.cs`
|
||||||
|
- `MiGu.Server/Launcher/SimpleLiteOptions.cs`
|
||||||
|
- `SimpleLite/Web/WorkspaceToolbarApiController.cs`
|
||||||
|
- `SimpleLite/Web/MonitorVisibilityConfig.cs`
|
||||||
|
- `SimpleLite/Workspace/CurveControlPointDragService.cs`
|
||||||
|
- (前端相关见下方「未审范围」)
|
||||||
|
- **现象**:`AuthController` 依赖 `SimpleLiteLauncher` DI;未 add 则他人 clone 无法编译/运行。
|
||||||
|
- **建议改法**:确认属于本需求后 `git add`;`.codegraph/`、`.cursor/mcp.json` 按 `.gitignore` 决定是否提交。
|
||||||
|
- **验收标准**:干净 clone + build 能通过;Launcher 与 Toolbar 路由可访问。
|
||||||
|
- **处理进度**(2026-05-29):
|
||||||
|
- ✅ 已补 `.gitignore`:忽略 `.codegraph/`(codegraph 本地索引缓存)与 `/SimpleCore/ref/`(同 `/ref/Ref` 的未知用途二进制),二者已从 untracked 列表移除。
|
||||||
|
- ✅ 已确认 `AuthController` 构造函数注入 `SimpleLiteLauncher`(第 69/72 行)→ `Launcher/*.cs` 为编译必需,必须入库。
|
||||||
|
- ⏳ 待人工执行(编译必需的后端源文件 + 本审核文档;单行命令,PowerShell/bash 均可):
|
||||||
|
|
||||||
|
```
|
||||||
|
git add MiGu.Server/Launcher/SimpleLiteLauncher.cs MiGu.Server/Launcher/SimpleLiteOptions.cs SimpleLite/Web/WorkspaceToolbarApiController.cs SimpleLite/Web/MonitorVisibilityConfig.cs SimpleLite/Workspace/CurveControlPointDragService.cs Doc/ .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
- ❓ 需决策是否入库:`.cursor/mcp.json`、`.cursor/rules/codegraph.mdc`(团队是否共享该 MCP / 规则配置)。
|
||||||
|
- 📦 前端新文件(`frontends/apps/simple-platform-vue/**`:`workspaceToolbar.ts`、`MonitorSelectionPanel.vue`、`FieldMemberEditor.vue`、`CarStyleEditor*`、`ProjectPropertiesView.vue`、`MapMonitorConfigView.vue` 等)归「前端另开任务」范围,建议随前端审核一并 add。
|
||||||
|
- ⚠ 未执行 `git commit`:提交内容与时机由人工掌控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — 按产品/环境决定或后续迭代
|
||||||
|
|
||||||
|
### ISSUE-01 — 登录仍可能阻塞至 Readiness 超时
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):现有配置即可缓解,暂不改代码
|
||||||
|
- **文件**:`MiGu.Server/Controllers/AuthController.cs`(`Login` → `Task.Run(() => _launcher.MaybeStart(...))`)
|
||||||
|
- **现象**:`MaybeStart` 内 `WaitForProjectionReady` 默认最多 8000ms,登录请求仍等待该 Task。
|
||||||
|
- **建议改法**:缩短超时、登录先返回 + 前端轮询 `/projection` 就绪、或 fire-and-forget + `LaunchWarning` 已有字段提示。
|
||||||
|
- **验收标准**:登录 P95 延迟可接受;SimpleLite 未就绪时前端有明确降级(`Detached` / `LaunchWarning`)。
|
||||||
|
- **评估说明**(2026-05-29):报告建议的「缩短超时 / 登录先返回」**现有设计已通过配置支持**,无需改代码:`SimpleLiteOptions.ReadinessTimeoutMs`(绑定 `appsettings.json:SimpleLite.ReadinessTimeoutMs`,默认 8000)注释明确 `0 = 不等待(登录立即返回)`、`负值 = 无限等待`;`Login` 已用 `Task.Run` 释放请求线程,失败时回填 `Detached` + `LaunchWarning`。运维若需更快登录可将该值调小或置 0(代价:`RunMode` 可能暂不准,由 `LaunchWarning` 降级提示)。真正的「fire-and-forget + 前端轮询 `/projection`」属前后端协议改动,留作后续迭代。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-02 — Me / SwitchScope 的 RunMode 在复用外部 SimpleLite 时偏保守
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):当前保守映射为有意设计,维持现状
|
||||||
|
- **文件**:`MiGu.Server/Controllers/AuthController.cs`(`Me`、`SwitchScope`、`ResolveRunMode`)
|
||||||
|
- **现象**:`ReusingExisting` / `ExternalReuseLaunchMode` 常归为 `WebEnabled`,与用户本次 `WebOnly` 选择可能不一致。
|
||||||
|
- **建议改法**:前端强依赖 `LaunchWarning`;或 HTTP 探针推断 displayMode(成本高)。
|
||||||
|
- **验收标准**:用户知悉「已有实例未应用新模式」;无静默错误状态。
|
||||||
|
- **评估说明**(2026-05-29):`ResolveRunMode` / `Me` / `SwitchScope` 在 `ExternalReuseLaunchMode`、`ReusingExisting` 时归 `WebEnabled` 是**有意的保守降级**(代码注释已说明:复用既有实例时其真实 displayMode 不可知,归 WebEnabled 最不会误导用户「以为本地窗口不存在」)。用 HTTP 探针推断成本高且不可靠;前端已可凭 `LaunchWarning` 提示「已有实例未应用新模式」。结论:维持现状,作为前端体验项后续跟进,不改后端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-03 — 默认 admin 凭据(部署安全)
|
||||||
|
|
||||||
|
- **状态**:[x] 已处理(2026-05-29):已在 README 增加生产安全须知
|
||||||
|
- **文件**:`MiGu.Server/appsettings.json`(`Auth:Users`)
|
||||||
|
- **建议**:生产用环境变量/密钥管理覆盖;README 注明勿用默认密码。
|
||||||
|
- **处理说明**(2026-05-29):已在 `MiGu.Server/README.md` 新增「安全须知(生产部署必读)」小节,明确 `admin/admin`、`ops/ops` 与 `Jwt.Secret`/`Internal.Token=REPLACE_ME` 仅供开发,并给出环境变量覆盖示例(`Auth__Users__admin__Password` 等)。`appsettings.json` 默认值保持不变以维持开箱即用的本地开发体验(属部署规范,非代码缺陷)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-06 — GET monitor-config 写副作用
|
||||||
|
|
||||||
|
- **状态**:[x] 已修复(2026-05-29)
|
||||||
|
- **文件**:`SimpleLite/Web/ReflectionApiController.cs`(`GetMonitorConfig`)
|
||||||
|
- **现象**:`monitorVisibility == null` 时 GET 会 `Configuration.conf.monitorVisibility = new MonitorVisibilityConfig()`。
|
||||||
|
- **建议改法**:GET 只读快照,不写回 `Configuration.conf`;初始化放在 `Configuration` 构造/`FromFile`。
|
||||||
|
- **验收标准**:多次 GET 不改变磁盘上的 `simple.json`(除非 POST)。
|
||||||
|
- **修复说明**(2026-05-29):删除 `GetMonitorConfig` 中的 `Configuration.conf.monitorVisibility = conf;` 写回行,GET 改为纯只读快照。`monitorVisibility` 非 null 已由 `Configuration` 字段默认值(`= new()`,第 74 行)、构造函数(`??=`,第 32 行)与 `FromFile`(`??=`,第 46 行)三处共同保证,GET 无需补写。多次 GET 不再触碰 `Configuration.conf`,自然不会引发后续写盘。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-07 — POST monitor-config 每次全量写 simple.json
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):维持「保存即写盘」,待产品决定是否需要预览态
|
||||||
|
- **文件**:`ReflectionApiController.PostMonitorConfig`
|
||||||
|
- **现象**:POST 成功即 `Configuration.ToFile("simple.json")`,无单独「保存」步骤。
|
||||||
|
- **建议改法**:若需「预览再保存」,拆分为 PATCH 内存 + `POST /monitor-config/save`;与 `PostAlarmColors` / `SaveCarStyle` 模式对齐。
|
||||||
|
- **评估说明**(2026-05-29):当前「POST 成功即写盘」行为正确(非 bug)。是否拆分为「预览态 + 显式保存」取决于产品对监控可见性配置的交互预期,拆分需新增 `POST /monitor-config/save` 端点并由前端配合「保存」按钮,属产品/前端联动改动。结论:维持现状,待产品确认是否需要预览态后再迭代。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-08 — WorkspaceToolbar GetState 响应格式不一致
|
||||||
|
|
||||||
|
- **状态**:[x] 已修复(2026-05-29)
|
||||||
|
- **文件**:`SimpleLite/Web/WorkspaceToolbarApiController.cs`(`GetState`)
|
||||||
|
- **现象**:`GetState` 返回 `{ success, code, data }`;其它端点用 `ReflectionEnvelope<object>`。
|
||||||
|
- **建议改法**:统一为 `ReflectionEnvelope`;或前端 adapter 层统一解析(若改 Vue 需另开任务)。
|
||||||
|
- **验收标准**:`WorkspaceCanvasToolbar.vue`(未审)与 API 契约一致。
|
||||||
|
- **修复说明**(2026-05-29):`GetState` 成功分支由手写匿名对象 `{ success, code, data }` 改为 `ReflectionEnvelope<object>.Ok(BuildStateAnon())`,输出补齐 `message` 字段。经核对前端 `workspaceToolbar.ts` 的 `Envelope<T>` 接口本就声明 `{ success, code, data, message }` 且 `unwrap` 在失败时读 `resp.message`——本次改动使后端与该契约完全对齐,`data`(`BuildStateAnon`)结构不变,前端仅多读到一个 `message` 字段,向后兼容,无需改 Vue。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-09 — 录制/图层 API 重复
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):逻辑已收敛到共享单例,维持双入口
|
||||||
|
- **文件**:
|
||||||
|
- `SimpleLite/Web/MapEditApiController.cs`(`/layers`、`/recording/*`、`/playback/*`)
|
||||||
|
- `SimpleLite/Web/WorkspaceToolbarApiController.cs`(同源能力)
|
||||||
|
- **建议改法**:抽取 `RecordingApiHelper` / 单 controller;或文档标明 MapEdit=地图编辑器、Toolbar=Embed iframe,避免双份逻辑漂移。
|
||||||
|
- **验收标准**:修一处行为两处一致,或 deprecate 其一。
|
||||||
|
- **评估说明**(2026-05-29):核对两个 controller 后确认——`MapEditApiController`(`/objects/*`、`/layers`、`/recording/*`、`/playback/*`)与 `WorkspaceToolbarApiController`(`/toolbar/*`)**都只是薄 HTTP 封装**,真正的状态与逻辑均委托给共享单例 `PlaybackController` / `SimulationRecorder` / `RecordingLibrary` / `SimpleUI.LayerToShowRef`。因此「修一处行为两处一致」**本质已满足**(无逻辑漂移,仅路由壳与响应组装重复)。分工:MapEdit=地图编辑器全功能页,Toolbar=Embed/CanvasOnly iframe 精简栏。结论:维持双入口(强行合并会牵动两套前端),作为技术债记录;后续若消重可抽取 `RecordingApiHelper` 收拢响应组装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-11 — 高频 redraw 仍可能偶发旧帧
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):观察项,未复现不改
|
||||||
|
- **文件**:`SimpleLite/Rendering/SimpleSceneRenderer.cs`
|
||||||
|
- **现象**:已做 force 16ms、运动 33ms、`NotifyStateChanged` 直通;极端高频仍可能竞态。
|
||||||
|
- **建议**:仅在实际复现时合并 redraw 或加锁。
|
||||||
|
- **评估说明**(2026-05-29):现有节流(force 16ms / 运动 33ms / `NotifyStateChanged` 直通)已覆盖常规场景;极端高频旧帧为理论竞态,未见稳定复现。盲目加锁可能引入渲染线程争用、得不偿失。结论:保持观察,待出现可复现用例再针对性合并 redraw 或加锁。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-12 — Car.OnlineCar / OfflineCar 经 Web 反射调用
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):需产品确认角色边界,未擅自加限制
|
||||||
|
- **文件**:`SimpleLite/RCS/CarTypes/Car.cs`;调用链 `ReflectionApiController` + Platform 鉴权
|
||||||
|
- **建议**:确认 Platform 角色是否允许远程触发;必要时 MethodMember 加权限或 Web 层拦截。
|
||||||
|
- **评估说明**(2026-05-29):`OnlineCar` / `OfflineCar` 经 `ReflectionApiController` 反射触发,已受 Platform JWT 鉴权链保护(需登录且具备相应 scope)。是否进一步收紧(仅 `role-platform`、或 `MethodMember` 增权限位)取决于产品对「远程上/下线车辆」的角色边界定义,属权限策略决策,不宜由代码审核单方收紧(可能误伤合法运营操作)。结论:待产品确认角色边界后再决定是否加 `MethodMember` 权限或 Web 层拦截。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-13 — GetPosition realtime 未启用
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):阻塞于 CycleGUI 协议,维持注释
|
||||||
|
- **文件**:`SimpleLite/Workspace/WorkspacePickSession.cs`
|
||||||
|
- **现象**:`getPos.realtime = true` 仍注释;依赖 `OnPickFeedback` 双路驱动拖动。
|
||||||
|
- **建议**:CycleGUI 协议支持后取消注释并删「尚未上线」说明。
|
||||||
|
- **评估说明**(2026-05-29):`getPos.realtime = true` 依赖 CycleGUI 协议层支持,当前版本未提供,强行开启会导致拖动反馈异常;现有 `OnPickFeedback` 双路驱动已能正常工作。结论:阻塞于外部依赖(CycleGUI 升级),维持注释现状,待协议支持后取消注释。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-14 — 2D 平移绑定依赖 CycleGUI API
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):环境/版本项,建议补版本矩阵
|
||||||
|
- **文件**:`SimpleLite/Workspace/WorkspaceViewModeService.cs`(`TryBind2DPanOnly`)
|
||||||
|
- **现象**:反射 `SetWorkspaceBehaviour` 字段名;版本不匹配时 2D 右键可能仍改俯仰。
|
||||||
|
- **建议**:版本矩阵文档;失败时 UI 提示升级 CycleGUI。
|
||||||
|
- **评估说明**(2026-05-29):`TryBind2DPanOnly` 经反射读 `SetWorkspaceBehaviour` 字段名,与 CycleGUI 版本强相关。本质是「运行环境/依赖版本匹配」问题,非代码逻辑缺陷。结论:作为环境项记录;建议在 CycleGUI 版本矩阵文档标注最低版本,并在反射失败时通过 UI 提示升级(可后续小改)。本轮不改逻辑以免误判版本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-15 — 表格双击为 400ms 时间窗口模拟
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):可接受方案,维持
|
||||||
|
- **文件**:`SimpleLite/UI/WorkbenchTableRowUi.cs`
|
||||||
|
- **说明**:CycleGUI 无原生 DoubleClick;已有 `PruneExpiredClickTimestamps` 防泄漏。
|
||||||
|
- **评估说明**(2026-05-29):CycleGUI 立即模式 UI 无原生 DoubleClick 事件,400ms 时间窗口模拟是合理的既定方案,且已有 `PruneExpiredClickTimestamps` 防时间戳泄漏。结论:方案可接受,维持现状,低优先级。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE-16 — 底栏 CAD 入口已移除
|
||||||
|
|
||||||
|
- **状态**:[~] 已评估(2026-05-29):非 bug,待产品确认无强依赖
|
||||||
|
- **文件**:`WorkspaceBottomBar.cs`、`WorkspaceFloatingToolbar.cs`
|
||||||
|
- **说明**:CAD 仍可通过主菜单/插件;确认用户无强依赖底栏入口。
|
||||||
|
- **评估说明**(2026-05-29):底栏 CAD 入口移除是产品调整(非缺陷),CAD 功能仍可经主菜单/插件访问。结论:非 bug,仅需产品确认用户无对「底栏 CAD 入口」的强依赖即可关闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已确认修复项(无需再改,供对照)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 脚本 KPI | `script` count 改为 `CarProgram.GetPrograms().Length` |
|
||||||
|
| 脚本 CRUD | Web 禁止新建/删除 script;`ResolveObject` 走 CarProgram |
|
||||||
|
| 删 script 误删车 | `MapObjectFactory.DeleteByKind` script 分支独立返回 400 |
|
||||||
|
| JSON camelCase | `SetViewFilter` 用 Dictionary;Toolbar 用 `JsonSerializerCase.CamelCase` |
|
||||||
|
| 敏感字段 | `MapObjectFactory` 前缀黑名单 + `writableViaApi` |
|
||||||
|
| 项目保存路径 | `TryResolveSafeProjectPath` 白名单 |
|
||||||
|
| 对齐/选择解耦 | `WorkspacePointerSnap` 用 `AlignSnap*` |
|
||||||
|
| GetPosition 竞态 | `terminated` ReferenceEquals 防误删新会话 |
|
||||||
|
| 车体默认白色 | `WorkspaceViewportStyle` 纯白判定修复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未审范围(后续可另开审核任务)
|
||||||
|
|
||||||
|
- `frontends/apps/simple-platform-vue/**`(含 `DashboardView.vue` 等大 diff)
|
||||||
|
- `SimpleLite/UI/ComposerDockPanel.cs` 大 diff
|
||||||
|
- `build-platform-frontend.bat`
|
||||||
|
- 全解决方案 `dotnet build` / 前端 `npm run build`
|
||||||
|
- 未跟踪前端文件:`workspaceToolbar.ts`、`WorkspaceCanvasToolbar.vue`、`CarStyleEditor*.vue` 等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给优化 AI 的推荐执行顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ISSUE-10(回归真实 DB → 修 binder 或序列化策略)
|
||||||
|
2. ISSUE-05(writableViaApi 对齐)
|
||||||
|
3. ISSUE-04(git add 缺失文件)
|
||||||
|
4. ISSUE-08 / ISSUE-09(若本轮动前端 Toolbar)
|
||||||
|
5. 其余 P2 按产品排期
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-05-29 | 初版:基于会话 58 代码审核整理 |
|
||||||
|
| 2026-05-29 | 优化轮(会话 65):修复 ISSUE-10/05/06/08(代码改动);ISSUE-03 在 `MiGu.Server/README.md` 增「安全须知」、ISSUE-04 补 `.gitignore` 并给出 `git add` 命令(后端必需文件待人工提交);ISSUE-01/02/07/09/11/12/13/14/15/16 完成评估并记录决策。`dotnet build SimpleLite/SimpleLite.csproj` 通过(0 错误 / 282 预存警告)。 |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 代码审核问题 Checklist(给其他 AI)
|
||||||
|
|
||||||
|
完整说明见:`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0
|
||||||
|
|
||||||
|
- [ ] **ISSUE-10** `SimpleLite/Persistence/Repositories/CarRepository.cs` — `CarTypeBinder` 白名单过严,可能拦 `System.String`/`System.Int32` 等,导致 `DeserializeObject(car)` 失败。修复:放宽安全 BCL 类型或缩小 `TypeNameHandling.Auto` 范围;用真实 DB 回归。
|
||||||
|
|
||||||
|
## P1
|
||||||
|
|
||||||
|
- [ ] **ISSUE-05** `SimpleLite/Web/ReflectionApiController.cs` — `SetProjectField` / `SetAppConfigField` 未检查 `[FieldMember].writableViaApi`,与 `MapObjectFactory.TryApplyField` 不一致。修复:writableViaApi=false → 403。
|
||||||
|
|
||||||
|
- [ ] **ISSUE-04** 未跟踪文件需 `git add`:`MiGu.Server/Launcher/*`、`WorkspaceToolbarApiController.cs`、`MonitorVisibilityConfig.cs`、`CurveControlPointDragService.cs`。
|
||||||
|
|
||||||
|
## P2(可选)
|
||||||
|
|
||||||
|
- [ ] **ISSUE-01** `AuthController.Login` — `MaybeStart` 仍可能阻塞 ~8s。
|
||||||
|
- [ ] **ISSUE-02** `Me`/`SwitchScope` — 复用外部 SimpleLite 时 RunMode 可能不准。
|
||||||
|
- [ ] **ISSUE-03** `appsettings.json` — 默认 admin 密码,生产需覆盖。
|
||||||
|
- [ ] **ISSUE-06** `GetMonitorConfig` — GET 写 `Configuration.conf.monitorVisibility`。
|
||||||
|
- [ ] **ISSUE-07** `PostMonitorConfig` — POST 即写 `simple.json`。
|
||||||
|
- [ ] **ISSUE-08** `WorkspaceToolbarApiController.GetState` — 响应格式与 `ReflectionEnvelope` 不一致。
|
||||||
|
- [ ] **ISSUE-09** MapEdit vs Toolbar 录制/图层 API 重复,易漂移。
|
||||||
|
- [ ] **ISSUE-11** `SimpleSceneRenderer` — 极端高频 redraw 竞态(观察)。
|
||||||
|
- [ ] **ISSUE-12** `Car.OnlineCar`/`OfflineCar` — Web 反射权限确认。
|
||||||
|
- [ ] **ISSUE-13** `WorkspacePickSession` — `realtime=true` 待 CycleGUI。
|
||||||
|
- [ ] **ISSUE-14** `WorkspaceViewModeService.TryBind2DPanOnly` — 依赖 CycleGUI 版本。
|
||||||
|
- [ ] **ISSUE-15** `WorkbenchTableRowUi` — 双击 400ms 模拟(低优)。
|
||||||
|
- [ ] **ISSUE-16** 底栏 CAD 已移除 — 产品确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐顺序
|
||||||
|
|
||||||
|
`ISSUE-10` → `ISSUE-05` → `ISSUE-04` → 其余 P2
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 本周代码复审报告(2026-05-23 ~ 2026-05-29)
|
||||||
|
|
||||||
|
> **用途**:对本周全部改动(含已提交)的一次复审,聚焦后端 C# 安全性与正确性。
|
||||||
|
> **仓库**:`E:\Work\Core\Simple-FR\Simple` **分支**:`dev_simpleLite`
|
||||||
|
> **基线**:`d8d4d3c`(2026-05-21)→ **HEAD**:`1412bf0`(含一次合并远程的 merge)
|
||||||
|
> **本文档为本地工作记录,默认不纳入版本库。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 范围与方法
|
||||||
|
|
||||||
|
- 本周累计:**37 commit,277 文件,+32461 / −5740 行**;其中后端 C# **96 文件 / +15302 行**为审核核心。
|
||||||
|
- `MiGu.Server/wwwroot/assets`(占 dirstat 28%)为前端构建产物,忽略。
|
||||||
|
- 说明:本轮原计划用 5 个并行子代理分模块审核,但因账户账单(unpaid invoice)子代理不可用,改为**人工聚焦后端安全关键面**深度审核;功能性大文件(录制/流/UI/Workspace)与前端做轻量或留待后续。
|
||||||
|
|
||||||
|
### 已深度审阅
|
||||||
|
| 文件 | 关注点 | 结论 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `MiGu.Server/Launcher/SimpleLiteLauncher.cs` | 命令注入/进程生命周期/JobObject | 良好 |
|
||||||
|
| `SimpleLite/Web/AiCompletionService.cs` | SSRF/密钥/Prompt 注入/并发 | 见 RV-01~03 |
|
||||||
|
| `SimpleLite/PluginManager.cs` | 任意 DLL 加载/RCE/并发 | 良好(入口受限) |
|
||||||
|
| `SimpleLite/Web/AiConfigController.cs` | 密钥脱敏 | 良好 |
|
||||||
|
| `SimpleLite/Web/InternalAuthModule.cs` | 8222 鉴权 | 见 RV-04 |
|
||||||
|
| `SimpleLite/Web/ReflectionApiController.cs`(本会话已改部分) | writableViaApi/只读/Envelope | 已修 ISSUE-05/06/08 |
|
||||||
|
| `SimpleLite/Persistence/Repositories/CarRepository.cs` | 反序列化白名单 | 已修 ISSUE-10 |
|
||||||
|
|
||||||
|
### 鉴权链确认(关键前提)
|
||||||
|
- `InternalAuthModule`(AR-1/AR-2)统一前置守护所有 `/projection/*`(reflection / map-edit / persistence / ai-config / stream)。
|
||||||
|
- MiGu.Server 侧 Controller 已加 `[Authorize]`(AR-4)。
|
||||||
|
- **结论**:AI 生图、插件 reload、字段写入等敏感入口**不可被未认证外部直接触达**——这把多处原本可能的高危项降级为 P2 加固建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 发现清单(按严重度)
|
||||||
|
|
||||||
|
### P1 — 建议处理(视部署环境)
|
||||||
|
|
||||||
|
#### RV-04 — InternalAuthModule loopback 默认无条件放行
|
||||||
|
- **文件**:`SimpleLite/Web/InternalAuthModule.cs:79-80`(`AllowLoopback` 默认 `true`)
|
||||||
|
- **现象**:本机回环(127.0.0.1/::1/IPv4-mapped)请求**不校验 token 直接放行**。
|
||||||
|
- **风险**:单机可信环境无碍;但在**多租户/共享主机**或存在可打到 `127.0.0.1:8222` 的 SSRF 时,本机任意进程即可接管调度内核(execute [MethodMember] / patch 字段 / reload plugin)。
|
||||||
|
- **建议**:生产(尤其共享主机)通过配置将 `AllowLoopback=false`,强制依赖 `X-Platform-Internal-Token`;并在部署文档标注。代码已预留开关,无需改逻辑,仅需配置 + 文档。
|
||||||
|
|
||||||
|
### P2 — 加固建议
|
||||||
|
|
||||||
|
#### RV-01 — AiCompletion endpoint 无 SSRF 白名单
|
||||||
|
- **文件**:`SimpleLite/Web/AiCompletionService.cs:191-197`
|
||||||
|
- **现象**:`cfg.Endpoint`(管理员经 ai-config 表单可配任意 URL)被服务端 `SharedClient.SendAsync` 直接请求,并携带 `Authorization: Bearer <apiKey>`。
|
||||||
|
- **风险**:已认证管理员可让服务器向任意(含内网)URL 发带密钥请求 → SSRF / 密钥外泄到非预期端点。
|
||||||
|
- **建议**:对 `Endpoint` 做 scheme 限制(仅 https)+ host 白名单/黑名单(禁内网段),或显式告警「endpoint 指向内网」。
|
||||||
|
|
||||||
|
#### RV-02 — apiKey 明文存盘且 config/ 未被 gitignore
|
||||||
|
- **文件**:`SimpleLite/Web/AiCompletionService.cs:71-99`(`CWD/config/ai-completion.json` 明文读写)
|
||||||
|
- **现象**:`apiKey` 明文落盘;`.gitignore` 未覆盖 `config/` 或该文件(已核查无匹配规则)。
|
||||||
|
- **风险**:误 `git add config/` 或随包分发会泄露 LLM 密钥。
|
||||||
|
- **建议**:`.gitignore` 增加 `**/config/ai-completion.json`(或运行时 `config/`);考虑落盘前对 apiKey 做 DPAPI/对称加密。注:GET 已脱敏(`AiConfigController.MaskApiKey`),此项仅针对磁盘文件。
|
||||||
|
|
||||||
|
#### RV-03 — AiCompletion 把 LLM 原始响应回抛前端
|
||||||
|
- **文件**:`SimpleLite/Web/AiCompletionService.cs:199-200`(`throw ... LLM HTTP {status}: {raw}`)
|
||||||
|
- **现象**:上游非 2xx 时把 `raw` 响应体原样抛出,经 `MapEditApiController` 返回前端。
|
||||||
|
- **风险**:信息泄露(上游错误细节/内部 endpoint),低概率含敏感片段。
|
||||||
|
- **建议**:仅记日志保留 `raw`,对前端返回精简错误码与通用提示。
|
||||||
|
|
||||||
|
#### RV-05 — 登录阻塞至 Readiness 超时(同 ISSUE-01)
|
||||||
|
- **文件**:`MiGu.Server/Controllers/AuthController.cs:111` + `SimpleLiteLauncher.WaitForProjectionReady`
|
||||||
|
- **现状**:已通过 `SimpleLiteOptions.ReadinessTimeoutMs`(可配,0=立即返回)缓解;真正的「fire-and-forget + 前端轮询」留待迭代。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已确认良好(无需改动)
|
||||||
|
|
||||||
|
- **SimpleLiteLauncher**:`displayMode` 由 `NormalizeDisplayMode` 归一为固定枚举(web / web+local),命令行无注入面;`_opts.Arguments` 来自受信 appsettings;进程 `Exited` 事件用 `ReferenceEquals(_proc, proc)` 防误清;JobObject 资源用 `AllocHGlobal/FreeHGlobal` 配对释放。
|
||||||
|
- **PluginManager**:`reload` 入口(`/reflection/plugins/reload`)只**重扫固定 `./plugins` 目录**,不接受任意路径参数 → 无「任意路径 DLL 加载」漏洞;`Load`/`Unload` 全程加锁(修复历史并发覆盖 bug);collectible ALC + WeakReference + GC best-effort 卸载。
|
||||||
|
- **AiConfigController**:GET 脱敏 `apiKey`(`***`+后4位);POST 对脱敏占位保留旧值,避免被覆盖清空。
|
||||||
|
- **InternalAuthModule**:token 校验用 `CryptographicOperations.FixedTimeEquals` 防 timing;未配置 token 时一律拒绝远程(仅 loopback 可入)。
|
||||||
|
- **AiCompletionService 并发**:静态 `SharedClient`(避免 socket exhaustion)+ `Timeout.Infinite` + `CancellationTokenSource` 控制单次超时;`BoundsContext` 用 `AsyncLocal` 跨 await 保持并 `try/finally` 清理。
|
||||||
|
- **本会话修复**:ISSUE-10(反序列化白名单)/ ISSUE-05(writableViaApi 403)/ ISSUE-06(GET 只读)/ ISSUE-08(Envelope 统一),均经 `dotnet build SimpleLite` 验证 0 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 未深审(建议后续专项)
|
||||||
|
|
||||||
|
| 区域 | 文件(本周新增/大改) | 建议关注 |
|
||||||
|
|------|----------------------|----------|
|
||||||
|
| 录制/回放 | `SimulationRecorder` `PlaybackController` `RecordingFileIO` `SimulationPlayer` `SchedulerProbe` | 文件句柄/内存映射释放、帧索引边界 |
|
||||||
|
| 实时流 | `ProjectionStreamModule` `AlarmStreamService` | SSE 连接泄漏/重连/背压 |
|
||||||
|
| 持久化 | `SceneRepository` `ScriptRepository` `MissionRepository` `PersistenceWorker` `PersistenceBootstrap` | EF DbContext 生命周期、并发、事务 |
|
||||||
|
| 交互/渲染 | `WorkspaceSceneInput` `CurveControlPointDragService` `SimpleSceneRenderer` `ComposerDockPanel` `SimpleUI` | 线程竞态、事件/定时器泄漏 |
|
||||||
|
| 前端 | `simple-platform-vue/**` | 鉴权守卫、契约一致、XSS、mock 开关(子代理不可用,本轮未审) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 结论
|
||||||
|
|
||||||
|
本周后端改动建立在会话 53 的安全基线(AR-1~AR-6)之上,**鉴权链完整、未见新增高危漏洞**。建议优先处理 **RV-04(生产关 loopback)** 与 **RV-02(config/ai-completion.json 入 gitignore)** 两项低成本加固;RV-01/RV-03 视风险排期。功能性模块与前端建议在子代理可用后做一轮专项复审。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 本轮处理与补充发现(会话 65 续)
|
||||||
|
|
||||||
|
### 6.1 已修复(按「修复 RV-04 + P2 加固项」)
|
||||||
|
- **RV-01(SSRF)**:`AiCompletionService.GenerateMapInternalAsync` 发请求前校验 endpoint —— 仅允许 http/https(挡 `file://` 等异常 scheme),指向内网/回环时记审计日志但不阻断(兼容自建 LLM)。新增 `IsPrivateOrLoopbackHost`。
|
||||||
|
- **RV-02(密钥入库)**:`.gitignore` 增 `**/config/ai-completion.json`。
|
||||||
|
- **RV-03(信息泄露)**:上游 LLM 非 2xx 时原始响应仅进日志(截断 500 字符),前端只返回状态码 + “详见日志”。
|
||||||
|
- **RV-04(loopback)**:经核实**已可配置**(`PlatformOptions.allowLoopbackBypass`,`ProjectionWebHost.cs:51` 已接线),无需改代码;已在 `MiGu.Server/README.md` 安全须知补充「生产建议 `allowLoopbackBypass=false`」。
|
||||||
|
|
||||||
|
### 6.2 补充深审结论(质量良好)
|
||||||
|
- **实时流** `ProjectionStreamModule` / `AlarmStreamService`:每订阅者一把 `SemaphoreSlim` + `finally` 清理 CTS/锁 + 断连移除;删除车辆时清 `LastAlarmByCar` 防膨胀;反射 `FieldInfo` 缓存。仅 P2(SSE `Dispose` 与 fire-and-forget `Broadcast` 的竞态已被 `catch (ObjectDisposedException)` 吸收;`AlarmStream` Timer Tick 极端慢时可能重入,`ConcurrentDictionary` 保证安全)。
|
||||||
|
- **录制文件 IO** `RecordingFileIO`:`compressedLen` ≤ 64MB 校验、`ReadExact` 截断检测、GZip 流 `using` 释放、`ScanIndex` 损坏帧停止。仅 P2(解压后大小无上限,GZip 炸弹理论风险;录像为本地受信文件,实际影响小)。
|
||||||
|
|
||||||
|
### 6.3 补充发现并修复
|
||||||
|
- **RV-06(已预防性修复,2026-05-29)** `PersistenceWorker.FlushUpsert`/`FlushDelete` 原在 `ctx.SaveChanges()` / `tx.Commit()` **之前**就调用 `SetHash`/`RemoveHash`,若事务随后回滚,`_lastWrittenHash` 已记录该 hash → 后续 `ShouldSkipWrite` 跳过重写 → 该变更(尤其低频状态如 Mission/Script)可能永久不落库。`AUDIT.md` 的 PB-1~PB-5 未覆盖此项。虽 `Startup.cs:72-74` 已整体下线持久化(worker 不运行),仍按要求预防性修复:
|
||||||
|
- `FlushUpsert`/`FlushDelete` 改为把 hash 变更累积到局部 `hashOps`(`hash==null` 表示移除),不再直接改字典;
|
||||||
|
- 仅当 `SaveChanges()` + `tx.Commit()` 成功后才由新增的 `ApplyHashOps` 在 `_hashLock` 内原子应用;回滚 / 异常路径不应用,保留「下次仍需写入」语义;
|
||||||
|
- 删除不再使用的 `SetHash`/`RemoveHash`。`ReadLints` 验证 0 错误(完整 `dotnet build` 仍受 §6.4 weaver 环境阻塞)。
|
||||||
|
|
||||||
|
### 6.4 编译验证说明(受阻于织入工具链环境问题)
|
||||||
|
- 修复仅触及 `SimpleLite/Web/AiCompletionService.cs`(标准 C#:`Uri.TryCreate` / `IPAddress` / 字符串操作)。
|
||||||
|
- **首次** `dotnet build SimpleLite/SimpleLite.csproj`(本会话早段)**0 错误**成功,`LessokajiWeaver` 在线织入完整跑过(约 51s,日志 `Online compilation finished`)。
|
||||||
|
- **本次复验**:SimpleCore 报 20 个 `CS0246/CS0616/CS0641`,全部是 `LessokajiWeaver` 在线织入临时项目(`obj/MaxKeeper_tmp`)应提供的类型缺失(`I18N`/`MethodMember`/`MaxValueKeeper`/`KeepMaxMod`/`ReplaceWithCompileInfo`);`--no-incremental` 全量重建仍 7s 即失败,未进入在线织入。**错误列表无任何 SimpleLite 文件**。
|
||||||
|
- 判定:**织入工具链在线服务/状态不可用的环境问题,非本次代码问题**。建议在 VS2022 正常环境、或 `tools\setenv.ps1` 恢复 deps 且 weaver 在线服务可用后复验本次 AI 服务改动。
|
||||||
|
|
||||||
|
### 6.5 未完成(建议后续专项)
|
||||||
|
- `Workspace` / `UI` / `Rendering`、前端全量:本轮因并行子代理不可用(账户账单)未深审,建议子代理恢复后补一轮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-05-29 | 初版:会话 65 本周复审(聚焦后端安全关键面;子代理因账单不可用,人工聚焦审核) |
|
||||||
|
| 2026-05-29 | 续:修复 RV-01/02/03 + RV-04 文档;补深审(流/文件良好、PersistenceWorker 发现 RV-06);编译复验受阻于 LessokajiWeaver 在线织入环境问题(与改动无关)。 |
|
||||||
|
| 2026-05-29 | 续:按要求预防性修复 RV-06(hash 延迟到事务 Commit 后由 ApplyHashOps 原子应用);本会话全部 5 个改动文件 ReadLints 0 错误。 |
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiGu.Server", "MiGu.Server\MiGu.Server.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
data/*.json
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||||
|
///
|
||||||
|
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||||
|
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||||
|
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||||
|
///
|
||||||
|
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
||||||
|
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header;
|
||||||
|
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
|
||||||
|
/// 会自动追加该 header;
|
||||||
|
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||||
|
/// <c>SIMPLELITE__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 读取(同机部署常见场景)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InternalTokenStore
|
||||||
|
{
|
||||||
|
public string Token { get; }
|
||||||
|
public bool IsEphemeral { get; }
|
||||||
|
public string? PersistedFilePath { get; }
|
||||||
|
|
||||||
|
public InternalTokenStore(IConfiguration config, IWebHostEnvironment env, ILogger<InternalTokenStore> logger)
|
||||||
|
{
|
||||||
|
var configured = config["Internal:Token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured) && configured != "REPLACE_ME")
|
||||||
|
{
|
||||||
|
Token = configured;
|
||||||
|
IsEphemeral = false;
|
||||||
|
logger.LogInformation("Internal token 从配置读取(长度 {Len})。", Token.Length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
|
||||||
|
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||||
|
Directory.CreateDirectory(dataDir);
|
||||||
|
var file = Path.Combine(dataDir, ".internal-token");
|
||||||
|
if (File.Exists(file))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = File.ReadAllText(file).Trim();
|
||||||
|
if (existing.Length >= 32)
|
||||||
|
{
|
||||||
|
Token = existing;
|
||||||
|
IsEphemeral = false;
|
||||||
|
PersistedFilePath = file;
|
||||||
|
logger.LogInformation("Internal token 复用 {File}(长度 {Len})。", file, Token.Length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "读取 {File} 失败,将重新生成 internal token。", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||||
|
IsEphemeral = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(file, Token);
|
||||||
|
PersistedFilePath = file;
|
||||||
|
logger.LogWarning(
|
||||||
|
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
||||||
|
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
|
||||||
|
file);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台 JWT 颁发与验签。Secret 从 <c>appsettings.json</c> 的 <c>Jwt:Secret</c> 读取;
|
||||||
|
/// 若为占位值 <c>"REPLACE_ME"</c> 则启动期生成临时随机 secret 并在日志里强制告警,
|
||||||
|
/// 防止生产部署忘改 secret 导致历史 hardcoded "simple-mock-secret" 风险复现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JwtIssuer
|
||||||
|
{
|
||||||
|
/// <summary>占位 secret;启动期检测到时自动换成进程随机值,并在日志里 critical 告警。</summary>
|
||||||
|
public const string PlaceholderSecret = "REPLACE_ME";
|
||||||
|
|
||||||
|
public string Issuer { get; }
|
||||||
|
public string Audience { get; }
|
||||||
|
public TimeSpan Lifetime { get; }
|
||||||
|
public string SecretInUse { get; }
|
||||||
|
public bool SecretIsEphemeral { get; }
|
||||||
|
|
||||||
|
private readonly SymmetricSecurityKey _key;
|
||||||
|
private readonly SigningCredentials _signing;
|
||||||
|
private readonly TokenValidationParameters _validation;
|
||||||
|
|
||||||
|
public JwtIssuer(string secret, string issuer, string audience, TimeSpan lifetime, ILogger<JwtIssuer> logger)
|
||||||
|
{
|
||||||
|
Issuer = issuer;
|
||||||
|
Audience = audience;
|
||||||
|
Lifetime = lifetime;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secret) || secret == PlaceholderSecret)
|
||||||
|
{
|
||||||
|
// 兜底:用进程随机 secret,保证签名不被预知,但 token 在 MiGu.Server 重启后失效。
|
||||||
|
// 这条路径只应出现在「开发机首次启动」/「忘改配置的部署」,**日志里强制告警让运维注意**。
|
||||||
|
secret = RandomBase64Secret(64);
|
||||||
|
SecretIsEphemeral = true;
|
||||||
|
logger.LogCritical(
|
||||||
|
"Jwt:Secret 是占位值或未配置 —— 已生成进程随机 secret(重启后所有 token 失效)。" +
|
||||||
|
"生产环境必须在 appsettings.Production.json 或环境变量 PLATFORM__JWT__SECRET 配置一个 ≥ 32 字节的稳定 secret。");
|
||||||
|
}
|
||||||
|
else if (Encoding.UTF8.GetByteCount(secret) < 32)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Jwt:Secret 长度不足 32 字节,HS256 推荐 ≥ 32 字节 secret 以达到 256bit 强度。");
|
||||||
|
}
|
||||||
|
|
||||||
|
SecretInUse = secret;
|
||||||
|
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||||||
|
_signing = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||||
|
_validation = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidIssuer = Issuer,
|
||||||
|
ValidAudience = Audience,
|
||||||
|
IssuerSigningKey = _key,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>颁发 access token。<paramref name="ops"/> 写入私有 claim <c>ops</c>(空格分隔,便于后端 <c>[Authorize]</c> policy 解析)。</summary>
|
||||||
|
public string Issue(string userId, string username, string scope, IReadOnlyList<string> roles, IReadOnlyList<string> ops)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new("scope", scope),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||||
|
new(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||||
|
new("ops", string.Join(' ', ops ?? Array.Empty<string>())),
|
||||||
|
};
|
||||||
|
foreach (var r in roles ?? Array.Empty<string>())
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, r));
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: Issuer,
|
||||||
|
audience: Audience,
|
||||||
|
claims: claims,
|
||||||
|
notBefore: now,
|
||||||
|
expires: now.Add(Lifetime),
|
||||||
|
signingCredentials: _signing);
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>给框架的 JwtBearer middleware 用的验证参数(启动期注入到 AddJwtBearer)。</summary>
|
||||||
|
public TokenValidationParameters BuildValidationParameters() => _validation;
|
||||||
|
|
||||||
|
private static string RandomBase64Secret(int byteLen)
|
||||||
|
{
|
||||||
|
var buf = RandomNumberGenerator.GetBytes(byteLen);
|
||||||
|
return Convert.ToBase64String(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最简化的用户表(占位实现):内置 admin / ops 两个账号 + PBKDF2 哈希密码校验。
|
||||||
|
/// 真实生产应替换成 Microsoft.AspNetCore.Identity 或外接 LDAP / OAuth。
|
||||||
|
///
|
||||||
|
/// 安全要点(哪怕是占位也要做到):
|
||||||
|
/// - 密码不明文存储,启动期用 PBKDF2-SHA256(100k iter, 16B salt) 哈希;
|
||||||
|
/// - 密码 hash 比较走 <see cref="CryptographicOperations.FixedTimeEquals"/> 防时序攻击;
|
||||||
|
/// - 不允许「空用户名 = 空密码」之类的快捷绕过。
|
||||||
|
///
|
||||||
|
/// 默认账号:
|
||||||
|
/// admin / admin (Platform scope, role-admin)
|
||||||
|
/// ops / ops (RCSMonitor scope, role-ops)
|
||||||
|
/// 默认密码同名是为了**开发机一次启动就能登录**;生产部署务必通过环境变量
|
||||||
|
/// <c>PLATFORM__AUTH__USERS__<USERNAME>__PASSWORD</c> 改写或接入真实身份源。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserStore
|
||||||
|
{
|
||||||
|
public sealed record UserRecord(
|
||||||
|
string Id,
|
||||||
|
string Username,
|
||||||
|
string DisplayName,
|
||||||
|
string DefaultScope,
|
||||||
|
IReadOnlyList<string> Roles,
|
||||||
|
byte[] Salt,
|
||||||
|
byte[] PasswordHash);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, UserRecord> _users;
|
||||||
|
|
||||||
|
public UserStore(IConfiguration config, ILogger<UserStore> logger)
|
||||||
|
{
|
||||||
|
_users = new Dictionary<string, UserRecord>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// 1. 内置 admin / ops(密码可从 appsettings 覆盖)
|
||||||
|
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
|
||||||
|
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
|
||||||
|
|
||||||
|
Add("u-admin", "admin", "系统管理员", "Platform", new[] { "role-admin", "role-platform-write" }, adminPwd);
|
||||||
|
Add("u-ops", "ops", "运营人员", "RCSMonitor", new[] { "role-ops", "role-monitor-read" }, opsPwd);
|
||||||
|
|
||||||
|
if (adminPwd == "admin" || opsPwd == "ops")
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"UserStore 使用默认弱密码(admin/admin 或 ops/ops)。生产环境务必通过 appsettings.Production.json " +
|
||||||
|
"或环境变量 PLATFORM__AUTH__USERS__admin__PASSWORD 等覆盖。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用户名 / 密码校验。返回 null = 不存在或密码错。<b>不向调用方区分两种失败原因</b>,防用户名枚举。</summary>
|
||||||
|
public UserRecord? Verify(string username, string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
|
||||||
|
if (!_users.TryGetValue(username, out var u)) return null;
|
||||||
|
|
||||||
|
var hash = Pbkdf2(password, u.Salt);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(hash, u.PasswordHash) ? u : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserRecord? Find(string username) =>
|
||||||
|
_users.TryGetValue(username ?? "", out var u) ? u : null;
|
||||||
|
|
||||||
|
private void Add(string id, string username, string displayName, string defaultScope, IReadOnlyList<string> roles, string plainPassword)
|
||||||
|
{
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
var hash = Pbkdf2(plainPassword, salt);
|
||||||
|
_users[username] = new UserRecord(id, username, displayName, defaultScope, roles, salt, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Pbkdf2(string password, byte[] salt) =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record ChargePriorityRule(string Id, string Condition, double Weight);
|
||||||
|
|
||||||
|
public record ChargePolicy(
|
||||||
|
bool AllowMidTaskCharge,
|
||||||
|
int IdleChargeAfterSec,
|
||||||
|
List<ChargePriorityRule> Priority)
|
||||||
|
{
|
||||||
|
public static ChargePolicy Default() => new(
|
||||||
|
AllowMidTaskCharge: false,
|
||||||
|
IdleChargeAfterSec: 300,
|
||||||
|
Priority: new List<ChargePriorityRule>
|
||||||
|
{
|
||||||
|
new("CP1", "soc<0.2", 100),
|
||||||
|
new("CP2", "idle>5min", 30)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||||
|
/// 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度。
|
||||||
|
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigStore
|
||||||
|
{
|
||||||
|
public static readonly string[] AllSections =
|
||||||
|
{
|
||||||
|
"system", "integrations", "routing", "vehicle", "charge", "task",
|
||||||
|
"traffic", "auth", "device", "fleet", "scenario", "location", "ops", "widget"
|
||||||
|
};
|
||||||
|
|
||||||
|
public sealed record Envelope(string Section, int Version, DateTimeOffset UpdatedAt, object Payload);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, Envelope> _mem = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly string _dataDir;
|
||||||
|
private readonly ILogger<ConfigStore> _logger;
|
||||||
|
private readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public ConfigStore(IWebHostEnvironment env, ILogger<ConfigStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||||
|
Directory.CreateDirectory(_dataDir);
|
||||||
|
SeedAndLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Envelope Get(string section)
|
||||||
|
{
|
||||||
|
section = section.ToLowerInvariant();
|
||||||
|
if (_mem.TryGetValue(section, out var env))
|
||||||
|
{
|
||||||
|
if (section == "ops") return MergeOpsEnvelope(env);
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
var def = NewDefault(section);
|
||||||
|
_mem[section] = def;
|
||||||
|
Persist(def);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Envelope Put(string section, JsonElement payload)
|
||||||
|
{
|
||||||
|
section = section.ToLowerInvariant();
|
||||||
|
if (!AllSections.Contains(section))
|
||||||
|
throw new ArgumentException($"未知 section: {section}");
|
||||||
|
|
||||||
|
var prev = _mem.TryGetValue(section, out var p) ? p : null;
|
||||||
|
var version = (prev?.Version ?? 0) + 1;
|
||||||
|
// payload 保留 JsonElement 原样;序列化器会按 camelCase 输出
|
||||||
|
var env = new Envelope(section, version, DateTimeOffset.UtcNow, JsonElementToObject(payload));
|
||||||
|
if (section == "ops") env = MergeOpsEnvelope(env);
|
||||||
|
_mem[section] = env;
|
||||||
|
Persist(env);
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Envelope> List() => AllSections.Select(Get);
|
||||||
|
|
||||||
|
private static object JsonElementToObject(JsonElement el)
|
||||||
|
{
|
||||||
|
// 简化:直接保留 JsonElement,让 STJ 在响应时再原样写回
|
||||||
|
return el.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedAndLoad()
|
||||||
|
{
|
||||||
|
foreach (var s in AllSections)
|
||||||
|
{
|
||||||
|
var file = FilePath(s);
|
||||||
|
if (File.Exists(file))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(file);
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
|
||||||
|
? v.GetInt32() : 1;
|
||||||
|
var updated = root.TryGetProperty("updatedAt", out var u) && u.ValueKind == JsonValueKind.String
|
||||||
|
? DateTimeOffset.Parse(u.GetString()!) : DateTimeOffset.UtcNow;
|
||||||
|
var payload = root.TryGetProperty("payload", out var pl)
|
||||||
|
? (object)pl.Clone()
|
||||||
|
: DefaultPayload(s);
|
||||||
|
_mem[s] = new Envelope(s, version, updated, payload);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "加载 {Section} 失败,回退到默认值", s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_mem[s] = NewDefault(s);
|
||||||
|
Persist(_mem[s]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Envelope NewDefault(string section)
|
||||||
|
{
|
||||||
|
return new Envelope(section, 1, DateTimeOffset.UtcNow, DefaultPayload(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object DefaultPayload(string section) => section switch
|
||||||
|
{
|
||||||
|
"system" => SystemConfig.Default(),
|
||||||
|
"integrations" => ExternalIntegrations.Default(),
|
||||||
|
"routing" => RoutingPolicy.Default(),
|
||||||
|
"vehicle" => VehicleMaintenancePolicy.Default(),
|
||||||
|
"charge" => ChargePolicy.Default(),
|
||||||
|
"task" => TaskAllocationPolicy.Default(),
|
||||||
|
"traffic" => TrafficRule.Default(),
|
||||||
|
"auth" => AuthRoleConfig.Default(),
|
||||||
|
"device" => DeviceManagementConfig.Default(),
|
||||||
|
"fleet" => FleetLifecycleConfig.Default(),
|
||||||
|
"scenario" => ScenarioTemplateConfig.Default(),
|
||||||
|
"location" => LocationManagement.Default(),
|
||||||
|
"ops" => OpsConfig.Default(),
|
||||||
|
"widget" => CustomWidgetConfig.Default(),
|
||||||
|
_ => new { }
|
||||||
|
};
|
||||||
|
|
||||||
|
private void Persist(Envelope env)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var toWrite = env.Section == "ops" ? MergeOpsEnvelope(env) : env;
|
||||||
|
var root = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["section"] = toWrite.Section,
|
||||||
|
["version"] = toWrite.Version,
|
||||||
|
["updatedAt"] = toWrite.UpdatedAt,
|
||||||
|
["payload"] = toWrite.Payload
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(root, _jsonOpts);
|
||||||
|
File.WriteAllText(FilePath(env.Section), json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "持久化 {Section} 失败", env.Section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Envelope MergeOpsEnvelope(Envelope env)
|
||||||
|
{
|
||||||
|
if (env.Payload is not JsonElement el || el.ValueKind != JsonValueKind.Object)
|
||||||
|
return env;
|
||||||
|
|
||||||
|
var def = OpsConfig.Default();
|
||||||
|
var merged = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
playback = ReadSection(el, "playback") ?? def.Playback,
|
||||||
|
logRetention = ReadSection(el, "logRetention") ?? def.LogRetention,
|
||||||
|
version = ReadSection(el, "version") ?? def.Version,
|
||||||
|
monitor = MergeOpsMonitor(el, def.Monitor)
|
||||||
|
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||||
|
|
||||||
|
return env with { Payload = merged.Clone() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ReadSection(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var p) ? p.Clone() : null;
|
||||||
|
|
||||||
|
private static JsonElement MergeOpsMonitor(JsonElement root, MonitorOpsPolicy def)
|
||||||
|
{
|
||||||
|
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||||
|
if (!root.TryGetProperty("monitor", out var m) || m.ValueKind != JsonValueKind.Object)
|
||||||
|
return JsonSerializer.SerializeToElement(def, opts);
|
||||||
|
|
||||||
|
var car = m.TryGetProperty("car", out var c) ? c.Clone() : JsonSerializer.SerializeToElement(def.Car, opts);
|
||||||
|
var site = m.TryGetProperty("site", out var s) ? s.Clone() : JsonSerializer.SerializeToElement(def.Site, opts);
|
||||||
|
var track = m.TryGetProperty("track", out var t) ? t.Clone() : JsonSerializer.SerializeToElement(def.Track, opts);
|
||||||
|
var carActionByType = m.TryGetProperty("carActionByType", out var cat)
|
||||||
|
? cat.Clone()
|
||||||
|
: JsonSerializer.SerializeToElement(def.CarActionByType, opts);
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToElement(new { car, site, track, carActionByType }, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FilePath(string section) => Path.Combine(_dataDir, $"config-{section}.json");
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record CustomWidget(
|
||||||
|
string Id, string Name, string SchemaJson, string LayoutJson,
|
||||||
|
List<string> BindToScopes);
|
||||||
|
|
||||||
|
public record CustomWidgetConfig(List<CustomWidget> Items)
|
||||||
|
{
|
||||||
|
public static CustomWidgetConfig Default() => new(
|
||||||
|
Items: new List<CustomWidget>
|
||||||
|
{
|
||||||
|
new("widget-call-button", "呼叫按钮",
|
||||||
|
"{\"fields\":[{\"name\":\"siteId\"}]}",
|
||||||
|
"{\"x\":0,\"y\":0,\"w\":2,\"h\":1}",
|
||||||
|
new List<string> { "RCSMonitor" })
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record DeviceDriverBinding(string Id, string DeviceType, string DriverName, string Version);
|
||||||
|
|
||||||
|
public record DeviceInstance(
|
||||||
|
string Id, string Name, string DeviceType, string Protocol, string Address,
|
||||||
|
string DriverId, bool Enabled);
|
||||||
|
|
||||||
|
public record DeviceHealthPolicy(int HeartbeatSec, int OfflineSec);
|
||||||
|
|
||||||
|
public record AlarmRule(string Level, string Condition);
|
||||||
|
public record AlarmPolicy(bool Enabled, List<AlarmRule> Rules);
|
||||||
|
|
||||||
|
public record DeviceManagementConfig(
|
||||||
|
List<DeviceDriverBinding> Drivers,
|
||||||
|
List<DeviceInstance> Devices,
|
||||||
|
DeviceHealthPolicy HealthPolicy,
|
||||||
|
AlarmPolicy AlarmPolicy)
|
||||||
|
{
|
||||||
|
public static DeviceManagementConfig Default() => new(
|
||||||
|
Drivers: new List<DeviceDriverBinding>
|
||||||
|
{
|
||||||
|
new("drv-elev", "电梯", "OpcUaElevatorDriver", "1.2.0"),
|
||||||
|
new("drv-chrg", "充电桩", "ModbusChargerDriver", "1.0.5"),
|
||||||
|
new("drv-cam", "摄像头", "OnvifCameraDriver", "2.1.0")
|
||||||
|
},
|
||||||
|
Devices: new List<DeviceInstance>
|
||||||
|
{
|
||||||
|
new("dev-elev-1", "#1 电梯", "电梯", "opc-ua", "opc.tcp://10.0.2.20:4840", "drv-elev", true),
|
||||||
|
new("dev-chrg-1", "充电桩-A1", "充电桩", "modbus-tcp", "10.0.2.30:502", "drv-chrg", true)
|
||||||
|
},
|
||||||
|
HealthPolicy: new DeviceHealthPolicy(5, 30),
|
||||||
|
AlarmPolicy: new AlarmPolicy(true, new List<AlarmRule>
|
||||||
|
{
|
||||||
|
new("warn", "offline>30s"),
|
||||||
|
new("error", "driverException")
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record WidgetGrantDto(string WidgetId, string Visibility);
|
||||||
|
|
||||||
|
public record AuthRole(
|
||||||
|
string Id, string Name, string Scope,
|
||||||
|
List<string> Permissions,
|
||||||
|
List<WidgetGrantDto> WidgetGrants);
|
||||||
|
|
||||||
|
public record AuthUser(string Id, string Username, List<string> Roles, bool Enabled);
|
||||||
|
|
||||||
|
public record AuthRoleConfig(List<AuthRole> Roles, List<AuthUser> Users)
|
||||||
|
{
|
||||||
|
public static AuthRoleConfig Default() => new(
|
||||||
|
Roles: new List<AuthRole>
|
||||||
|
{
|
||||||
|
new("role-admin", "管理员", "Platform",
|
||||||
|
new List<string> { "*" },
|
||||||
|
new List<WidgetGrantDto>()),
|
||||||
|
new("role-ops", "运营", "RCSMonitor",
|
||||||
|
new List<string>
|
||||||
|
{
|
||||||
|
"ops.car.pause", "ops.car.resume", "ops.car.gohome",
|
||||||
|
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||||
|
"ops.task.boostPriority", "monitor.note.write"
|
||||||
|
},
|
||||||
|
new List<WidgetGrantDto>
|
||||||
|
{
|
||||||
|
new("MapEditor", "readonly"),
|
||||||
|
new("CadToolbar", "hidden")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
Users: new List<AuthUser>
|
||||||
|
{
|
||||||
|
new("u-admin", "admin", new List<string> { "role-admin" }, true),
|
||||||
|
new("u-ops", "ops", new List<string> { "role-ops" }, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EffectivePermissions(
|
||||||
|
string UserId,
|
||||||
|
int Version,
|
||||||
|
List<string> AllowedOps,
|
||||||
|
List<WidgetGrantDto> VisibleWidgets);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record EndpointDescriptor(string Id, string Name, string Url, bool Enabled);
|
||||||
|
|
||||||
|
public record ExternalIntegrations(
|
||||||
|
List<EndpointDescriptor> Mes,
|
||||||
|
List<EndpointDescriptor> Wms,
|
||||||
|
List<EndpointDescriptor> Rcs)
|
||||||
|
{
|
||||||
|
public static ExternalIntegrations Default() => new(
|
||||||
|
Mes: new List<EndpointDescriptor> { new("mes-1", "MES 主线", "http://mes.lan/api", true) },
|
||||||
|
Wms: new List<EndpointDescriptor> { new("wms-1", "WMS 仓储", "http://wms.lan/api", true) },
|
||||||
|
Rcs: new List<EndpointDescriptor>());
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record FleetGroup(string Id, string Name, string Floor, string Region, List<string> CarIds);
|
||||||
|
public record OtaPolicy(bool Enabled, int BatchSize, bool RollbackOnFail);
|
||||||
|
public record BatchOpsPolicy(bool ConfirmationRequired, int MaxBatch);
|
||||||
|
public record NetworkDiagPolicy(int RttThresholdMs, double PacketLossThreshold);
|
||||||
|
|
||||||
|
public record FleetLifecycleConfig(
|
||||||
|
List<FleetGroup> Groups,
|
||||||
|
OtaPolicy Ota,
|
||||||
|
BatchOpsPolicy BatchOps,
|
||||||
|
NetworkDiagPolicy NetworkDiag)
|
||||||
|
{
|
||||||
|
public static FleetLifecycleConfig Default() => new(
|
||||||
|
Groups: new List<FleetGroup>
|
||||||
|
{
|
||||||
|
new("G-A", "A 区车队", "F1", "A", new List<string> { "C01", "C02", "C03" }),
|
||||||
|
new("G-B", "B 区车队", "F1", "B", new List<string> { "C04", "C05" })
|
||||||
|
},
|
||||||
|
Ota: new OtaPolicy(true, 2, true),
|
||||||
|
BatchOps: new BatchOpsPolicy(true, 10),
|
||||||
|
NetworkDiag: new NetworkDiagPolicy(80, 0.02));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record Location(string Id, string Code, string Name, string SiteId, int Capacity, int Occupied);
|
||||||
|
public record InventoryRule(string Id, string ItemType, int MinQty, int MaxQty);
|
||||||
|
|
||||||
|
public record LocationManagement(
|
||||||
|
List<Location> Locations,
|
||||||
|
List<InventoryRule> InventoryRules)
|
||||||
|
{
|
||||||
|
public static LocationManagement Default() => new(
|
||||||
|
Locations: new List<Location>
|
||||||
|
{
|
||||||
|
new("L01", "A-01", "A 区货架 1", "S001", 20, 12),
|
||||||
|
new("L02", "A-02", "A 区货架 2", "S002", 20, 7),
|
||||||
|
new("L03", "B-01", "B 区缓存", "S003", 30, 25)
|
||||||
|
},
|
||||||
|
InventoryRules: new List<InventoryRule>
|
||||||
|
{
|
||||||
|
new("IR1", "PalletA", 5, 30)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record PlaybackPolicy(int RetentionDays, int SamplingHz);
|
||||||
|
public record LogRetention(int HotDays, int ColdDays);
|
||||||
|
public record VersionPolicy(int KeepReleases);
|
||||||
|
public record MonitorPanelPolicy(string[] PropertyKeys, string[] StatusKeys, string[] ActionKeys);
|
||||||
|
public record MonitorOpsPolicy(
|
||||||
|
MonitorPanelPolicy Car,
|
||||||
|
MonitorPanelPolicy Site,
|
||||||
|
MonitorPanelPolicy Track,
|
||||||
|
Dictionary<string, string[]> CarActionByType);
|
||||||
|
|
||||||
|
public record OpsConfig(
|
||||||
|
PlaybackPolicy Playback,
|
||||||
|
LogRetention LogRetention,
|
||||||
|
VersionPolicy Version,
|
||||||
|
MonitorOpsPolicy Monitor)
|
||||||
|
{
|
||||||
|
public static OpsConfig Default() => new(
|
||||||
|
Playback: new PlaybackPolicy(30, 5),
|
||||||
|
LogRetention: new LogRetention(7, 180),
|
||||||
|
Version: new VersionPolicy(5),
|
||||||
|
Monitor: new MonitorOpsPolicy(
|
||||||
|
Car: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||||
|
Site: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||||
|
Track: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||||
|
CarActionByType: new Dictionary<string, string[]>()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record AvoidanceRule(string Id, string ZoneId, string Rule);
|
||||||
|
public record ZoneSpeedLimit(string ZoneId, double MaxSpeedMps);
|
||||||
|
|
||||||
|
public record RoutingPolicy(
|
||||||
|
string Algorithm,
|
||||||
|
Dictionary<string, double> Weights,
|
||||||
|
List<AvoidanceRule> Avoidance,
|
||||||
|
List<ZoneSpeedLimit> ZoneSpeedLimits)
|
||||||
|
{
|
||||||
|
public static RoutingPolicy Default() => new(
|
||||||
|
Algorithm: "astar",
|
||||||
|
Weights: new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["distance"] = 1.0,
|
||||||
|
["congestion"] = 0.5,
|
||||||
|
["turnPenalty"] = 0.2
|
||||||
|
},
|
||||||
|
Avoidance: new List<AvoidanceRule> { new("AV1", "Z-NORTH", "no-entry-while-loading") },
|
||||||
|
ZoneSpeedLimits: new List<ZoneSpeedLimit> { new("Z-NARROW", 0.5) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record ScenarioTemplate(string Id, string Name, string Category, string Version, string BaselineJson);
|
||||||
|
public record TemplateDslPolicy(bool Enabled, string SchemaVersion);
|
||||||
|
public record LowCodePolicy(bool Enabled, string Editor);
|
||||||
|
public record TemplateVersionPolicy(int KeepVersions, bool AllowRollback);
|
||||||
|
|
||||||
|
public record ScenarioTemplateConfig(
|
||||||
|
List<ScenarioTemplate> Templates,
|
||||||
|
TemplateDslPolicy DslPolicy,
|
||||||
|
LowCodePolicy LowCode,
|
||||||
|
TemplateVersionPolicy VersionPolicy)
|
||||||
|
{
|
||||||
|
public static ScenarioTemplateConfig Default() => new(
|
||||||
|
Templates: new List<ScenarioTemplate>
|
||||||
|
{
|
||||||
|
new("tpl-sps", "SPS 物料配送", "SPS", "1.0.0", "{}"),
|
||||||
|
new("tpl-pack", "电池 Pack 自动化产线", "BatteryPack", "1.0.0", "{}"),
|
||||||
|
new("tpl-loop", "环线运行", "Loop", "1.0.0", "{}"),
|
||||||
|
new("tpl-p2p", "点对点柔性搬运", "P2P", "1.0.0", "{}")
|
||||||
|
},
|
||||||
|
DslPolicy: new TemplateDslPolicy(true, "1"),
|
||||||
|
LowCode: new LowCodePolicy(false, "json"),
|
||||||
|
VersionPolicy: new TemplateVersionPolicy(10, true));
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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 static SystemConfig Default() => new(
|
||||||
|
DispatchLoopHz: 50,
|
||||||
|
Log: new LogPolicy("info", 7, 256),
|
||||||
|
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record TaskAllocationPolicy(
|
||||||
|
string Mode,
|
||||||
|
bool LoadBalance,
|
||||||
|
int MaxQueuePerCar)
|
||||||
|
{
|
||||||
|
public static TaskAllocationPolicy Default() => new(
|
||||||
|
Mode: "leastLoad",
|
||||||
|
LoadBalance: true,
|
||||||
|
MaxQueuePerCar: 3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record IntersectionPolicy(string Id, List<string> SiteIds, string Mode);
|
||||||
|
public record ZoneMutex(string Id, List<string> ZoneIds);
|
||||||
|
public record DynamicYield(string Id, string From, string To, string Condition);
|
||||||
|
|
||||||
|
public record TrafficRule(
|
||||||
|
List<IntersectionPolicy> Intersections,
|
||||||
|
List<ZoneMutex> Mutex,
|
||||||
|
List<DynamicYield> Yields)
|
||||||
|
{
|
||||||
|
public static TrafficRule Default() => new(
|
||||||
|
Intersections: new List<IntersectionPolicy> { new("IX1", new List<string> { "S006", "S007" }, "mutex") },
|
||||||
|
Mutex: new List<ZoneMutex> { new("MZ1", new List<string> { "Z-CROSS" }) },
|
||||||
|
Yields: new List<DynamicYield> { new("YD1", "A 区", "B 区", "priority<peer") });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
public record FaultReportPolicy(bool Enabled, List<string> EmailTo);
|
||||||
|
public record AutoRepairPolicy(bool Enabled, int CooldownSec);
|
||||||
|
|
||||||
|
public record VehicleMaintenancePolicy(
|
||||||
|
double LowBatteryThreshold,
|
||||||
|
double CriticalBatteryThreshold,
|
||||||
|
FaultReportPolicy FaultReport,
|
||||||
|
AutoRepairPolicy AutoRepair)
|
||||||
|
{
|
||||||
|
public static VehicleMaintenancePolicy Default() => new(
|
||||||
|
LowBatteryThreshold: 0.3,
|
||||||
|
CriticalBatteryThreshold: 0.15,
|
||||||
|
FaultReport: new FaultReportPolicy(true, new List<string> { "ops@example.com" }),
|
||||||
|
AutoRepair: new AutoRepairPolicy(false, 600));
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MiGu.Server.Auth;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/auth")]
|
||||||
|
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 默认行为一致,向后兼容)。
|
||||||
|
/// </summary>
|
||||||
|
public record LoginRequest(string Username, string Password, string Scope, 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>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public record LoginResponse(
|
||||||
|
string Token,
|
||||||
|
AuthUserDto User,
|
||||||
|
string Scope,
|
||||||
|
string RunMode,
|
||||||
|
EffectivePermissions EffectivePermissions,
|
||||||
|
string? LaunchStatus = null,
|
||||||
|
string? LaunchWarning = null);
|
||||||
|
|
||||||
|
public record AuthUserDto(string Id, string Username, string DisplayName, List<string> Roles);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前会话身份的轻量摘要。
|
||||||
|
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>,
|
||||||
|
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
|
||||||
|
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
|
||||||
|
/// 与 <see cref="LoginResponse"/> 的区别:不返回 Token(client 已有,重发反而易触发竞态);
|
||||||
|
/// 不返回 LaunchStatus/Warning(那是登录时一次性的 SimpleLite 拉起结果)。
|
||||||
|
/// </summary>
|
||||||
|
public record MeResponse(
|
||||||
|
AuthUserDto User,
|
||||||
|
string Scope,
|
||||||
|
string RunMode,
|
||||||
|
EffectivePermissions EffectivePermissions);
|
||||||
|
|
||||||
|
private static readonly string[] PlatformOps = { "*" };
|
||||||
|
|
||||||
|
private static readonly string[] RcsOps =
|
||||||
|
{
|
||||||
|
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||||
|
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||||
|
"ops.task.boostPriority", "monitor.note.write"
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string CookieName = "simple.auth.token";
|
||||||
|
|
||||||
|
private readonly UserStore _users;
|
||||||
|
private readonly JwtIssuer _jwt;
|
||||||
|
private readonly SimpleLiteLauncher _launcher;
|
||||||
|
private readonly ILogger<AuthController> _log;
|
||||||
|
|
||||||
|
public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> log)
|
||||||
|
{
|
||||||
|
_users = users;
|
||||||
|
_jwt = jwt;
|
||||||
|
_launcher = launcher;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Username))
|
||||||
|
return BadRequest(new { message = "用户名不能为空" });
|
||||||
|
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||||
|
return BadRequest(new { message = "无效 scope" });
|
||||||
|
|
||||||
|
// AR-3: 真密码校验 —— 替代会话21 点名的「完全不验密码」漏洞。
|
||||||
|
// UserStore.Verify 对不存在用户和密码错都返回 null,防用户名枚举。
|
||||||
|
var rec = _users.Verify(req.Username, req.Password);
|
||||||
|
if (rec == null)
|
||||||
|
return Unauthorized(new { message = "用户名或密码错误" });
|
||||||
|
|
||||||
|
// scope 与角色匹配检查:admin 默认 Platform;ops 默认 RCSMonitor。
|
||||||
|
// 如果 ops 想登录 Platform scope,目前直接拒绝;后续可以加 role-platform-impersonate 之类。
|
||||||
|
if (!CanUseScope(rec, req.Scope))
|
||||||
|
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||||
|
|
||||||
|
// 会话 N+1(启动反转):按 LaunchMode 拉起 SimpleLite 子进程。
|
||||||
|
// - 历史前端不带该字段 → 默认 DesktopAndWeb(保持向后兼容的 web+local 行为)。
|
||||||
|
// - WebOnly → SimpleLite 启动时只起 WebTerminal,不会弹本地桌面窗口。
|
||||||
|
// - DesktopAndWeb → SimpleLite 同时起 LocalTerminal + WebTerminal。
|
||||||
|
// - SimpleLiteLauncher 内部幂等:第二次/第 N 次登录不会重复拉起;子进程退出后下一次登录可重启。
|
||||||
|
// PERF-A8 修复:MaybeStart 内部包含同步 WaitForProjectionReady(最多 ReadinessTimeoutMs,默认 8s)。
|
||||||
|
// 走 Task.Run 把它扔到线程池,让登录请求自身的请求处理线程释放回 ASP.NET,避免高并发下挤兑。
|
||||||
|
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
||||||
|
SimpleLiteLauncher.LaunchResult? launchResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode));
|
||||||
|
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
|
||||||
|
rec.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 启动 SimpleLite 失败不应阻断登录:用户至少能进 Platform 看状态页面排查。
|
||||||
|
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", rec.Username, launchMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A7 修复:runMode 跟 result.Started 走 ——
|
||||||
|
// - launcher 没起来 / 抛异常 → "Detached",前端显示降级状态而不是"假装 SimpleLite 在跑"。
|
||||||
|
// - 跑起来了 → 优先按 launcher 回传的真实 DisplayMode 映射,避免 AlreadyRunning 时被本次请求的 launchMode 误导。
|
||||||
|
// - ReusingExisting 时既有 SimpleLite 实际模式未知,保守归 WebEnabled(旧实例最可能带本地窗口)。
|
||||||
|
var runMode = ResolveRunMode(launchResult, launchMode);
|
||||||
|
|
||||||
|
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||||
|
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||||
|
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||||
|
|
||||||
|
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||||
|
SetAuthCookie(token);
|
||||||
|
|
||||||
|
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||||
|
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm,
|
||||||
|
LaunchStatus: launchResult?.Status,
|
||||||
|
LaunchWarning: launchResult?.Warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A7 修复:根据 Launcher 真实结果决定 RunMode。
|
||||||
|
/// 之前的逻辑直接按 launchMode 映射,导致 SimpleLite 没起也假装"WebEnabled",前端 RunMode 角标骗人。
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
|
||||||
|
{
|
||||||
|
if (result is not { Started: true })
|
||||||
|
return "Detached";
|
||||||
|
// ReusingExisting 时 DisplayMode = "external",无法确认本地窗口是否存在,保守按 WebEnabled。
|
||||||
|
if (result.Value.Status == "ReusingExisting")
|
||||||
|
return "WebEnabled";
|
||||||
|
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
||||||
|
{
|
||||||
|
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "WebOnly";
|
||||||
|
if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "WebEnabled";
|
||||||
|
}
|
||||||
|
return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult Logout()
|
||||||
|
{
|
||||||
|
Response.Cookies.Delete(CookieName);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由
|
||||||
|
/// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。
|
||||||
|
/// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("me")]
|
||||||
|
[Authorize]
|
||||||
|
public ActionResult<MeResponse> Me()
|
||||||
|
{
|
||||||
|
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var scope = User.FindFirstValue("scope");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope))
|
||||||
|
return Unauthorized(new { message = "身份无效" });
|
||||||
|
if (scope is not ("Platform" or "RCSMonitor"))
|
||||||
|
return Unauthorized(new { message = "无效 scope" });
|
||||||
|
|
||||||
|
var rec = _users.Find(username);
|
||||||
|
if (rec == null)
|
||||||
|
return Unauthorized(new { message = "账号已失效" });
|
||||||
|
|
||||||
|
// 账号当前是否还允许这个 scope —— 比如运维把 admin 的角色去掉了,也要在这里及时回 403。
|
||||||
|
if (!CanUseScope(rec, scope))
|
||||||
|
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {scope} 的权限" });
|
||||||
|
|
||||||
|
var (allowedOps, widgets) = BuildPermissions(scope);
|
||||||
|
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||||
|
var roles = rec.Roles.Concat(new[] { scope == "Platform" ? "role-platform" : "role-rcs-monitor" })
|
||||||
|
.Distinct().ToList();
|
||||||
|
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||||
|
|
||||||
|
// RunMode 推断:与 SwitchScope 保持一致 —— 复用 Launcher 记录的 LastLaunchMode,
|
||||||
|
// 不重新拉起 SimpleLite。
|
||||||
|
var last = _launcher.LastLaunchMode;
|
||||||
|
string runMode;
|
||||||
|
if (string.IsNullOrEmpty(last))
|
||||||
|
runMode = "Detached";
|
||||||
|
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||||
|
runMode = "WebEnabled";
|
||||||
|
else
|
||||||
|
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||||
|
|
||||||
|
return Ok(new MeResponse(user, scope, runMode, perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用同一身份切换 scope 并重发 token + perms。
|
||||||
|
/// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。
|
||||||
|
/// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("switch-scope")]
|
||||||
|
[Authorize]
|
||||||
|
public ActionResult<LoginResponse> SwitchScope([FromBody] SwitchScopeRequest req)
|
||||||
|
{
|
||||||
|
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||||
|
return BadRequest(new { message = "无效 scope" });
|
||||||
|
|
||||||
|
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(username))
|
||||||
|
return Unauthorized(new { message = "身份无效" });
|
||||||
|
|
||||||
|
var rec = _users.Find(username);
|
||||||
|
if (rec == null)
|
||||||
|
return Unauthorized(new { message = "账号已失效" });
|
||||||
|
|
||||||
|
if (!CanUseScope(rec, req.Scope))
|
||||||
|
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||||
|
|
||||||
|
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||||
|
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||||
|
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||||
|
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||||
|
SetAuthCookie(token);
|
||||||
|
|
||||||
|
// 复用首次登录确定的 LaunchMode:SwitchScope 不重新选启动模式(也不应该重启 SimpleLite)。
|
||||||
|
// 三种情况:
|
||||||
|
// 1) Launcher 已记录 "web" / "web+local" → 按 displayMode 反推 runMode。
|
||||||
|
// 2) Launcher 记录 ExternalReuseLaunchMode(既有 SimpleLite 复用)→ 模式未知,保守 WebEnabled。
|
||||||
|
// 3) Launcher 没拉起 / LastLaunchMode = null → Detached(前端降级展示)。
|
||||||
|
var last = _launcher.LastLaunchMode;
|
||||||
|
string runMode;
|
||||||
|
if (string.IsNullOrEmpty(last))
|
||||||
|
runMode = "Detached";
|
||||||
|
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||||
|
runMode = "WebEnabled";
|
||||||
|
else
|
||||||
|
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||||
|
|
||||||
|
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||||
|
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SwitchScopeRequest(string Scope);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。
|
||||||
|
/// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeLaunchMode(string? raw)
|
||||||
|
{
|
||||||
|
return raw?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"webonly" or "web-only" or "web" => "WebOnly",
|
||||||
|
_ => "DesktopAndWeb",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanUseScope(UserStore.UserRecord rec, string scope)
|
||||||
|
{
|
||||||
|
if (scope == rec.DefaultScope) return true;
|
||||||
|
// 管理员可以下沉到 RCSMonitor 体验运营视角;ops 不能上探 Platform。
|
||||||
|
if (scope == "RCSMonitor" && rec.Roles.Contains("role-admin")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string[] ops, WidgetGrantDto[] widgets) BuildPermissions(string scope)
|
||||||
|
{
|
||||||
|
var ops = scope == "Platform" ? PlatformOps : RcsOps;
|
||||||
|
var widgets = scope == "Platform"
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
new WidgetGrantDto("MapEditor", "interactive"),
|
||||||
|
new WidgetGrantDto("CadToolbar", "interactive"),
|
||||||
|
new WidgetGrantDto("CarPanel", "interactive"),
|
||||||
|
new WidgetGrantDto("MissionEditor", "interactive"),
|
||||||
|
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||||
|
new WidgetGrantDto("ConfigCenter", "interactive")
|
||||||
|
}
|
||||||
|
: new[]
|
||||||
|
{
|
||||||
|
new WidgetGrantDto("MapEditor", "readonly"),
|
||||||
|
new WidgetGrantDto("CadToolbar", "hidden"),
|
||||||
|
new WidgetGrantDto("CarPanel", "readonly"),
|
||||||
|
new WidgetGrantDto("MissionEditor", "readonly"),
|
||||||
|
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||||
|
new WidgetGrantDto("ConfigCenter", "hidden")
|
||||||
|
};
|
||||||
|
return (ops, widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetAuthCookie(string token)
|
||||||
|
{
|
||||||
|
// AR-5: 同时下发 httpOnly Cookie(XSS 防护)+ Bearer 兼容(旧前端过渡)。
|
||||||
|
// SameSite=Lax 足够:管理端 / 监控端均为同源(同一 MiGu.Server 进程),
|
||||||
|
// 第三方请求不应也无法附带 Cookie;Strict 会让一些 SPA 路由首次刷新认证丢失。
|
||||||
|
Response.Cookies.Append(CookieName, token, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = "/",
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddHours(24)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
||||||
|
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/config")]
|
||||||
|
public class ConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ConfigStore _store;
|
||||||
|
|
||||||
|
public ConfigController(ConfigStore store)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult List()
|
||||||
|
{
|
||||||
|
var envs = _store.List().Select(e => new
|
||||||
|
{
|
||||||
|
section = e.Section,
|
||||||
|
version = e.Version,
|
||||||
|
updatedAt = e.UpdatedAt
|
||||||
|
});
|
||||||
|
return Ok(envs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{section}")]
|
||||||
|
public IActionResult Get(string section)
|
||||||
|
{
|
||||||
|
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return NotFound(new { message = $"未知 section: {section}" });
|
||||||
|
|
||||||
|
var env = _store.Get(section);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
section = env.Section,
|
||||||
|
version = env.Version,
|
||||||
|
updatedAt = env.UpdatedAt,
|
||||||
|
payload = env.Payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
|
||||||
|
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
|
||||||
|
[HttpPut("{section}")]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
||||||
|
{
|
||||||
|
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return NotFound(new { message = $"未知 section: {section}" });
|
||||||
|
|
||||||
|
var env = _store.Put(section, payload);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
section = env.Section,
|
||||||
|
version = env.Version,
|
||||||
|
updatedAt = env.UpdatedAt,
|
||||||
|
payload = env.Payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/health")]
|
||||||
|
public class HealthController : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
|
||||||
|
private readonly SimpleLiteLauncher _launcher;
|
||||||
|
|
||||||
|
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Get()
|
||||||
|
{
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
status = "ok",
|
||||||
|
mode = "WebEnabled",
|
||||||
|
startTime = StartTime,
|
||||||
|
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
|
||||||
|
ports = new
|
||||||
|
{
|
||||||
|
webApi = 7001,
|
||||||
|
webSocket = 7002,
|
||||||
|
platform = 8080,
|
||||||
|
vrender = 8223,
|
||||||
|
vehicle = 8222
|
||||||
|
},
|
||||||
|
architecture = "v1.5"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||||
|
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("simplelite")]
|
||||||
|
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 运维白名单网关(占位)。真实落地时按架构 §5.1 + §10.5:
|
||||||
|
/// - 校验 scope=RCSMonitor 是否允许该 op;
|
||||||
|
/// - YARP 转发到 SimpleLite /api/ops/*;
|
||||||
|
/// - 写 OpsAuditLog(simple_main.db) + 写 OpsLogs(platform.db)。
|
||||||
|
///
|
||||||
|
/// AR-4: 全 class 加 [Authorize] —— 至少要求登录,再按 op 白名单 + JWT ops claim 双校验。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/sl/ops")]
|
||||||
|
public class OpsController : ControllerBase
|
||||||
|
{
|
||||||
|
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
||||||
|
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||||
|
public record AuditEntry(string Id, DateTimeOffset Ts, string User, string Scope, string OpCode, string Target, string Result, string? Message);
|
||||||
|
|
||||||
|
private static readonly ConcurrentQueue<AuditEntry> Audits = new();
|
||||||
|
private static long _seq = 0;
|
||||||
|
|
||||||
|
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.task.boostPriority", "monitor.note.write"
|
||||||
|
};
|
||||||
|
|
||||||
|
[HttpPost("execute")]
|
||||||
|
public ActionResult<ExecuteResponse> Execute([FromBody] ExecuteRequest req)
|
||||||
|
{
|
||||||
|
if (!Whitelist.Contains(req.OpCode))
|
||||||
|
return BadRequest(new { message = $"非白名单 op:{req.OpCode}" });
|
||||||
|
|
||||||
|
// AR-4: JWT ops claim 二次校验 —— JWT 颁发时已写入用户被授权的 ops 列表(空格分隔),
|
||||||
|
// 这里要求 (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))
|
||||||
|
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
|
||||||
|
|
||||||
|
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
||||||
|
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||||
|
|
||||||
|
var id = $"A{Interlocked.Increment(ref _seq):D6}";
|
||||||
|
var entry = new AuditEntry(id, DateTimeOffset.UtcNow, user, scope,
|
||||||
|
req.OpCode, req.TargetId, "ok", req.Reason);
|
||||||
|
Audits.Enqueue(entry);
|
||||||
|
while (Audits.Count > 200 && Audits.TryDequeue(out _)) { }
|
||||||
|
return Ok(new ExecuteResponse(true, id, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("audits")]
|
||||||
|
public IActionResult Audits200()
|
||||||
|
{
|
||||||
|
return Ok(Audits.Reverse());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
||||||
|
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
||||||
|
///
|
||||||
|
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/projection")]
|
||||||
|
public class ProjectionController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("sites")]
|
||||||
|
public IActionResult Sites() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
|
||||||
|
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
|
||||||
|
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("tracks")]
|
||||||
|
public IActionResult Tracks() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
|
||||||
|
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("cars")]
|
||||||
|
public IActionResult Cars() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
|
||||||
|
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("missions")]
|
||||||
|
public IActionResult Missions() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
|
||||||
|
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||||||
|
///
|
||||||
|
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||||||
|
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||||||
|
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||||||
|
/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程;
|
||||||
|
/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL。
|
||||||
|
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||||
|
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SimpleLiteLauncher : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SimpleLiteOptions _opts;
|
||||||
|
private readonly ILogger<SimpleLiteLauncher> _log;
|
||||||
|
private readonly IHostEnvironment _env;
|
||||||
|
private readonly object _sync = new();
|
||||||
|
|
||||||
|
private Process? _proc;
|
||||||
|
private IntPtr _job = IntPtr.Zero;
|
||||||
|
private string? _lastLaunchMode;
|
||||||
|
|
||||||
|
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
|
||||||
|
{
|
||||||
|
_opts = opts.Value;
|
||||||
|
_log = log;
|
||||||
|
_env = env;
|
||||||
|
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
||||||
|
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||||
|
if (_opts.FollowParent)
|
||||||
|
{
|
||||||
|
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_sync) return _proc is { HasExited: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||||||
|
|
||||||
|
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||||
|
internal const string ExternalReuseLaunchMode = "external";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||||||
|
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||||||
|
public LaunchResult MaybeStart(string launchMode)
|
||||||
|
{
|
||||||
|
var displayMode = NormalizeDisplayMode(launchMode);
|
||||||
|
|
||||||
|
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。");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_proc is { HasExited: false })
|
||||||
|
{
|
||||||
|
_log.LogInformation("[SimpleLite] 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 后重新登录。"
|
||||||
|
: null;
|
||||||
|
return new LaunchResult(true, "AlreadyRunning",
|
||||||
|
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||||||
|
DisplayMode: _lastLaunchMode,
|
||||||
|
Warning: warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||||||
|
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||||||
|
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||||
|
//
|
||||||
|
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||||
|
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
||||||
|
//
|
||||||
|
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
||||||
|
// 用 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));
|
||||||
|
if (!probeOk)
|
||||||
|
{
|
||||||
|
_log.LogWarning(
|
||||||
|
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite 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 配置后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
_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.",
|
||||||
|
_opts.ProjectionPort);
|
||||||
|
|
||||||
|
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||||||
|
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||||||
|
_lastLaunchMode = ExternalReuseLaunchMode;
|
||||||
|
|
||||||
|
return new LaunchResult(true, "ReusingExisting",
|
||||||
|
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
|
||||||
|
DisplayMode: ExternalReuseLaunchMode,
|
||||||
|
Warning: $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||||
|
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。");
|
||||||
|
}
|
||||||
|
|
||||||
|
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}",
|
||||||
|
_env.ContentRootPath);
|
||||||
|
return new LaunchResult(false, "ExecutableNotFound",
|
||||||
|
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
|
||||||
|
DisplayMode: null,
|
||||||
|
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||||||
|
? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory
|
||||||
|
: Path.GetFullPath(_opts.WorkingDirectory);
|
||||||
|
|
||||||
|
var arguments = BuildArguments(displayMode, _opts.Arguments);
|
||||||
|
|
||||||
|
// 会话 N+2:把 SimpleLite 作为独立进程拉起,**带自己的控制台窗口**。
|
||||||
|
// - UseShellExecute=true:交给 OS Shell 启动,生成新的进程组 + 独立 console,
|
||||||
|
// MiGu.Server 关闭不会影响它(不再处于父进程的「controlling process」链上)。
|
||||||
|
// - CreateNoWindow=false + 不重定向 stdout/stderr:SimpleLite 自带 OutputType=Exe 控制台,
|
||||||
|
// 即便选 webonly 模式也会有一个黑底控制台显示日志,便于用户观察 / 关闭。
|
||||||
|
// - 不能与 RedirectStandard* 共用,要让日志独立就只能让用户看 SimpleLite 自己的窗口。
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolved,
|
||||||
|
Arguments = arguments,
|
||||||
|
WorkingDirectory = workdir,
|
||||||
|
UseShellExecute = true,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Normal,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||||
|
proc.Exited += (_, _) =>
|
||||||
|
{
|
||||||
|
int exit;
|
||||||
|
try { exit = proc.ExitCode; } catch { exit = -1; }
|
||||||
|
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_proc, proc))
|
||||||
|
{
|
||||||
|
_proc = null;
|
||||||
|
_lastLaunchMode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!proc.Start())
|
||||||
|
{
|
||||||
|
_log.LogError("[SimpleLite] Process.Start returned false; exe={Exe} args={Args}", resolved, arguments);
|
||||||
|
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||||||
|
Warning: $"启动 SimpleLite 进程失败(Process.Start 返回 false);exe={resolved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_proc = proc;
|
||||||
|
_lastLaunchMode = displayMode;
|
||||||
|
|
||||||
|
// FollowParent 仅在用户显式 opt-in 时启用;UseShellExecute=true 之下 JobObject 仍可绑定 pid,
|
||||||
|
// 但会破坏「独立程序」语义,因此默认 false(见 SimpleLiteOptions.FollowParent 说明)。
|
||||||
|
if (_opts.FollowParent) AttachToJobObject(proc);
|
||||||
|
|
||||||
|
_log.LogInformation("[SimpleLite] 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");
|
||||||
|
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
|
||||||
|
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready = WaitForProjectionReady();
|
||||||
|
return new LaunchResult(true, ready ? "Ready" : "StartedButNotReady",
|
||||||
|
ready ? $"projection :{_opts.ProjectionPort} reachable, displayMode={displayMode}"
|
||||||
|
: $"projection :{_opts.ProjectionPort} did not respond within {_opts.ReadinessTimeoutMs}ms, displayMode={displayMode}",
|
||||||
|
DisplayMode: displayMode,
|
||||||
|
Warning: ready
|
||||||
|
? null
|
||||||
|
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手动关闭 SimpleLite 子进程。
|
||||||
|
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
||||||
|
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Process? proc;
|
||||||
|
IntPtr job;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
proc = _proc;
|
||||||
|
job = _job;
|
||||||
|
_proc = null;
|
||||||
|
_job = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_opts.FollowParent)
|
||||||
|
{
|
||||||
|
// 独立模式:不真的杀子进程,只释放本地引用让 GC 收 Process handle。
|
||||||
|
// 这样 MiGu.Server 重启时下一次登录可以重新 MaybeStart 拉一个新的 SimpleLite,
|
||||||
|
// 而老 SimpleLite 仍然在自己的窗口里跑。
|
||||||
|
if (proc is { HasExited: false })
|
||||||
|
{
|
||||||
|
_log.LogInformation("[SimpleLite] standalone mode: skip kill on Dispose; pid={Pid} keeps running", proc.Id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc is { HasExited: false })
|
||||||
|
{
|
||||||
|
try { proc.Kill(entireProcessTree: true); }
|
||||||
|
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
|
||||||
|
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
try { CloseHandle(job); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeDisplayMode(string launchMode)
|
||||||
|
{
|
||||||
|
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 SimpleLite「内部语言」(web / web+local)。
|
||||||
|
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
|
||||||
|
return launchMode?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"webonly" or "web" or "web-only" => "web",
|
||||||
|
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
||||||
|
_ => "web+local",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildArguments(string displayMode, string extra)
|
||||||
|
{
|
||||||
|
var args = $"--display-mode={displayMode}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||||
|
public SimpleLiteDiagnostics GetDiagnostics()
|
||||||
|
{
|
||||||
|
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||||
|
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||||
|
return new SimpleLiteDiagnostics(
|
||||||
|
Enabled: _opts.Enabled,
|
||||||
|
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||||||
|
ConfiguredWorkingDirectory: _opts.WorkingDirectory ?? "",
|
||||||
|
ContentRootPath: _env.ContentRootPath,
|
||||||
|
ResolvedExecutablePath: resolved,
|
||||||
|
ExecutableExists: resolved != null && File.Exists(resolved),
|
||||||
|
IsRunning: IsRunning,
|
||||||
|
LastLaunchMode: LastLaunchMode,
|
||||||
|
ProjectionPort: _opts.ProjectionPort,
|
||||||
|
ProjectionPortReachable: projectionUp,
|
||||||
|
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveExecutable(string configured)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured))
|
||||||
|
{
|
||||||
|
var p = Path.IsPathRooted(configured) ? configured : Path.GetFullPath(configured, _env.ContentRootPath);
|
||||||
|
return File.Exists(p) ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cwd = _env.ContentRootPath;
|
||||||
|
var staticCandidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(cwd, "SimpleLite.exe"),
|
||||||
|
Path.Combine(cwd, "SimpleLite", "SimpleLite.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
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
foreach (var c in staticCandidates)
|
||||||
|
{
|
||||||
|
if (File.Exists(c)) return Path.GetFullPath(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 沿父目录上行兜底(开发机 cwd 可能是 MiGu.Server/bin/Debug/net8.0/)
|
||||||
|
var dir = new DirectoryInfo(cwd);
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
foreach (var sub in new[]
|
||||||
|
{
|
||||||
|
"SimpleLite/bin/Debug/SimpleLite.exe",
|
||||||
|
"SimpleLite/bin/Release/SimpleLite.exe",
|
||||||
|
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
|
||||||
|
"Simple/SimpleLite/bin/Release/SimpleLite.exe",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var p = Path.Combine(dir.FullName, sub.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (File.Exists(p)) return Path.GetFullPath(p);
|
||||||
|
}
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WaitForProjectionReady()
|
||||||
|
{
|
||||||
|
if (_opts.ReadinessTimeoutMs == 0) return false;
|
||||||
|
var deadline = _opts.ReadinessTimeoutMs < 0
|
||||||
|
? DateTime.MaxValue
|
||||||
|
: DateTime.UtcNow.AddMilliseconds(_opts.ReadinessTimeoutMs);
|
||||||
|
var interval = Math.Max(50, _opts.ReadinessPollIntervalMs);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
// 进程已死就别等了 —— 端口永远不会就绪。
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_proc is null || _proc.HasExited) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
|
||||||
|
{
|
||||||
|
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryConnect(string host, int port, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync(host, port);
|
||||||
|
return task.Wait(timeout) && client.Connected;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
||||||
|
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||||||
|
/// </summary>
|
||||||
|
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var httpClient = new System.Net.Http.HttpClient
|
||||||
|
{
|
||||||
|
Timeout = timeout
|
||||||
|
};
|
||||||
|
using var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, $"http://{host}:{port}/projection/cars");
|
||||||
|
using var resp = httpClient.Send(req);
|
||||||
|
if (!resp.IsSuccessStatusCode) return false;
|
||||||
|
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult().TrimStart();
|
||||||
|
return body.StartsWith("[", StringComparison.Ordinal) || body.StartsWith("{", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拉起 SimpleLite 的结果摘要。
|
||||||
|
/// <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>
|
||||||
|
/// <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(
|
||||||
|
bool Enabled,
|
||||||
|
string ConfiguredExecutablePath,
|
||||||
|
string ConfiguredWorkingDirectory,
|
||||||
|
string ContentRootPath,
|
||||||
|
string? ResolvedExecutablePath,
|
||||||
|
bool ExecutableExists,
|
||||||
|
bool IsRunning,
|
||||||
|
string? LastLaunchMode,
|
||||||
|
int ProjectionPort,
|
||||||
|
bool ProjectionPortReachable,
|
||||||
|
string ConfigHint);
|
||||||
|
|
||||||
|
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||||
|
{
|
||||||
|
public long PerProcessUserTimeLimit;
|
||||||
|
public long PerJobUserTimeLimit;
|
||||||
|
public uint LimitFlags;
|
||||||
|
public UIntPtr MinimumWorkingSetSize;
|
||||||
|
public UIntPtr MaximumWorkingSetSize;
|
||||||
|
public uint ActiveProcessLimit;
|
||||||
|
public long Affinity;
|
||||||
|
public uint PriorityClass;
|
||||||
|
public uint SchedulingClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct IO_COUNTERS
|
||||||
|
{
|
||||||
|
public ulong ReadOperationCount;
|
||||||
|
public ulong WriteOperationCount;
|
||||||
|
public ulong OtherOperationCount;
|
||||||
|
public ulong ReadTransferCount;
|
||||||
|
public ulong WriteTransferCount;
|
||||||
|
public ulong OtherTransferCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||||
|
{
|
||||||
|
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||||
|
public IO_COUNTERS IoInfo;
|
||||||
|
public UIntPtr ProcessMemoryLimit;
|
||||||
|
public UIntPtr JobMemoryLimit;
|
||||||
|
public UIntPtr PeakProcessMemoryUsed;
|
||||||
|
public UIntPtr PeakJobMemoryUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||||
|
private const int JobObjectExtendedLimitInformation = 9;
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpInfo, uint cbInfoLength);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool CloseHandle(IntPtr hObject);
|
||||||
|
|
||||||
|
private void AttachToJobObject(Process child)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_job == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
_job = CreateJobObject(IntPtr.Zero, null);
|
||||||
|
if (_job == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||||
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
|
||||||
|
int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
||||||
|
IntPtr ptr = Marshal.AllocHGlobal(len);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.StructureToPtr(info, ptr, false);
|
||||||
|
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
||||||
|
_log.LogWarning("[SimpleLite] 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");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace MiGu.Server.Launcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
|
||||||
|
///
|
||||||
|
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
|
||||||
|
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe,
|
||||||
|
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
|
||||||
|
///
|
||||||
|
/// 绑定 <c>appsettings.json:SimpleLite</c>。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SimpleLiteOptions
|
||||||
|
{
|
||||||
|
/// <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>
|
||||||
|
/// </summary>
|
||||||
|
public string ExecutablePath { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||||
|
public string WorkingDirectory { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
||||||
|
public string Arguments { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
||||||
|
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
|
||||||
|
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
||||||
|
/// </summary>
|
||||||
|
public int ReadinessTimeoutMs { get; set; } = 8000;
|
||||||
|
|
||||||
|
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
||||||
|
public int ReadinessPollIntervalMs { get; set; } = 250;
|
||||||
|
|
||||||
|
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
|
||||||
|
public int ProjectionPort { get; set; } = 8222;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||||
|
///
|
||||||
|
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite;
|
||||||
|
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false:
|
||||||
|
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
||||||
|
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
||||||
|
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||||
|
///
|
||||||
|
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
||||||
|
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||||
|
/// </summary>
|
||||||
|
public bool FollowParent { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>MiGu.Server</RootNamespace>
|
||||||
|
<AssemblyName>MiGu.Server</AssemblyName>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using MiGu.Server.Auth;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||||
|
// 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」
|
||||||
|
// 的死循环。
|
||||||
|
//
|
||||||
|
// 探测策略:从 ContentRootPath 出发向上逐级找 frontends/apps/simple-platform-vue/dist/index.html,
|
||||||
|
// 找到则将 WebRootPath 改指到 dist;找不到则保持默认 wwwroot/(用于「克隆后没跑 build」或
|
||||||
|
// 生产部署机不带 node 的场景,wwwroot/ 由 build-platform-frontend.bat 的 robocopy /MIR 兜底)。
|
||||||
|
//
|
||||||
|
// 影响:
|
||||||
|
// - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效;
|
||||||
|
// 也不再需要 commit wwwroot/index.html。
|
||||||
|
// - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在
|
||||||
|
// 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。
|
||||||
|
{
|
||||||
|
static string? FindDistRoot(string startDir)
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(startDir);
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html");
|
||||||
|
if (File.Exists(candidate)) return Path.GetDirectoryName(candidate);
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dist = FindDistRoot(builder.Environment.ContentRootPath);
|
||||||
|
if (dist != null)
|
||||||
|
{
|
||||||
|
builder.Environment.WebRootPath = dist;
|
||||||
|
Console.WriteLine($"[MiGu.Server] WebRoot -> dist: {dist}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[MiGu.Server] WebRoot -> wwwroot (dist not found): {builder.Environment.WebRootPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制器 + JSON 默认大小写、忽略 null
|
||||||
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(opt =>
|
||||||
|
{
|
||||||
|
opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
|
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||||
|
opt.JsonSerializerOptions.WriteIndented = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
{
|
||||||
|
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
||||||
|
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||||
|
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Name = "Authorization",
|
||||||
|
Description = "JWT bearer。值: \"Bearer {token}\"",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Type = SecuritySchemeType.ApiKey,
|
||||||
|
Scheme = "Bearer"
|
||||||
|
});
|
||||||
|
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
|
||||||
|
},
|
||||||
|
Array.Empty<string>()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS(Vite dev 5173 与 MiGu.Server 8080 跨域)
|
||||||
|
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||||
|
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
||||||
|
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||||||
|
|
||||||
|
// ─── AR-3 / AR-5:JWT + Cookie 双轨鉴权 ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 设计:
|
||||||
|
// - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输;
|
||||||
|
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||||||
|
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||||||
|
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
|
||||||
|
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||||||
|
// 共享密钥(YARP transform 自动追加)。
|
||||||
|
builder.Services.AddSingleton<UserStore>();
|
||||||
|
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||||||
|
{
|
||||||
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
|
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
|
||||||
|
var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret;
|
||||||
|
var issuer = config["Jwt:Issuer"] ?? "MiGu.Server";
|
||||||
|
var audience = config["Jwt:Audience"] ?? "platform.client";
|
||||||
|
var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60;
|
||||||
|
return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger);
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<InternalTokenStore>();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(opt =>
|
||||||
|
{
|
||||||
|
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
||||||
|
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateIssuerSigningKey = false,
|
||||||
|
ValidateLifetime = false,
|
||||||
|
};
|
||||||
|
opt.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = ctx =>
|
||||||
|
{
|
||||||
|
// 优先从 Authorization: Bearer 取;没有再从 Cookie 取。
|
||||||
|
if (string.IsNullOrEmpty(ctx.Token))
|
||||||
|
{
|
||||||
|
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||||||
|
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||||||
|
}
|
||||||
|
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
||||||
|
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
||||||
|
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(opts =>
|
||||||
|
{
|
||||||
|
// Platform scope:完整管理端权限,对应 admin 账号。
|
||||||
|
opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform"));
|
||||||
|
// RCSMonitor scope:运营白名单。
|
||||||
|
opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor"));
|
||||||
|
// 任一登录用户。
|
||||||
|
opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser());
|
||||||
|
});
|
||||||
|
|
||||||
|
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。
|
||||||
|
builder.Services.AddReverseProxy()
|
||||||
|
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||||
|
.AddTransforms(tctx =>
|
||||||
|
{
|
||||||
|
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||||
|
if (tctx.Route.RouteId != "sl-route") return;
|
||||||
|
tctx.AddRequestTransform(rt =>
|
||||||
|
{
|
||||||
|
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||||
|
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||||||
|
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||||||
|
builder.Services.AddSingleton<ConfigStore>();
|
||||||
|
|
||||||
|
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||||
|
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||||
|
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||||
|
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||||||
|
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||||||
|
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||||
|
// 主动实例化 SimpleLiteLauncher,让 ProcessExit 钩子尽早注册(MiGu.Server 异常退出时 SimpleLite 也会被清理)。
|
||||||
|
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||||||
|
{
|
||||||
|
var sl = simpleLiteLauncher.GetDiagnostics();
|
||||||
|
app.Logger.LogInformation(
|
||||||
|
"[MiGu.Server] SimpleLite: Enabled={Enabled}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
|
||||||
|
sl.Enabled, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||||||
|
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiGu.Server 停机时是否带走 SimpleLite,由 SimpleLiteLauncher.Dispose 内部按 FollowParent 决定:
|
||||||
|
// - 会话 N+2 起 FollowParent=false 默认值 → Dispose 仅释放本地引用,不 kill 子进程(独立程序语义);
|
||||||
|
// - 仅当用户显式 opt-in FollowParent=true 时,Dispose 才会 kill 子进程 + 关闭 JobObject。
|
||||||
|
app.Lifetime.ApplicationStopping.Register(() =>
|
||||||
|
{
|
||||||
|
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
||||||
|
catch { /* shutdown best-effort */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
// 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
// YARP:/api/sl/* → :8222;/vr/* → :8223
|
||||||
|
app.MapReverseProxy();
|
||||||
|
|
||||||
|
// SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status)
|
||||||
|
// 所有非 API、非静态资源的路径都返回 wwwroot/index.html
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"MiGu.Server": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://0.0.0.0:8080",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# MiGu.Server
|
||||||
|
|
||||||
|
> 「**迷毂 · 智能调度平台**」的后端骨架(ASP.NET Core 8 + YARP)。
|
||||||
|
> 对应 [ARCHITECTURE.md](ARCHITECTURE.md) v1.6 §1.1 中提到的「Platform 管理端」后端可执行文件。
|
||||||
|
>
|
||||||
|
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
||||||
|
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
||||||
|
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`,
|
||||||
|
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
|
||||||
|
>
|
||||||
|
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
||||||
|
|
||||||
|
## 克隆后首次部署速查(必读)
|
||||||
|
|
||||||
|
本仓库为 **Migu2.0**,已包含预构建的 `wwwroot/` 前端,克隆后可直接编译运行 MiGu.Server。
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
# 1) 编译 MiGu.Server
|
||||||
|
cd MiGu.Server
|
||||||
|
dotnet build MiGu.Server.csproj -c Debug
|
||||||
|
|
||||||
|
# 2) 启动(登录后按所选模式拉起 SimpleLite)
|
||||||
|
dotnet run
|
||||||
|
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
||||||
|
|
||||||
|
## 配置 SimpleLite 路径(必读)
|
||||||
|
|
||||||
|
> **配置文件位置(无 Web 界面):**
|
||||||
|
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
|
||||||
|
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
|
||||||
|
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
|
||||||
|
|
||||||
|
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
|
||||||
|
|
||||||
|
### 本机开发(与 Simple 仓库并列)
|
||||||
|
|
||||||
|
仓库默认已在 `appsettings.Development.json` 中写好:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"SimpleLite": {
|
||||||
|
"Enabled": true,
|
||||||
|
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||||
|
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||||
|
"ProjectionPort": 8222,
|
||||||
|
"ReadinessTimeoutMs": 8000,
|
||||||
|
"FollowParent": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
请先编译 SimpleLite(在 Simple 仓库):
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
cd ..\Simple
|
||||||
|
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||||
|
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
| 键 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI) |
|
||||||
|
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs`) |
|
||||||
|
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
||||||
|
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
||||||
|
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
|
||||||
|
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
|
||||||
|
|
||||||
|
### 环境变量覆盖
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
|
||||||
|
$env:SimpleLite__WorkingDirectory = "D:\apps"
|
||||||
|
$env:SimpleLite__Enabled = "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产 / 合并发布
|
||||||
|
|
||||||
|
将 `SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:8080/login`:
|
||||||
|
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
||||||
|
2. 选 scope(管理员 / 运营);
|
||||||
|
3. 选 **SimpleLite 启动模式**:
|
||||||
|
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
||||||
|
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
|
||||||
|
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
|
||||||
|
|
||||||
|
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
|
||||||
|
|
||||||
|
## 安全须知(生产部署必读)
|
||||||
|
|
||||||
|
> **重要:以下默认值仅供本地开发,切勿原样用于生产环境。**
|
||||||
|
>
|
||||||
|
> - `appsettings.json:Auth.Users` 内置 `admin/admin`、`ops/ops` 为弱口令演示账号;
|
||||||
|
> - `appsettings.json:Jwt.Secret`、`Internal.Token` 为占位符 `REPLACE_ME`。
|
||||||
|
>
|
||||||
|
> 生产部署务必通过**环境变量**或 `appsettings.Production.json` / 密钥管理覆盖
|
||||||
|
> (ASP.NET Core 配置优先级:环境变量 > `appsettings.{Environment}.json` > `appsettings.json`)。
|
||||||
|
> 环境变量示例(`__` 双下划线表示配置层级):
|
||||||
|
>
|
||||||
|
> ```pwsh
|
||||||
|
> $env:Auth__Users__admin__Password = "<强密码>"
|
||||||
|
> $env:Jwt__Secret = "<不少于 32 字节的随机串>"
|
||||||
|
> $env:Internal__Token = "<随机串>"
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 详见代码审核 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。
|
||||||
|
|
||||||
|
## 能力清单(本轮)
|
||||||
|
|
||||||
|
- Kestrel 监听 `:8080`(HTTP);
|
||||||
|
- 静态托管:启动时自动探测 WebRoot ——
|
||||||
|
- 开发模式:仓库内 `frontends/apps/simple-platform-vue/dist/` 存在则直接挂为 WebRoot(`pnpm build` 后立即生效,无需 robocopy 到 wwwroot);
|
||||||
|
- 兜底/部署模式:找不到 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);
|
||||||
|
- `/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`;
|
||||||
|
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
||||||
|
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
||||||
|
- 健康检查:`/api/health`;
|
||||||
|
- Swagger:开发环境下 `/swagger`。
|
||||||
|
|
||||||
|
> 不在本轮范围:真实 SimpleLite WebAPI、SystemMission 拉起、真实 JWT/RBAC、SQLite/EF Core 持久层。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MiGu.Server/
|
||||||
|
├── MiGu.Server.csproj
|
||||||
|
├── Program.cs # Kestrel + YARP + CORS + StaticFiles + SPA fallback
|
||||||
|
├── appsettings.json # YARP 路由 + CORS 白名单
|
||||||
|
├── appsettings.Development.json
|
||||||
|
├── Properties/launchSettings.json
|
||||||
|
├── Controllers/
|
||||||
|
│ ├── AuthController.cs # /api/auth/login | /api/auth/logout
|
||||||
|
│ ├── ConfigController.cs # GET/PUT /api/config[/{section}]
|
||||||
|
│ ├── ProjectionController.cs # /api/projection/{sites,tracks,cars,missions}
|
||||||
|
│ ├── OpsController.cs # /api/sl/ops/{execute,audits}
|
||||||
|
│ └── HealthController.cs # /api/health
|
||||||
|
├── Configs/ # 与 ARCHITECTURE.md §9 一一对齐的强类型 record
|
||||||
|
│ ├── SystemConfig.cs
|
||||||
|
│ ├── ExternalIntegrations.cs
|
||||||
|
│ ├── RoutingPolicy.cs
|
||||||
|
│ ├── VehicleMaintenancePolicy.cs
|
||||||
|
│ ├── ChargePolicy.cs
|
||||||
|
│ ├── TaskAllocationPolicy.cs
|
||||||
|
│ ├── TrafficRule.cs
|
||||||
|
│ ├── DeviceManagementConfig.cs
|
||||||
|
│ ├── FleetLifecycleConfig.cs
|
||||||
|
│ ├── ScenarioTemplateConfig.cs
|
||||||
|
│ ├── LocationManagement.cs
|
||||||
|
│ ├── OpsConfig.cs
|
||||||
|
│ ├── CustomWidget.cs
|
||||||
|
│ ├── EffectivePermissions.cs
|
||||||
|
│ └── ConfigStore.cs # 内存 + JSON 文件持久化(占位)
|
||||||
|
├── data/ # 运行时生成的 config-{section}.json
|
||||||
|
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 独立启动(仅当你不通过 SimpleLite 自启时)
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
# 1) 还原 + 编译 + 运行
|
||||||
|
cd MiGu.Server
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# 等价的 build + run 一键脚本:
|
||||||
|
.\build-and-run.bat # Debug
|
||||||
|
.\build-and-run.bat release # Release
|
||||||
|
|
||||||
|
# 2) 验证
|
||||||
|
# 打开 http://localhost:8080/ 欢迎页 / 平台前端
|
||||||
|
# 打开 http://localhost:8080/swagger API 文档
|
||||||
|
# 打开 http://localhost:8080/api/health 健康检查
|
||||||
|
# 打开 http://localhost:8080/api/config/system 系统级配置
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 IDE 中以 `MiGu.Server` 作为启动项目。
|
||||||
|
|
||||||
|
> 启动时控制台会打印 `[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;
|
||||||
|
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
|
||||||
|
|
||||||
|
## 与前端联调
|
||||||
|
|
||||||
|
- 开发模式(前端在 Simple 仓库):
|
||||||
|
- 前端:`cd ../Simple/frontends && pnpm dev`(`:5173`),Vite 代理 `/api` 至 `http://127.0.0.1:8080`;
|
||||||
|
- 后端:`cd MiGu.Server && dotnet run`(`:8080`);
|
||||||
|
- 浏览器访问 `http://localhost:5173/login`。
|
||||||
|
- 生产/同源模式(本仓库 `wwwroot/`):
|
||||||
|
- 默认使用 `MiGu.Server/wwwroot/`(已随仓库提交构建产物);
|
||||||
|
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
||||||
|
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
||||||
|
|
||||||
|
## YARP 与 SimpleLite
|
||||||
|
|
||||||
|
YARP 路由配置见 `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ReverseProxy": {
|
||||||
|
"Routes": {
|
||||||
|
"sl-route": { "Match": { "Path": "/api/sl/{**catch-all}" }, "ClusterId": "sl-cluster" },
|
||||||
|
"vrender-route": { "Match": { "Path": "/vr/{**catch-all}" }, "ClusterId": "vrender-cluster" }
|
||||||
|
},
|
||||||
|
"Clusters": {
|
||||||
|
"sl-cluster": { "Destinations": { "sl1": { "Address": "http://127.0.0.1:8222/" } } },
|
||||||
|
"vrender-cluster": { "Destinations": { "vr1": { "Address": "http://127.0.0.1:8223/" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
||||||
|
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`,SimpleLite 未启时会得到 502/连接被拒。
|
||||||
|
|
||||||
|
## iframe 嵌入 webVRender 的两种方式
|
||||||
|
|
||||||
|
1. **直连**(默认):前端 iframe 指向 `http://localhost:8223/?scope=...&token=...&ro=...`,跨源;
|
||||||
|
2. **同源代理**(可选):iframe 指向 `http://localhost:8080/vr/?scope=...`,由本工程 YARP 反代到 8223,
|
||||||
|
可规避 X-Frame-Options 等限制。前端可通过 `Workspace3D` 组件的 `host` prop 传 `localhost:8080/vr` 切换。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
|
||||||
|
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Information",
|
||||||
|
"Yarp": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SimpleLite": {
|
||||||
|
"Enabled": true,
|
||||||
|
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||||
|
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||||
|
"ProjectionPort": 8222,
|
||||||
|
"ReadinessTimeoutMs": 8000,
|
||||||
|
"FollowParent": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Yarp": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"Origins": [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "REPLACE_ME",
|
||||||
|
"Issuer": "migu.server",
|
||||||
|
"Audience": "migu.client",
|
||||||
|
"LifetimeMinutes": 1440
|
||||||
|
},
|
||||||
|
"Internal": {
|
||||||
|
"Token": "REPLACE_ME"
|
||||||
|
},
|
||||||
|
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
||||||
|
"SimpleLite": {
|
||||||
|
"Enabled": true,
|
||||||
|
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||||
|
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
|
||||||
|
"Arguments": "",
|
||||||
|
"ReadinessTimeoutMs": 15000,
|
||||||
|
"ReadinessPollIntervalMs": 250,
|
||||||
|
"ProjectionPort": 8222,
|
||||||
|
"FollowParent": false
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"Users": {
|
||||||
|
"admin": { "Password": "admin" },
|
||||||
|
"ops": { "Password": "ops" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ReverseProxy": {
|
||||||
|
"Routes": {
|
||||||
|
"sl-route": {
|
||||||
|
"ClusterId": "sl-cluster",
|
||||||
|
"Match": { "Path": "/api/sl/{**catch-all}" },
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vrender-route": {
|
||||||
|
"ClusterId": "vrender-cluster",
|
||||||
|
"Match": { "Path": "/vr/{**catch-all}" },
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/vr" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Clusters": {
|
||||||
|
"sl-cluster": {
|
||||||
|
"Destinations": {
|
||||||
|
"sl1": { "Address": "http://127.0.0.1:8222/" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vrender-cluster": {
|
||||||
|
"Destinations": {
|
||||||
|
"vr1": { "Address": "http://127.0.0.1:8223/" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal ENABLEDELAYEDEXPANSION
|
||||||
|
|
||||||
|
REM ==========================================================
|
||||||
|
REM MiGu.Server build and run (one-click)
|
||||||
|
REM - default Debug; pass "release" / "-r" / "/r" for Release
|
||||||
|
REM - listens on http://0.0.0.0:8080 (see Properties\launchSettings.json)
|
||||||
|
REM - YARP proxies: /api/sl/* -> :8222 ; /vr/* -> :8223
|
||||||
|
REM ==========================================================
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
set "CONFIG=Debug"
|
||||||
|
if /I "%~1"=="release" set "CONFIG=Release"
|
||||||
|
if /I "%~1"=="-r" set "CONFIG=Release"
|
||||||
|
if /I "%~1"=="/r" set "CONFIG=Release"
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo MiGu.Server 生成 + 启动 ( Config = %CONFIG% )
|
||||||
|
echo 目录: %CD%
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where dotnet >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 未找到 dotnet ^( .NET 8 SDK ^)。请先安装 .NET 8 SDK 并重新打开终端。
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo === [1/2] dotnet build ===
|
||||||
|
dotnet build "MiGu.Server.csproj" -c %CONFIG% --nologo
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo *** 构建失败,已停止。请检查上方编译错误。 ***
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === [2/2] dotnet run ===
|
||||||
|
echo [提示] Ctrl+C 终止;浏览器访问 http://localhost:8080/ ^(Swagger: /swagger^)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
dotnet run --project "MiGu.Server.csproj" -c %CONFIG% --no-build --no-restore
|
||||||
|
set "RUN_EC=%errorlevel%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if not "%RUN_EC%"=="0" (
|
||||||
|
echo *** MiGu.Server 退出,errorlevel=%RUN_EC%
|
||||||
|
) else (
|
||||||
|
echo MiGu.Server 正常退出。
|
||||||
|
)
|
||||||
|
endlocal & exit /b %RUN_EC%
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
|||||||
|
import{aG as S,bo as z,aA as b,bB as a,u as B,b3 as p,aF as e,P as N,S as A,a4 as O,aC as T,bd as F,_ as M,ai as U,s as D,bm as f,aE as d,bE as I,ab as G,ad as L,bj as j,az as c,ba as P,bb as Y,Y as g,O as $,aa as q,I as H}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const J={style:{display:"flex","align-items":"center",gap:"8px"}},pe=S({__name:"AnnotationView",setup(K){const s=z(),l=P({title:"",target:"",level:"info",content:""}),m=Y([{id:"N001",ts:"2026-05-19 10:25",author:"ops",target:"C05",level:"warn",title:"AGV-005 故障观察",content:"出库点附近频繁停顿,怀疑激光被遮挡"}]);function w(r){return r==="error"?"danger":r==="warn"?"warning":"info"}function v(){if(!l.title){g.warning("请填写标题");return}const r=`N${String(m.value.length+1).padStart(3,"0")}`;m.value.unshift({id:r,ts:new Date().toLocaleString("zh-CN"),author:s.user?.username??"mock",target:l.target,level:l.level,title:l.title,content:l.content}),g.success("已记录(Mock,未写入数据库)"),l.title="",l.content="",l.target=""}return(r,t)=>{const u=L,_=A,i=N,V=M,y=O,E=D,x=$,h=H,n=G,k=q,C=B;return p(),b(C,{shadow:"never"},{header:a(()=>[c("div",J,[t[7]||(t[7]=c("span",null,"运营备注(monitor.note.write · 写 platform.db.Annotations)",-1)),f(s).hasOp("monitor.note.write")?(p(),b(u,{key:0,size:"small",type:"success"},{default:a(()=>[...t[5]||(t[5]=[d("可写",-1)])]),_:1})):(p(),b(u,{key:1,size:"small",type:"danger"},{default:a(()=>[...t[6]||(t[6]=[d("只读",-1)])]),_:1}))])]),default:a(()=>[e(x,{inline:"",onSubmit:t[3]||(t[3]=I(()=>{},["prevent"]))},{default:a(()=>[e(i,{label:"标题"},{default:a(()=>[e(_,{modelValue:l.title,"onUpdate:modelValue":t[0]||(t[0]=o=>l.title=o),style:{width:"240px"}},null,8,["modelValue"])]),_:1}),e(i,{label:"关联目标"},{default:a(()=>[e(_,{modelValue:l.target,"onUpdate:modelValue":t[1]||(t[1]=o=>l.target=o),placeholder:"例如 C01 / M02 / S006",style:{width:"200px"}},null,8,["modelValue"])]),_:1}),e(i,{label:"层级"},{default:a(()=>[e(y,{modelValue:l.level,"onUpdate:modelValue":t[2]||(t[2]=o=>l.level=o),style:{width:"120px"}},{default:a(()=>[(p(),T(U,null,F(["info","warn","error"],o=>e(V,{key:o,label:o,value:o},null,8,["label","value"])),64))]),_:1},8,["modelValue"])]),_:1}),e(i,null,{default:a(()=>[e(E,{type:"primary",disabled:!f(s).hasOp("monitor.note.write"),onClick:v},{default:a(()=>[...t[8]||(t[8]=[d("提交",-1)])]),_:1},8,["disabled"])]),_:1})]),_:1}),e(_,{modelValue:l.content,"onUpdate:modelValue":t[4]||(t[4]=o=>l.content=o),type:"textarea",rows:4,placeholder:"描述备注内容...",disabled:!f(s).hasOp("monitor.note.write")},null,8,["modelValue","disabled"]),e(h),e(k,{data:m.value,size:"small","max-height":"400"},{default:a(()=>[e(n,{prop:"ts",label:"时间",width:"180"}),e(n,{prop:"author",label:"作者",width:"100"}),e(n,{prop:"target",label:"目标",width:"100"}),e(n,{prop:"level",label:"层级",width:"80"},{default:a(o=>[e(u,{size:"small",type:w(o.row.level)},{default:a(()=>[d(j(o.row.level),1)]),_:2},1032,["type"])]),_:1}),e(n,{prop:"title",label:"标题"}),e(n,{prop:"content",label:"内容"})]),_:1},8,["data"])]),_:1})}}});export{pe as default};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{aG as y,aA as n,bB as a,bm as E,D as T,b3 as o,aF as e,a9 as V,aa as k,ab as x,aC as d,bd as u,ad as C,aE as p,bj as s,ai as m,a8 as z,ac as B}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as D}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const O=y({__name:"AuthRoleView",setup(v){function w(c){switch(c){case"hidden":return"danger";case"readonly":return"warning";case"interactive":return"success";default:return"info"}}return(c,A)=>{const l=x,i=C,_=k,b=V,h=z,g=B;return o(),n(D,{section:"auth",title:"权限与角色管理",description:"角色、权限码、控件级 WidgetGrant(auth.user.write / auth.role.write)",defaults:E(T)},{default:a(({payload:f})=>[e(g,{"model-value":"roles"},{default:a(()=>[e(b,{name:"roles",label:"角色"},{default:a(()=>[e(_,{data:f.roles,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"名称",prop:"name",width:"120"}),e(l,{label:"Scope",prop:"scope",width:"120"}),e(l,{label:"权限码"},{default:a(r=>[(o(!0),d(m,null,u(r.row.permissions,t=>(o(),n(i,{key:t,size:"small",effect:"plain",style:{margin:"2px"}},{default:a(()=>[p(s(t),1)]),_:2},1024))),128))]),_:1}),e(l,{label:"WidgetGrant",width:"280"},{default:a(r=>[(o(!0),d(m,null,u(r.row.widgetGrants,t=>(o(),n(i,{key:t.widgetId,size:"small",type:w(t.visibility),effect:"plain",style:{margin:"2px"}},{default:a(()=>[p(s(t.widgetId)+"="+s(t.visibility),1)]),_:2},1032,["type"]))),128))]),_:1})]),_:1},8,["data"])]),_:2},1024),e(b,{name:"users",label:"用户"},{default:a(()=>[e(_,{data:f.users,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"用户名",prop:"username",width:"140"}),e(l,{label:"角色"},{default:a(r=>[(o(!0),d(m,null,u(r.row.roles,t=>(o(),n(i,{key:t,size:"small"},{default:a(()=>[p(s(t),1)]),_:2},1024))),128))]),_:1}),e(l,{label:"启用",width:"80"},{default:a(r=>[e(h,{modelValue:r.row.enabled,"onUpdate:modelValue":t=>r.row.enabled=t},null,8,["modelValue","onUpdate:modelValue"])]),_:1})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{O as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.cse-form[data-v-3cdad944]{padding:8px 4px}.cse-color-code[data-v-3cdad944]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary);margin-left:8px}.cse-color-hint[data-v-3cdad944]{font-size:12px;color:var(--el-text-color-secondary);margin-left:8px}.cse-model-row[data-v-3cdad944]{display:flex;align-items:center;gap:8px;width:100%}.cse-model-hint[data-v-3cdad944]{margin:6px 0 0;font-size:12px;color:var(--el-text-color-secondary);line-height:1.55}.cse-root[data-v-3af8f925]{height:100%;display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box;overflow:auto}.cse-card[data-v-3af8f925] .el-card__body{padding:12px 16px}.cse-card-header[data-v-3af8f925]{display:flex;align-items:center;gap:10px}.cse-card-title[data-v-3af8f925]{font-weight:600}.cse-card-spacer[data-v-3af8f925]{flex:1}.cse-desc[data-v-3af8f925]{margin:0 0 12px;color:var(--el-text-color-primary);font-size:13px;line-height:1.65;display:flex;flex-wrap:wrap;align-items:center;gap:6px}.cse-palette[data-v-3af8f925]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.cse-palette-item[data-v-3af8f925]{border:1px solid var(--el-border-color);background:var(--el-fill-color-light);border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}.cse-palette-label[data-v-3af8f925]{display:flex;align-items:center;gap:6px}.cse-palette-key[data-v-3af8f925]{font-weight:600;color:var(--el-text-color-primary)}.cse-palette-pickers[data-v-3af8f925]{display:flex;align-items:center;gap:8px}.cse-color-code[data-v-3af8f925]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary)}.cse-types-card[data-v-3af8f925]{flex:1}.cse-row-title[data-v-3af8f925]{display:flex;align-items:center;gap:8px;width:100%}.cse-row-name[data-v-3af8f925]{font-weight:600}.cse-row-fqn[data-v-3af8f925]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary);flex:1;text-align:left;word-break:break-all}.cse-btn-text[data-v-3af8f925]{margin-left:4px}.car-page[data-v-c3733824]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box;display:flex;flex-direction:column}.car-tabs[data-v-c3733824]{flex:1;display:flex;flex-direction:column;overflow:hidden}.car-tabs[data-v-c3733824] .el-tabs__content{flex:1;overflow:hidden;padding:0}.car-tabs[data-v-c3733824] .el-tab-pane{height:100%;overflow:hidden}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as s,aA as c,bB as t,bm as d,a as u,b3 as f,aF as e,P as b,a8 as h,T as C,aa as g,ab as w,O as E}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const q=s({__name:"ChargePolicyView",setup(V){return(x,A)=>{const i=h,l=b,n=C,a=w,p=g,_=E;return f(),c(T,{section:"charge",title:"充电逻辑",description:"充电优先级、空闲充电、任务中断充电(ChargePolicy)",defaults:d(u)},{default:t(({payload:o,update:r})=>[e(_,{"label-width":"180px",model:o},{default:t(()=>[e(l,{label:"允许任务中断充电"},{default:t(()=>[e(i,{"model-value":o.allowMidTaskCharge,"onUpdate:modelValue":m=>r({...o,allowMidTaskCharge:!!m})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(l,{label:"空闲多少秒后充电"},{default:t(()=>[e(n,{"model-value":o.idleChargeAfterSec,min:0,max:86400,"onUpdate:modelValue":m=>r({...o,idleChargeAfterSec:m??0})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(l,{label:"充电优先级规则"},{default:t(()=>[e(p,{data:o.priority,size:"small"},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"条件",prop:"condition"}),e(a,{label:"权重",prop:"weight",width:"100"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{q as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.cpb-header[data-v-83b90685]{display:flex;align-items:center;gap:8px}.cpb-title[data-v-83b90685]{font-weight:600;font-size:15px;color:#fff}.cpb-header .spacer[data-v-83b90685]{flex:1}.cpb-desc[data-v-83b90685]{color:#e8d7f5b3;font-size:12.5px;margin:0 0 14px;line-height:1.6}.cpb-body[data-v-83b90685]{margin-top:4px}.cpb-json[data-v-83b90685]{margin:0;padding:14px;font-size:12px;line-height:1.55;font-family:ui-monospace,Menlo,Consolas,monospace;background:#0f04208c;color:#d6c5ee;max-height:520px;overflow:auto}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{aG as _,aA as i,bB as e,bm as d,m as u,b3 as s,aF as a,ab as f,az as n,bj as r,aC as b,bd as g,ad as h,aE as w,ai as C,aa as E,al as T}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as y}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const B={class:"js-mini"},x={class:"js-mini"},S=_({__name:"CustomWidgetView",setup(V){return(k,D)=>{const t=f,m=h,p=E;return s(),i(y,{section:"widget",title:"自定义控件",description:"呼叫/展示界面可自由定义(platform-vue + rcsmonitor-vue 双渲染器)",defaults:d(u)},{default:e(({payload:c})=>[a(p,{data:c.items,size:"small",border:""},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"180"}),a(t,{label:"名称",prop:"name",width:"140"}),a(t,{label:"Schema",prop:"schemaJson"},{default:e(o=>[n("pre",B,r(o.row.schemaJson),1)]),_:1}),a(t,{label:"Layout",prop:"layoutJson"},{default:e(o=>[n("pre",x,r(o.row.layoutJson),1)]),_:1}),a(t,{label:"绑定 Scope"},{default:e(o=>[(s(!0),b(C,null,g(o.row.bindToScopes,l=>(s(),i(m,{key:l,size:"small",effect:"plain",style:{"margin-right":"4px"}},{default:e(()=>[w(r(l),1)]),_:2},1024))),128))]),_:1})]),_:1},8,["data"])]),_:1},8,["defaults"])}}}),H=T(S,[["__scopeId","data-v-96b90481"]]);export{H as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.js-mini[data-v-96b90481]{margin:0;font-size:11px;background:#f5f7fa;padding:4px 6px;border-radius:4px;max-height:60px;overflow:auto}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{aG as b,aA as u,bB as a,bm as f,b as h,b3 as w,aF as e,a9 as v,aa as E,ab as D,a8 as C,F as P,G as T,aE as i,bj as r,ac as V}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as g}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const Q=b({__name:"DeviceHubView",setup(x){return(B,I)=>{const l=D,s=E,n=v,d=C,o=T,c=P,m=V;return w(),u(g,{section:"device",title:"第三方设备统一接入",description:"电梯/卷帘门/安全门/充电桩/AP/交换机/摄像头/读码器/PLC(DeviceManagementConfig)",defaults:f(h)},{default:a(({payload:t})=>[e(m,{"model-value":"drivers"},{default:a(()=>[e(n,{name:"drivers",label:"驱动绑定"},{default:a(()=>[e(s,{data:t.drivers,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"设备类型",prop:"deviceType",width:"120"}),e(l,{label:"驱动名",prop:"driverName"}),e(l,{label:"版本",prop:"version",width:"100"})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"devices",label:"设备实例"},{default:a(()=>[e(s,{data:t.devices,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"名称",prop:"name",width:"140"}),e(l,{label:"类型",prop:"deviceType",width:"100"}),e(l,{label:"协议",prop:"protocol",width:"120"}),e(l,{label:"地址",prop:"address"}),e(l,{label:"启用",width:"80"},{default:a(p=>[e(d,{modelValue:p.row.enabled,"onUpdate:modelValue":_=>p.row.enabled=_},null,8,["modelValue","onUpdate:modelValue"])]),_:1})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"policy",label:"健康 / 告警"},{default:a(()=>[e(c,{column:2,border:""},{default:a(()=>[e(o,{label:"心跳周期 (s)"},{default:a(()=>[i(r(t.healthPolicy.heartbeatSec),1)]),_:2},1024),e(o,{label:"离线判定 (s)"},{default:a(()=>[i(r(t.healthPolicy.offlineSec),1)]),_:2},1024),e(o,{label:"告警启用"},{default:a(()=>[i(r(t.alarmPolicy.enabled?"是":"否"),1)]),_:2},1024),e(o,{label:"规则数"},{default:a(()=>[i(r(t.alarmPolicy.rules.length),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{Q as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as U,aA as x,bB as a,bm as c,d as g,b3 as b,aF as t,aC as h,bd as T,a9 as S,aa as B,ab as I,S as $,a8 as v,s as D,aE as f,b6 as N,bj as z,ai as A,ac as F}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as L}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const te=U({__name:"ExternalIntegrationView",setup(R){function w(r,s,l){const o=`${l}-${Date.now()}`,u={...r,[l]:[...r[l],{id:o,name:"新端点",url:"http://",enabled:!1}]};s(u)}function V(r,s,l,o){const u={...r,[l]:r[l].filter((i,d)=>d!==o)};s(u)}return(r,s)=>{const l=$,o=I,u=v,i=D,d=B,E=S,C=F;return b(),x(L,{section:"integrations",title:"外部系统对接",description:"MES / WMS / RCS 等标准接口配置(ExternalIntegrations)",defaults:c(g)},{default:a(({payload:p,update:_})=>[t(C,{"model-value":"mes"},{default:a(()=>[(b(),h(A,null,T(["mes","wms","rcs"],m=>t(E,{key:m,name:m,label:m.toUpperCase()},{default:a(()=>[t(d,{data:p[m],size:"small",border:""},{default:a(()=>[t(o,{label:"ID",width:"120"},{default:a(e=>[t(l,{modelValue:e.row.id,"onUpdate:modelValue":n=>e.row.id=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"名称","min-width":"160"},{default:a(e=>[t(l,{modelValue:e.row.name,"onUpdate:modelValue":n=>e.row.name=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"URL","min-width":"260"},{default:a(e=>[t(l,{modelValue:e.row.url,"onUpdate:modelValue":n=>e.row.url=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"启用",width:"80"},{default:a(e=>[t(u,{modelValue:e.row.enabled,"onUpdate:modelValue":n=>e.row.enabled=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"操作",width:"80"},{default:a(e=>[t(i,{text:"",type:"danger",size:"small",onClick:n=>V(p,_,m,e.$index)},{default:a(()=>[...s[0]||(s[0]=[f("删除",-1)])]),_:1},8,["onClick"])]),_:2},1024)]),_:2},1032,["data"]),t(i,{size:"small",icon:c(N),style:{"margin-top":"8px"},onClick:e=>w(p,_,m)},{default:a(()=>[f("新增 "+z(m.toUpperCase())+" 端点",1)]),_:2},1032,["icon","onClick"])]),_:2},1032,["name","label"])),64))]),_:2},1024)]),_:1},8,["defaults"])}}});export{te as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as f,aA as m,bB as t,bm as g,c as h,b3 as _,aF as e,a9 as T,aa as E,ab as w,aC as k,bd as F,ad as C,aE as l,bj as o,ai as D,F as x,G as B,ac as L}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as O}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const X=f({__name:"FleetLifecycleView",setup(A){return(z,I)=>{const s=w,p=C,d=E,n=T,r=B,i=x,b=L;return _(),m(O,{section:"fleet",title:"车队全生命周期",description:"分区域/跨楼层/多车协作;OTA、批量操作、网络诊断(FleetLifecycleConfig)",defaults:g(h)},{default:t(({payload:a})=>[e(b,{"model-value":"groups"},{default:t(()=>[e(n,{name:"groups",label:"车队分组"},{default:t(()=>[e(d,{data:a.groups,size:"small",border:""},{default:t(()=>[e(s,{label:"ID",prop:"id",width:"100"}),e(s,{label:"名称",prop:"name",width:"140"}),e(s,{label:"楼层",prop:"floor",width:"80"}),e(s,{label:"区域",prop:"region",width:"80"}),e(s,{label:"车辆"},{default:t(u=>[(_(!0),k(D,null,F(u.row.carIds,c=>(_(),m(p,{key:c,size:"small",style:{margin:"2px"}},{default:t(()=>[l(o(c),1)]),_:2},1024))),128))]),_:1})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"ota",label:"OTA 升级"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"启用"},{default:t(()=>[l(o(a.ota.enabled?"是":"否"),1)]),_:2},1024),e(r,{label:"批次大小"},{default:t(()=>[l(o(a.ota.batchSize),1)]),_:2},1024),e(r,{label:"失败回滚"},{default:t(()=>[l(o(a.ota.rollbackOnFail?"是":"否"),1)]),_:2},1024)]),_:2},1024)]),_:2},1024),e(n,{name:"batch",label:"批量操作"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"需确认"},{default:t(()=>[l(o(a.batchOps.confirmationRequired?"是":"否"),1)]),_:2},1024),e(r,{label:"最大批量"},{default:t(()=>[l(o(a.batchOps.maxBatch),1)]),_:2},1024)]),_:2},1024)]),_:2},1024),e(n,{name:"diag",label:"网络诊断"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"RTT 阈值 (ms)"},{default:t(()=>[l(o(a.networkDiag.rttThresholdMs),1)]),_:2},1024),e(r,{label:"丢包阈值"},{default:t(()=>[l(o(a.networkDiag.packetLossThreshold),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{X as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as s,aA as m,bB as t,bm as c,e as d,b3 as _,aF as e,a9 as b,aa as u,ab as f,$ as w,ac as h}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import{C as g}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const V=s({__name:"LocationView",setup(T){return(C,E)=>{const a=f,i=w,o=u,l=b,n=h;return _(),m(g,{section:"location",title:"库位管理",description:"出入库、库存、库位可视化(LocationManagement)",defaults:c(d)},{default:t(({payload:p})=>[e(n,{"model-value":"locs"},{default:t(()=>[e(l,{name:"locs",label:"库位"},{default:t(()=>[e(o,{data:p.locations,size:"small",border:""},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"编码",prop:"code",width:"120"}),e(a,{label:"名称",prop:"name"}),e(a,{label:"站点",prop:"siteId",width:"100"}),e(a,{label:"容量",prop:"capacity",width:"100"}),e(a,{label:"占用"},{default:t(r=>[e(i,{percentage:Math.round(r.row.occupied/r.row.capacity*100),"stroke-width":10},null,8,["percentage"])]),_:1})]),_:1},8,["data"])]),_:2},1024),e(l,{name:"rules",label:"库存规则"},{default:t(()=>[e(o,{data:p.inventoryRules,size:"small",border:""},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"物料类型",prop:"itemType"}),e(a,{label:"下限",prop:"minQty",width:"100"}),e(a,{label:"上限",prop:"maxQty",width:"100"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{V as default};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.mmcg[data-v-c29d34fc]{display:flex;flex-direction:column;gap:8px;padding:12px 14px;background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px}.mmcg-head[data-v-c29d34fc]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.mmcg-title[data-v-c29d34fc]{font-weight:600;font-size:13px;color:#fff}.mmcg-head .spacer[data-v-c29d34fc]{flex:1}.mmcg-empty[data-v-c29d34fc]{margin:4px 0 0;font-size:12px;font-style:italic}.muted[data-v-c29d34fc]{color:#ffffff9e}.mmcg-grid[data-v-c29d34fc]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 12px;align-items:center}.mmcg-grid[data-v-c29d34fc] .el-checkbox{margin-right:0;height:28px}.mmcg-grid[data-v-c29d34fc] .el-checkbox__label{color:#ffffffeb;font-size:12.5px;font-family:ui-monospace,Menlo,Consolas,monospace}.mmcg-orphans[data-v-c29d34fc]{margin-top:4px}.orphan-list[data-v-c29d34fc]{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.mmc-card[data-v-13970b71]{margin:0}.mmc-header[data-v-13970b71]{display:flex;align-items:center;gap:8px}.mmc-title[data-v-13970b71]{font-weight:600;font-size:15px;color:#fff}.mmc-header .spacer[data-v-13970b71]{flex:1}.mmc-desc[data-v-13970b71]{color:#e8d7f5c7;font-size:12.5px;margin:0 0 12px;line-height:1.7}.mmc-desc code[data-v-13970b71]{background:#ffffff14;padding:1px 5px;border-radius:4px;font-family:ui-monospace,Menlo,Consolas,monospace}.mmc-tabs[data-v-13970b71] .el-tabs__item{color:#ffffffd1!important}.mmc-tabs[data-v-13970b71] .el-tabs__item.is-active{color:#fff!important}.mmc-tab-body[data-v-13970b71]{display:flex;flex-direction:column;gap:18px;padding-top:6px}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.mission-page[data-v-40f885f1]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{R as o}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as i,aC as t,aF as r,b3 as p,al as e}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"mission-page"},s=i({__name:"MissionEditorView",setup(a){return(n,c)=>(p(),t("div",m,[r(o,{kind:"process","kind-label":"任务",title:"任务编排(Mission / 进程,含插件 MissionType 实例化)","empty-text":"当前没有任务;点击右上角「新建任务」从已加载的 MissionType 中选一个实例化。"})]))}}),q=e(s,[["__scopeId","data-v-40f885f1"]]);export{q as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.muted[data-v-d6b33cf6]{color:#e8d7f58c;font-size:12px}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as k,b1 as z,aC as M,aF as t,bB as e,bb as p,a2 as T,b3 as V,x as B,a6 as D,az as d,bj as c,u as N,aa as S,ab as I,ad as P,aE as w,$ as j,av as _,al as A}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{l as F,a as G}from"./projection-CizY2UtH.js";import{l as R}from"./ops-CZkWvMAR.js";import"./reflection-Dr3WM5Rz.js";const $={class:"muted"},q=k({__name:"MonitorDashboardView",setup(H){const n=p([]),m=p([]),i=p([]),b=_(()=>n.value.filter(l=>l.state!=="offline").length),C=_(()=>m.value.filter(l=>l.status==="running").length),x=_(()=>n.value.filter(l=>l.state==="fault").length);function E(l){switch(l){case"running":return"success";case"idle":return"info";case"charging":return"warning";case"paused":return"warning";case"fault":return"danger";case"offline":return"info";default:return"info"}}return z(async()=>{[n.value,m.value,i.value]=await Promise.all([F(),G(),R()])}),(l,r)=>{const u=D,o=B,f=T,a=I,g=P,y=j,v=S,h=N;return V(),M("div",null,[t(f,{gutter:14,class:"kpi-row"},{default:e(()=>[t(o,{span:6},{default:e(()=>[t(u,{title:"车辆在线",value:b.value},{suffix:e(()=>[d("span",$,"/ "+c(n.value.length),1)]),_:1},8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"任务进行中",value:C.value},null,8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"故障车辆",value:x.value},null,8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"今日运维动作",value:i.value.length},null,8,["value"])]),_:1})]),_:1}),t(f,{gutter:14,style:{"margin-top":"14px"}},{default:e(()=>[t(o,{span:14},{default:e(()=>[t(h,{shadow:"never"},{header:e(()=>[...r[0]||(r[0]=[d("span",null,"实时车辆",-1)])]),default:e(()=>[t(v,{data:n.value,size:"small","max-height":"360",stripe:""},{default:e(()=>[t(a,{prop:"name",label:"名称",width:"100"}),t(a,{prop:"state",label:"状态",width:"90"},{default:e(s=>[t(g,{size:"small",type:E(s.row.state)},{default:e(()=>[w(c(s.row.state),1)]),_:2},1032,["type"])]),_:1}),t(a,{prop:"group",label:"分组",width:"100"}),t(a,{label:"电量",width:"160"},{default:e(s=>[t(y,{percentage:Math.round(s.row.batterySoc*100),"stroke-width":8},null,8,["percentage"])]),_:1}),t(a,{prop:"missionId",label:"当前任务"})]),_:1},8,["data"])]),_:1})]),_:1}),t(o,{span:10},{default:e(()=>[t(h,{shadow:"never"},{header:e(()=>[...r[1]||(r[1]=[d("span",null,"最近运维动作",-1)])]),default:e(()=>[t(v,{data:i.value,size:"small","max-height":"360",stripe:""},{default:e(()=>[t(a,{prop:"ts",label:"时间",width:"180"}),t(a,{prop:"opCode",label:"动作"}),t(a,{prop:"target",label:"目标",width:"100"}),t(a,{prop:"result",label:"结果",width:"80"},{default:e(s=>[t(g,{size:"small",type:s.row.result==="ok"?"success":"danger"},{default:e(()=>[w(c(s.row.result),1)]),_:2},1032,["type"])]),_:1})]),_:1},8,["data"])]),_:1})]),_:1})]),_:1})])}}}),ot=A(q,[["__scopeId","data-v-d6b33cf6"]]);export{ot as default};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import{aG as A,bo as G,b1 as L,aC as d,aF as a,bB as o,bb as r,E as j,a2 as H,b3 as i,aE as u,bj as _,bm as c,x as Y,az as f,u as Z,a1 as q,a0 as J,a4 as K,ai as b,bd as I,aA as S,F as Q,G as X,ag as ee,R as te,aR as ae,aB as oe,av as le,_ as ne,s as se,Y as g,Z as re,al as ie}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{W as ue}from"./Workspace3D-D7MYl2zT.js";import{W as ce}from"./WorkspaceCanvasToolbar-1mCqpdq_.js";import{O as pe}from"./ops-BCeFEEjY.js";import{l as me,a as de}from"./projection-CizY2UtH.js";import{e as _e}from"./ops-CZkWvMAR.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";const fe={class:"monitor-map-page"},ve={class:"canvas-with-toolbar"},be={class:"ops-grid"},ge=A({__name:"MonitorMapView",setup(ye){const y=G(),E="localhost:8223",p=r("car"),s=r(""),w=r([]),k=r([]),m=r(null),v=r([]),$=le(()=>p.value==="car"?w.value:k.value);function M(t){return t.target===p.value||t.target==="note"}async function B(t){if(!s.value&&t.target!=="note"){g.warning("请先选择目标");return}if(t.needConfirm)try{await re.confirm(`确认执行 [${t.label}]?
|
||||||
|
目标:${s.value}`,"二次确认",{type:"warning"})}catch{return}try{const e=await _e({opCode:t.code,targetId:s.value,reason:""});g.success(`已执行 ${t.label},auditId=${e.auditId}`)}catch(e){g.error(`执行失败:${e instanceof Error?e.message:String(e)}`)}}function O(t){m.value=t}function R(t){v.value=t,t.length===1&&t[0].startsWith("UICar-")&&(p.value="car",s.value=t[0].replace("UICar-",""))}return L(async()=>{const t=await me();w.value=t.map(n=>({label:`${n.name} (${n.id})`,value:n.id}));const e=await de();k.value=e.map(n=>({label:`${n.name} (${n.id})`,value:n.id}))}),(t,e)=>{const n=j,x=Y,C=J,T=q,W=ne,D=K,h=X,F=Q,V=Z,z=te,N=ee,P=se,U=H;return i(),d("div",fe,[a(n,{type:"info",closable:!1,"show-icon":"",style:{"margin-bottom":"12px"}},{default:o(()=>[u(" RCSMonitor 只读视图:iframe 嵌入 SimpleLite webVRender("+_(c(E))+"),右侧可执行白名单运维动作。 ",1)]),_:1}),a(U,{gutter:12,class:"main-row"},{default:o(()=>[a(x,{span:17},{default:o(()=>[f("div",ve,[a(ue,{host:c(E),scope:"RCSMonitor",token:c(y).token??"","read-only":!0,"embed-ui":!0,onPick:O,onSelect:R},null,8,["host","token"]),a(ce)])]),_:1}),a(x,{span:7},{default:o(()=>[a(V,{shadow:"never"},{header:o(()=>[...e[2]||(e[2]=[f("span",null,"选中目标",-1)])]),default:o(()=>[a(T,{modelValue:p.value,"onUpdate:modelValue":e[0]||(e[0]=l=>p.value=l),size:"small"},{default:o(()=>[a(C,{label:"car"},{default:o(()=>[...e[3]||(e[3]=[u("车辆",-1)])]),_:1}),a(C,{label:"task"},{default:o(()=>[...e[4]||(e[4]=[u("任务",-1)])]),_:1})]),_:1},8,["modelValue"]),a(D,{modelValue:s.value,"onUpdate:modelValue":e[1]||(e[1]=l=>s.value=l),placeholder:"选择目标 ID",filterable:"",style:{width:"100%","margin-top":"8px"}},{default:o(()=>[(i(!0),d(b,null,I($.value,l=>(i(),S(W,{key:l.value,label:l.label,value:l.value},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),a(F,{column:1,border:"",size:"small",style:{"margin-top":"8px"}},{default:o(()=>[a(h,{label:"最近 Pick"},{default:o(()=>[u(_(m.value?`(${m.value.x.toFixed(0)}, ${m.value.y.toFixed(0)})`:"—"),1)]),_:1}),a(h,{label:"最近 Select"},{default:o(()=>[u(_(v.value.length?v.value.join(", "):"—"),1)]),_:1})]),_:1})]),_:1}),a(V,{shadow:"never",style:{"margin-top":"12px"}},{header:o(()=>[...e[5]||(e[5]=[f("span",null,"运维白名单(按权限隐藏)",-1)])]),default:o(()=>[f("div",be,[(i(!0),d(b,null,I(c(pe),l=>(i(),d(b,{key:l.code},[c(y).hasOp(l.code)&&M(l)?(i(),S(P,{key:0,type:l.needConfirm?"warning":"primary",size:"small",plain:"",onClick:Ee=>B(l)},{default:o(()=>[u(_(l.label)+" ",1),a(N,{content:l.description},{default:o(()=>[a(z,{style:{"margin-left":"4px"}},{default:o(()=>[a(c(ae))]),_:1})]),_:1},8,["content"])]),_:2},1032,["type","onClick"])):oe("",!0)],64))),128))])]),_:1})]),_:1})]),_:1})])}}}),He=ie(ge,[["__scopeId","data-v-96c491ac"]]);export{He as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.monitor-map-page[data-v-96c491ac]{display:flex;flex-direction:column;height:calc(100vh - 124px)}.main-row[data-v-96c491ac]{flex:1;min-height:0}.main-row>.el-col[data-v-96c491ac]{display:flex;flex-direction:column}.main-row>.el-col[data-v-96c491ac]:first-child{min-height:0}.ops-grid[data-v-96c491ac]{display:grid;grid-template-columns:1fr 1fr;gap:8px}.canvas-with-toolbar[data-v-96c491ac]{flex:1;min-height:0;display:flex;flex-direction:column}.canvas-with-toolbar[data-v-96c491ac] .workspace-3d-wrap{flex:1;min-height:0}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.el-timeline{--el-timeline-node-size-normal:12px;--el-timeline-node-size-large:14px;--el-timeline-node-color:var(--el-border-color-light);font-size:var(--el-font-size-base);margin:0;list-style:none}.el-timeline .el-timeline-item:last-child .el-timeline-item__tail{display:none}.el-timeline .el-timeline-item__center{align-items:center;display:flex}.el-timeline .el-timeline-item__center .el-timeline-item__wrapper{width:100%}.el-timeline .el-timeline-item__center .el-timeline-item__tail{top:0}.el-timeline .el-timeline-item__center:first-child .el-timeline-item__tail{height:calc(50% + 10px);top:calc(50% - 10px)}.el-timeline .el-timeline-item__center:last-child .el-timeline-item__tail{height:calc(50% - 10px);display:block}.el-timeline.is-start{padding-left:40px;padding-right:0}.el-timeline.is-end{padding-left:0;padding-right:40px}.el-timeline.is-alternate{padding-left:20px;padding-right:20px}.el-timeline.is-alternate .el-timeline-item:nth-child(odd) .el-timeline-item__wrapper{width:calc(50% - 28px);left:calc(50% - var(--el-timeline-node-size-large) / 2);padding-left:28px}.el-timeline.is-alternate .el-timeline-item:nth-child(2n) .el-timeline-item__wrapper{width:calc(50% - 28px + var(--el-timeline-node-size-large) / 2);text-align:right;padding-right:28px}.el-timeline.is-alternate-reverse{padding-left:20px;padding-right:20px}.el-timeline.is-alternate-reverse .el-timeline-item:nth-child(odd) .el-timeline-item__wrapper{width:calc(50% - 28px + var(--el-timeline-node-size-large) / 2);text-align:right;padding-right:28px}.el-timeline.is-alternate-reverse .el-timeline-item:nth-child(2n) .el-timeline-item__wrapper{width:calc(50% - 28px);left:calc(50% - var(--el-timeline-node-size-large) / 2);padding-left:28px}.el-timeline-item{padding-bottom:20px;position:relative}.el-timeline-item__wrapper{box-sizing:content-box;position:relative;top:-3px}.el-timeline-item__tail{border-left:2px solid var(--el-timeline-node-color);height:100%;position:absolute}.el-timeline-item .el-timeline-item__icon{color:var(--el-color-white);font-size:var(--el-font-size-small)}.el-timeline-item__node{background-color:var(--el-timeline-node-color);border-color:var(--el-timeline-node-color);box-sizing:border-box;border-radius:50%;justify-content:center;align-items:center;display:flex;position:absolute}.el-timeline-item__node--normal{width:var(--el-timeline-node-size-normal);height:var(--el-timeline-node-size-normal)}.el-timeline-item__node--large{width:var(--el-timeline-node-size-large);height:var(--el-timeline-node-size-large)}.el-timeline-item__node.is-hollow{background:var(--el-color-white);border-style:solid;border-width:2px}.el-timeline-item__node--primary{background-color:var(--el-color-primary);border-color:var(--el-color-primary)}.el-timeline-item__node--success{background-color:var(--el-color-success);border-color:var(--el-color-success)}.el-timeline-item__node--warning{background-color:var(--el-color-warning);border-color:var(--el-color-warning)}.el-timeline-item__node--danger{background-color:var(--el-color-danger);border-color:var(--el-color-danger)}.el-timeline-item__node--info{background-color:var(--el-color-info);border-color:var(--el-color-info)}.el-timeline-item__dot{justify-content:center;align-items:center;display:flex;position:absolute}.el-timeline-item__content{color:var(--el-text-color-primary)}.el-timeline-item__timestamp{color:var(--el-text-color-secondary);line-height:1;font-size:var(--el-font-size-small)}.el-timeline-item__timestamp.is-top{margin-bottom:8px;padding-top:4px}.el-timeline-item__timestamp.is-bottom{margin-top:8px}.el-timeline-item.is-start .el-timeline-item__wrapper{padding-left:28px}.el-timeline-item.is-start .el-timeline-item__tail{left:4px}.el-timeline-item.is-start .el-timeline-item__node--normal{left:-1px}.el-timeline-item.is-start .el-timeline-item__node--large{left:-2px}.el-timeline-item.is-end .el-timeline-item__wrapper{text-align:right;padding-right:28px}.el-timeline-item.is-end .el-timeline-item__tail{right:4px}.el-timeline-item.is-end .el-timeline-item__node--normal{right:-1px}.el-timeline-item.is-end .el-timeline-item__node--large{right:-2px}.el-timeline-item.is-alternate .el-timeline-item__tail,.el-timeline-item.is-alternate .el-timeline-item__node,.el-timeline-item.is-alternate-reverse .el-timeline-item__tail,.el-timeline-item.is-alternate-reverse .el-timeline-item__node{left:50%;transform:translate(-50%)}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import{aG as O,bo as T,b1 as V,aA as m,bB as a,bb as B,b3 as d,aF as o,aa as S,bm as _,ab as A,az as p,bj as r,ad as M,aE as i,S as P,s as $,ae as D,aC as N,bd as F,af as G,ai as L,ba as U,av as j,Y as f,Z as H,u as R}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{P as W}from"./PermissionGuard-BxagL6-4.js";import{O as b}from"./ops-BCeFEEjY.js";import{l as C,e as Y}from"./ops-CZkWvMAR.js";const Z={style:{display:"flex","align-items":"center",gap:"8px"}},pe=O({__name:"OpsActionPanelView",setup(q){const g=T(),u=U({}),c=B([]),E=j(()=>b.filter(n=>g.hasOp(n.code)));async function x(n){const e=u[n.code];if(!e&&n.target!=="note"){f.warning("请填写目标 ID");return}if(n.needConfirm)try{await H.confirm(`确认执行 [${n.label}]?
|
||||||
|
目标:${e}`,"二次确认",{type:"warning"})}catch{return}try{const l=await Y({opCode:n.code,targetId:e});f.success(`成功,auditId=${l.auditId}`),c.value=await C()}catch(l){f.error(`失败:${l instanceof Error?l.message:String(l)}`)}}return V(async()=>{c.value=await C()}),(n,e)=>{const l=M,s=A,h=P,k=$,v=S,w=R,I=G,z=D;return d(),m(W,{"widget-id":"OpsActionPanel"},{default:a(()=>[o(w,{shadow:"never"},{header:a(()=>[p("div",Z,[e[1]||(e[1]=p("span",null,"运维操作(架构 §5.1 白名单)",-1)),o(l,{size:"small",type:"info"},{default:a(()=>[...e[0]||(e[0]=[i("scope=RCSMonitor",-1)])]),_:1}),o(l,{size:"small",type:"success"},{default:a(()=>[i(r(E.value.length)+" / "+r(_(b).length)+" 可用",1)]),_:1})])]),default:a(()=>[o(v,{data:_(b),size:"small",border:""},{default:a(()=>[o(s,{prop:"code",label:"权限码",width:"200"},{default:a(t=>[p("code",null,r(t.row.code),1)]),_:1}),o(s,{prop:"label",label:"操作",width:"140"}),o(s,{prop:"target",label:"目标",width:"80"}),o(s,{prop:"needConfirm",label:"二次确认",width:"100"},{default:a(t=>[t.row.needConfirm?(d(),m(l,{key:0,size:"small",type:"warning"},{default:a(()=>[...e[2]||(e[2]=[i("是",-1)])]),_:1})):(d(),m(l,{key:1,size:"small",effect:"plain"},{default:a(()=>[...e[3]||(e[3]=[i("否",-1)])]),_:1}))]),_:1}),o(s,{prop:"description",label:"说明"}),o(s,{label:"操作",width:"200"},{default:a(t=>[o(h,{modelValue:u[t.row.code],"onUpdate:modelValue":y=>u[t.row.code]=y,placeholder:"目标 ID",size:"small",style:{width:"100px","margin-right":"6px"}},null,8,["modelValue","onUpdate:modelValue"]),o(k,{size:"small",type:t.row.needConfirm?"warning":"primary",disabled:!_(g).hasOp(t.row.code),onClick:y=>x(t.row)},{default:a(()=>[...e[4]||(e[4]=[i("执行",-1)])]),_:1},8,["type","disabled","onClick"])]),_:1})]),_:1},8,["data"])]),_:1}),o(w,{shadow:"never",style:{"margin-top":"12px"}},{header:a(()=>[...e[5]||(e[5]=[p("span",null,"本地操作记录(占位 Mock)",-1)])]),default:a(()=>[o(z,null,{default:a(()=>[(d(!0),N(L,null,F(c.value,t=>(d(),m(I,{key:t.id,timestamp:t.ts,type:t.result==="ok"?"success":"danger"},{default:a(()=>[p("strong",null,r(t.opCode),1),i(" → "+r(t.target)+"("+r(t.user)+") ",1)]),_:2},1032,["timestamp","type"]))),128))]),_:1})]),_:1})]),_:1})}}});export{pe as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.cfg-check-grid[data-v-e66d9ef7]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 10px}.type-actions[data-v-e66d9ef7]{width:100%}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as i,bo as n,be as o,aC as l,az as u,aB as c,av as m,b3 as p,al as v}from"./index-DrvLDslf.js";const f=["title"],g=i({__name:"PermissionGuard",props:{widgetId:{}},setup(e){const d=e,r=n(),t=m(()=>r.widgetOf(d.widgetId));return(s,a)=>t.value==="interactive"?o(s.$slots,"default",{key:0},void 0,!0):t.value==="readonly"?(p(),l("div",{key:1,class:"pg-readonly",title:`${e.widgetId} 当前为只读`},[o(s.$slots,"default",{},void 0,!0),a[0]||(a[0]=u("div",{class:"pg-mask"},null,-1))],8,f)):c("",!0)}}),k=v(g,[["__scopeId","data-v-3220f2f7"]]);export{k as P};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.pg-readonly[data-v-3220f2f7]{position:relative}.pg-mask[data-v-3220f2f7]{position:absolute;inset:0;background:#ffffff4d;pointer-events:all;cursor:not-allowed}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as c,aA as y,bB as a,u as w,b3 as E,aF as e,x as V,S as k,C as x,s as B,aE as n,bm as g,bx as z,ab as N,az as P,bj as v,ba as C,bb as S,a2 as A,aa as D,I}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const K=30,X=c({__name:"PlaybackView",setup(T){const s=C({kw:"",range:null}),i=S([{id:"SNAP-001",ts:"2026-05-19 10:21:33",type:"调度异常",summary:"AGV-005 上线超时 → 故障",size:"128 KB"},{id:"SNAP-002",ts:"2026-05-19 14:05:17",type:"路口死锁",summary:"路口-N 等待链 3 节点 30s",size:"64 KB"},{id:"SNAP-003",ts:"2026-05-20 09:11:02",type:"手动快照",summary:"用户 admin 触发 SnapshotExport",size:"420 KB"}]);return(G,t)=>{const d=k,r=V,m=x,o=B,u=A,_=I,l=N,b=D,f=w;return E(),y(f,{shadow:"never"},{header:a(()=>[P("span",null,"调度回放(PlaybackPolicy.retentionDays="+v(K)+" 天)")]),default:a(()=>[e(u,{gutter:12},{default:a(()=>[e(r,{span:6},{default:a(()=>[e(d,{modelValue:s.kw,"onUpdate:modelValue":t[0]||(t[0]=p=>s.kw=p),placeholder:"按任务 / 车辆检索",clearable:""},null,8,["modelValue"])]),_:1}),e(r,{span:8},{default:a(()=>[e(m,{modelValue:s.range,"onUpdate:modelValue":t[1]||(t[1]=p=>s.range=p),type:"datetimerange","range-separator":"→","start-placeholder":"开始","end-placeholder":"结束",style:{width:"100%"}},null,8,["modelValue"])]),_:1}),e(r,{span:10},{default:a(()=>[e(o,{type:"primary"},{default:a(()=>[...t[2]||(t[2]=[n("检索快照",-1)])]),_:1}),e(o,null,{default:a(()=>[...t[3]||(t[3]=[n("下载日志",-1)])]),_:1}),e(o,{icon:g(z),plain:""},{default:a(()=>[...t[4]||(t[4]=[n("回放选中",-1)])]),_:1},8,["icon"])]),_:1})]),_:1}),e(_),e(b,{data:i.value,stripe:""},{default:a(()=>[e(l,{prop:"id",label:"ID",width:"100"}),e(l,{prop:"ts",label:"时间",width:"180"}),e(l,{prop:"type",label:"类型",width:"140"}),e(l,{prop:"summary",label:"概要"}),e(l,{prop:"size",label:"大小",width:"100"}),e(l,{label:"操作",width:"160"},{default:a(()=>[e(o,{text:"",size:"small"},{default:a(()=>[...t[5]||(t[5]=[n("回放",-1)])]),_:1}),e(o,{text:"",size:"small"},{default:a(()=>[...t[6]||(t[6]=[n("下载",-1)])]),_:1})]),_:1})]),_:1},8,["data"])]),_:1})}}});export{X as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{R as o}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as t,aC as r,aF as e,b3 as i,al as p}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"process-page"},s=t({__name:"ProcessPanelView",setup(a){return(n,c)=>(i(),r("div",m,[e(o,{kind:"process","kind-label":"进程",title:"进程管理(Mission / Process)","empty-text":"当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"})]))}}),q=p(s,[["__scopeId","data-v-6a85813e"]]);export{q as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.process-page[data-v-6a85813e]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.fme-root[data-v-708f3ec1]{height:100%;display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box}.fme-header[data-v-708f3ec1]{display:flex;flex-direction:column;gap:10px}.fme-desc[data-v-708f3ec1]{margin:0;color:var(--el-text-color-primary);font-size:13px;line-height:1.65;background:var(--el-fill-color-light);padding:10px 12px;border-left:3px solid var(--el-color-primary);border-radius:4px}.fme-paths code.fme-path[data-v-708f3ec1]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;word-break:break-all;color:var(--el-text-color-primary)}.fme-path-empty[data-v-708f3ec1]{color:var(--el-text-color-secondary);font-size:12px;font-style:italic}.fme-toolbar[data-v-708f3ec1]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.fme-search[data-v-708f3ec1]{width:280px}.fme-btn-text[data-v-708f3ec1]{margin-left:4px}.fme-table-card[data-v-708f3ec1]{flex:1;display:flex;flex-direction:column;overflow:hidden}.fme-table-card[data-v-708f3ec1] .el-card__body{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden}.fme-table[data-v-708f3ec1]{flex:1}.fme-input[data-v-708f3ec1]{width:100%}.fme-locked[data-v-708f3ec1]{color:var(--el-text-color-secondary);font-style:italic}.pp-page[data-v-b5a5a932]{height:calc(100vh - 56px);padding:12px;box-sizing:border-box;display:flex;flex-direction:column}.pp-tabs[data-v-b5a5a932]{flex:1;display:flex;flex-direction:column;overflow:hidden}.pp-tabs[data-v-b5a5a932] .el-tabs__content{flex:1;overflow:hidden;padding:0}.pp-tabs[data-v-b5a5a932] .el-tab-pane{height:100%}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{aG as f,aA as w,bB as t,bm as h,g,b3 as v,aF as e,P as E,a4 as I,_ as k,aa as x,ab as z,T as U,O as V}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const W=f({__name:"RoutingPolicyView",setup(T){function _(r){return Object.entries(r).map(([s,a])=>({k:s,v:a}))}return(r,s)=>{const a=k,u=I,m=E,o=z,d=U,n=x,c=V;return v(),w(C,{section:"routing",title:"路径规划策略",description:"算法选择、权重、避障规则、区域限速(RoutingPolicy)",defaults:h(g)},{default:t(({payload:l,update:p})=>[e(c,{"label-width":"140px",model:l},{default:t(()=>[e(m,{label:"算法"},{default:t(()=>[e(u,{"model-value":l.algorithm,"onUpdate:modelValue":i=>p({...l,algorithm:i})},{default:t(()=>[e(a,{label:"Dijkstra",value:"dijkstra"}),e(a,{label:"A*",value:"astar"}),e(a,{label:"自定义",value:"custom"})]),_:1},8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(m,{label:"权重"},{default:t(()=>[e(n,{data:_(l.weights),size:"small"},{default:t(()=>[e(o,{label:"维度",prop:"k",width:"140"}),e(o,{label:"权重"},{default:t(i=>[e(d,{"model-value":i.row.v,min:0,max:10,step:.1,"onUpdate:modelValue":b=>p({...l,weights:{...l.weights,[i.row.k]:b??0}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["data"])]),_:2},1024),e(m,{label:"避障规则"},{default:t(()=>[e(n,{data:l.avoidance,size:"small"},{default:t(()=>[e(o,{label:"ID",prop:"id",width:"100"}),e(o,{label:"区域 ID",prop:"zoneId",width:"140"}),e(o,{label:"规则",prop:"rule"})]),_:1},8,["data"])]),_:2},1024),e(m,{label:"区域限速"},{default:t(()=>[e(n,{data:l.zoneSpeedLimits,size:"small"},{default:t(()=>[e(o,{label:"区域",prop:"zoneId",width:"140"}),e(o,{label:"最大速度 (m/s)",prop:"maxSpeedMps"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{W as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.tpl-card[data-v-4faf66a8]{margin-bottom:12px}.tpl-title[data-v-4faf66a8]{font-weight:600;margin-bottom:6px}.tpl-meta[data-v-4faf66a8]{display:flex;gap:6px;margin-bottom:8px}.tpl-baseline[data-v-4faf66a8]{margin:0;font-size:11px;background:#f5f7fa;padding:6px;border-radius:4px;max-height:80px;overflow:auto}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as b,aA as _,bB as e,bm as C,h as E,b3 as i,aF as t,aC as h,bd as v,x as S,u as g,az as c,bj as a,ad as w,aE as o,ai as k,G as D,a2 as P,F as V,I as x,al as B}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const F={class:"tpl-title"},I={class:"tpl-meta"},L={class:"tpl-baseline"},N=b({__name:"ScenarioTemplateView",setup(z){return(A,R)=>{const r=w,d=g,m=S,p=P,f=x,l=D,u=V;return i(),_(T,{section:"scenario",title:"业务场景模板化",description:"SPS / Pack / 环线 / 点对点;DSL/低代码扩展(ScenarioTemplateConfig)",defaults:C(E)},{default:e(({payload:s})=>[t(p,{gutter:12},{default:e(()=>[(i(!0),h(k,null,v(s.templates,n=>(i(),_(m,{key:n.id,span:6},{default:e(()=>[t(d,{shadow:"hover",class:"tpl-card"},{default:e(()=>[c("div",F,a(n.name),1),c("div",I,[t(r,{size:"small"},{default:e(()=>[o(a(n.category),1)]),_:2},1024),t(r,{size:"small",type:"info"},{default:e(()=>[o("v"+a(n.version),1)]),_:2},1024)]),c("pre",L,a(n.baselineJson||"{}"),1)]),_:2},1024)]),_:2},1024))),128))]),_:2},1024),t(f),t(u,{column:2,border:""},{default:e(()=>[t(l,{label:"DSL 启用"},{default:e(()=>[o(a(s.dslPolicy.enabled?"是":"否"),1)]),_:2},1024),t(l,{label:"Schema 版本"},{default:e(()=>[o(a(s.dslPolicy.schemaVersion),1)]),_:2},1024),t(l,{label:"低代码"},{default:e(()=>[o(a(s.lowCode.enabled?"启用":"关闭"),1)]),_:2},1024),t(l,{label:"编辑器"},{default:e(()=>[o(a(s.lowCode.editor),1)]),_:2},1024),t(l,{label:"保留版本"},{default:e(()=>[o(a(s.versionPolicy.keepVersions),1)]),_:2},1024),t(l,{label:"允许回滚"},{default:e(()=>[o(a(s.versionPolicy.allowRollback?"是":"否"),1)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}}),W=B(N,[["__scopeId","data-v-4faf66a8"]]);export{W as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{R as t}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as r,aC as o,aF as i,b3 as e,al as a}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"script-page"},p=r({__name:"ScriptPanelView",setup(s){return(c,n)=>(e(),o("div",m,[i(t,{kind:"script","kind-label":"脚本",title:"脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)","empty-text":"当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,要创建脚本请到「任务编排」页新建 Mission。","disable-create":"","disable-delete":"","show-summary-column":"","summary-label":"车辆","show-status-column":"","status-label":"状态","show-script-actions":""})]))}}),j=a(p,[["__scopeId","data-v-40d1e5dd"]]);export{j as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.script-page[data-v-40d1e5dd]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as w,b1 as E,b2 as k,aC as y,aF as s,bB as e,u as V,bb as x,b3 as z,F as C,G as D,aE as l,bj as f,bm as B,ad as I,az as m,s as M,bq as W,al as N}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const O={class:"status-page mg-content"},K={class:"status-actions"},R=w({__name:"ServiceStatusView",setup(T){const b=W(),_=new Date().toLocaleString("zh-CN"),i=x("00:00:00");let r;const S=Date.now();function u(){const p=Date.now()-S,t=Math.floor(p/1e3),o=String(Math.floor(t/3600)).padStart(2,"0"),a=String(Math.floor(t%3600/60)).padStart(2,"0"),d=String(t%60).padStart(2,"0");i.value=`${o}:${a}:${d}`}E(()=>{u(),r=window.setInterval(u,1e3)}),k(()=>{r&&clearInterval(r)});function v(){b.back()}return(p,t)=>{const o=I,a=D,d=C,n=M,g=V;return z(),y("div",O,[s(g,{shadow:"never",class:"status-card"},{header:e(()=>[t[1]||(t[1]=m("span",null,"SimpleLite Service Status",-1)),s(o,{type:"success",effect:"dark",style:{"margin-left":"8px"}},{default:e(()=>[...t[0]||(t[0]=[l("Web-Enabled (Mock)",-1)])]),_:1})]),default:e(()=>[s(d,{column:2,border:""},{default:e(()=>[s(a,{label:"模式"},{default:e(()=>[...t[2]||(t[2]=[l("Web-Enabled",-1)])]),_:1}),s(a,{label:"启动时间"},{default:e(()=>[l(f(B(_)),1)]),_:1}),s(a,{label:"运行时长"},{default:e(()=>[l(f(i.value),1)]),_:1}),s(a,{label:"节点角色"},{default:e(()=>[s(o,{type:"success"},{default:e(()=>[...t[3]||(t[3]=[l("Active (ROSE)",-1)])]),_:1})]),_:1}),s(a,{label:"WebAPI"},{default:e(()=>[t[5]||(t[5]=l("http://0.0.0.0:7001 ",-1)),s(o,{size:"small"},{default:e(()=>[...t[4]||(t[4]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"WebSocket"},{default:e(()=>[t[7]||(t[7]=l("ws://0.0.0.0:7002 ",-1)),s(o,{size:"small"},{default:e(()=>[...t[6]||(t[6]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"webVRender"},{default:e(()=>[t[9]||(t[9]=l("http://0.0.0.0:8223 ",-1)),s(o,{size:"small",type:"success"},{default:e(()=>[...t[8]||(t[8]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"Platform.Server"},{default:e(()=>[...t[10]||(t[10]=[l(":8080 (pid=12345)",-1)])]),_:1}),s(a,{label:"在线 Vue 客户端"},{default:e(()=>[...t[11]||(t[11]=[l("admin=3, monitor=4",-1)])]),_:1}),s(a,{label:"调度循环 / 任务"},{default:e(()=>[...t[12]||(t[12]=[l("50 Hz · 14 / 32",-1)])]),_:1})]),_:1}),m("div",K,[s(n,null,{default:e(()=>[...t[13]||(t[13]=[l("查看日志",-1)])]),_:1}),s(n,{type:"warning",plain:""},{default:e(()=>[...t[14]||(t[14]=[l("重启 Web",-1)])]),_:1}),s(n,{type:"danger",plain:""},{default:e(()=>[...t[15]||(t[15]=[l("关闭服务",-1)])]),_:1}),s(n,{onClick:v},{default:e(()=>[...t[16]||(t[16]=[l("返回",-1)])]),_:1})])]),_:1})])}}}),c=N(R,[["__scopeId","data-v-ddc086d3"]]);export{c as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.status-page[data-v-ddc086d3]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}.status-card[data-v-ddc086d3]{width:720px}.status-actions[data-v-ddc086d3]{display:flex;gap:8px;margin-top:16px;justify-content:flex-end}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
.theme-customizer[data-v-8a725bd2]{margin-top:8px}.tc-hint[data-v-8a725bd2]{margin:0 0 12px;font-size:12px;color:var(--mg-text-dim, rgba(255, 255, 255, .55));line-height:1.5}.tc-title-row[data-v-8a725bd2]{display:flex;align-items:center;gap:10px;width:100%}.tc-swatch[data-v-8a725bd2]{width:22px;height:22px;border-radius:6px;flex-shrink:0;box-shadow:0 2px 6px #00000059}.tc-name[data-v-8a725bd2]{font-weight:600;color:var(--mg-text-light, #fff)}.tc-panel[data-v-8a725bd2]{padding:4px 8px 12px}.tc-picker-row[data-v-8a725bd2]{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.tc-hex[data-v-8a725bd2]{font-family:ui-monospace,monospace;font-size:12px;color:var(--mg-text-muted, rgba(255, 255, 255, .75))}.tc-field-hint[data-v-8a725bd2]{font-size:11px;color:var(--mg-text-dim, rgba(255, 255, 255, .45))}.tc-actions[data-v-8a725bd2]{display:flex;gap:8px;margin-top:8px;padding-left:100px}.tc-footer[data-v-8a725bd2]{margin-top:16px}.ai-service-card[data-v-b66101c5]{margin:12px 0}.ml-8[data-v-b66101c5]{margin-left:8px}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as p,aA as f,bB as o,bm as c,j as b,b3 as V,aF as e,P as x,a1 as B,a0 as E,aE as n,a8 as U,T as v,O as C}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const O=p({__name:"TaskAllocationView",setup(k){return(w,l)=>{const m=E,d=B,r=x,s=U,i=v,_=C;return V(),f(T,{section:"task",title:"任务分配机制",description:"负载均衡、就近分配、优先级调度(TaskAllocationPolicy)",defaults:c(b)},{default:o(({payload:t,update:u})=>[e(_,{"label-width":"160px",model:t},{default:o(()=>[e(r,{label:"分配模式"},{default:o(()=>[e(d,{"model-value":t.mode,"onUpdate:modelValue":a=>u({...t,mode:a})},{default:o(()=>[e(m,{label:"roundRobin"},{default:o(()=>[...l[0]||(l[0]=[n("轮询",-1)])]),_:1}),e(m,{label:"nearest"},{default:o(()=>[...l[1]||(l[1]=[n("就近",-1)])]),_:1}),e(m,{label:"leastLoad"},{default:o(()=>[...l[2]||(l[2]=[n("最少负载",-1)])]),_:1}),e(m,{label:"custom"},{default:o(()=>[...l[3]||(l[3]=[n("自定义",-1)])]),_:1})]),_:1},8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(r,{label:"启用负载均衡"},{default:o(()=>[e(s,{"model-value":t.loadBalance,"onUpdate:modelValue":a=>u({...t,loadBalance:!!a})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(r,{label:"单车队列上限"},{default:o(()=>[e(i,{"model-value":t.maxQueuePerCar,min:1,max:100,"onUpdate:modelValue":a=>u({...t,maxQueuePerCar:a??0})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{O as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.scene-mgr-page[data-v-ce41515a]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box;display:flex;flex-direction:column}.scene-tabs[data-v-ce41515a]{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as m,b3 as _,aC as k,aF as e,bB as a,a9 as x,ac as g,bb as l,al as T,aA as v}from"./index-DrvLDslf.js";/* empty css *//* empty css */import{R as s}from"./ReflectionManagerPanel-BP1OYef-.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const y={class:"scene-mgr-page"},P=m({__name:"SceneManagerView",setup(f){const r=l("site"),o=l(null),p=l(null),c=l(null);function u(t){(t==="site"?o.value:t==="track"?p.value:t==="special"?c.value:null)?.refresh?.()}return(t,n)=>{const i=x,d=g;return _(),k("div",y,[e(d,{modelValue:r.value,"onUpdate:modelValue":n[0]||(n[0]=b=>r.value=b),type:"border-card",class:"scene-tabs admin-tabs",onTabChange:u},{default:a(()=>[e(i,{label:"站点",name:"site"},{default:a(()=>[e(s,{ref_key:"sitePanelRef",ref:o,kind:"site","kind-label":"站点",title:"站点管理(Site / UISite,含禁用/启用、必空点等动作)","empty-text":"当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"},null,512)]),_:1}),e(i,{label:"路径",name:"track"},{default:a(()=>[e(s,{ref_key:"trackPanelRef",ref:p,kind:"track","kind-label":"路径",title:"路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)","empty-text":"当前没有路径;点击右上角「新建路径」选起止站点添加。"},null,512)]),_:1}),e(i,{label:"装饰物",name:"special"},{default:a(()=>[e(s,{ref_key:"specialPanelRef",ref:c,kind:"special","kind-label":"装饰物",title:"装饰物管理(UI_Image / UI_Text / UI_Model)","empty-text":"当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"},null,512)]),_:1})]),_:1},8,["modelValue"])])}}}),R=T(P,[["__scopeId","data-v-ce41515a"]]),Y=m({__name:"TrackTableView",setup(f){return(r,o)=>(_(),v(R))}});export{Y as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as d,aA as m,bB as e,bm as _,k as c,b3 as f,aF as a,a9 as b,aa as u,ab as w,aE as r,bj as p,ac as I}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const G=d({__name:"TrafficRuleView",setup(h){return(x,D)=>{const t=w,o=u,l=b,n=I;return f(),m(T,{section:"traffic",title:"交通管制规则",description:"路口策略、区域互斥、动态让行(TrafficRule)",defaults:_(c)},{default:e(({payload:i})=>[a(n,{"model-value":"ix"},{default:e(()=>[a(l,{name:"ix",label:"路口策略"},{default:e(()=>[a(o,{data:i.intersections,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"站点 IDs",prop:"siteIds"},{default:e(s=>[r(p(s.row.siteIds.join(", ")),1)]),_:1}),a(t,{label:"模式",prop:"mode",width:"100"})]),_:1},8,["data"])]),_:2},1024),a(l,{name:"mz",label:"区域互斥"},{default:e(()=>[a(o,{data:i.mutex,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"区域 IDs",prop:"zoneIds"},{default:e(s=>[r(p(s.row.zoneIds.join(", ")),1)]),_:1})]),_:1},8,["data"])]),_:2},1024),a(l,{name:"yd",label:"动态让行"},{default:e(()=>[a(o,{data:i.yields,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"From",prop:"from",width:"140"}),a(t,{label:"To",prop:"to",width:"140"}),a(t,{label:"条件",prop:"condition"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{G as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{aG as c,aA as f,bB as t,bm as b,l as V,b3 as v,aF as o,P as U,a5 as h,I as R,aE as d,a8 as B,S as E,T as w,O as T}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";import{C as x}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const z=c({__name:"VehicleMaintenanceView",setup(C){return(I,m)=>{const i=h,a=U,r=R,u=B,p=E,s=w,_=T;return v(),f(x,{section:"vehicle",title:"车辆维护策略",description:"电量阈值、故障上报、自动报修(VehicleMaintenancePolicy)",defaults:b(V)},{default:t(({payload:e,update:n})=>[o(_,{"label-width":"160px",model:e},{default:t(()=>[o(a,{label:"低电量阈值"},{default:t(()=>[o(i,{"model-value":e.lowBatteryThreshold*100,min:0,max:100,"format-tooltip":l=>`${l}%`,"onUpdate:modelValue":l=>n({...e,lowBatteryThreshold:(Number(l)||0)/100})},null,8,["model-value","format-tooltip","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"临界电量阈值"},{default:t(()=>[o(i,{"model-value":e.criticalBatteryThreshold*100,min:0,max:100,"format-tooltip":l=>`${l}%`,"onUpdate:modelValue":l=>n({...e,criticalBatteryThreshold:(Number(l)||0)/100})},null,8,["model-value","format-tooltip","onUpdate:modelValue"])]),_:2},1024),o(r,{"content-position":"left"},{default:t(()=>[...m[0]||(m[0]=[d("故障上报",-1)])]),_:1}),o(a,{label:"启用"},{default:t(()=>[o(u,{"model-value":e.faultReport.enabled,"onUpdate:modelValue":l=>n({...e,faultReport:{...e.faultReport,enabled:!!l}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"接收邮箱"},{default:t(()=>[o(p,{"model-value":e.faultReport.emailTo.join(", "),"onUpdate:modelValue":l=>n({...e,faultReport:{...e.faultReport,emailTo:l.split(/[,\s]+/).filter(Boolean)}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(r,{"content-position":"left"},{default:t(()=>[...m[1]||(m[1]=[d("自动报修",-1)])]),_:1}),o(a,{label:"启用"},{default:t(()=>[o(u,{"model-value":e.autoRepair.enabled,"onUpdate:modelValue":l=>n({...e,autoRepair:{...e.autoRepair,enabled:!!l}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"冷却时间 (秒)"},{default:t(()=>[o(s,{"model-value":e.autoRepair.cooldownSec,min:0,max:86400,"onUpdate:modelValue":l=>n({...e,autoRepair:{...e.autoRepair,cooldownSec:l??0}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{z as default};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user