From 4b7ce6790f15f015c2dfab7931b458afa5577bae Mon Sep 17 00:00:00 2001 From: ArtoriasWu Date: Wed, 24 Jun 2026 16:22:24 +0800 Subject: [PATCH] merge --- Doc/MIGU-API.md | 496 +++++++++++++ .../Controllers/DashboardController.cs | 54 ++ .../Controllers/SimpleFieldController.cs | 85 +++ .../Dashboard/DashboardShortcutCatalog.cs | 99 +++ .../Dashboard/DashboardShortcutService.cs | 146 ++++ .../Dashboard/UserDashboardShortcut.cs | 10 + .../SimpleLiteOpenApiDocumentFilter.cs | 183 +++++ .../SimpleFields/SimpleFieldDateTime.cs | 21 + MiGu.Server/SimpleFields/SimpleFieldModels.cs | 84 +++ .../SimpleFields/SimpleFieldService.cs | 132 ++++ .../src/api/dashboardQuickEntries.ts | 51 ++ .../src/api/simpleField.ts | 31 + .../dashboard/QuickEntryPickerDialog.vue | 211 ++++++ .../src/components/fleet/VehicleHealthRow.vue | 287 ++++++++ .../fleet/VehicleMaintenanceSelect.vue | 105 +++ .../composables/useDashboardQuickEntries.ts | 145 ++++ .../src/composables/useQuickEntryDragSwap.ts | 126 ++++ .../src/composables/useVehicleCardState.ts | 147 ++++ .../useVehicleMaintenanceActions.ts | 54 ++ .../simple-platform-vue/src/config/navMenu.ts | 62 ++ .../src/config/quickEntries.ts | 110 +++ .../src/types/simpleField.ts | 89 +++ .../simple-platform-vue/src/utils/vrender.ts | 11 + .../views/admin/SimpleFieldManagementView.vue | 682 ++++++++++++++++++ 24 files changed, 3421 insertions(+) create mode 100644 Doc/MIGU-API.md create mode 100644 MiGu.Server/Controllers/DashboardController.cs create mode 100644 MiGu.Server/Controllers/SimpleFieldController.cs create mode 100644 MiGu.Server/Dashboard/DashboardShortcutCatalog.cs create mode 100644 MiGu.Server/Dashboard/DashboardShortcutService.cs create mode 100644 MiGu.Server/Dashboard/UserDashboardShortcut.cs create mode 100644 MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs create mode 100644 MiGu.Server/SimpleFields/SimpleFieldDateTime.cs create mode 100644 MiGu.Server/SimpleFields/SimpleFieldModels.cs create mode 100644 MiGu.Server/SimpleFields/SimpleFieldService.cs create mode 100644 frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts create mode 100644 frontends/apps/simple-platform-vue/src/api/simpleField.ts create mode 100644 frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue create mode 100644 frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts create mode 100644 frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts create mode 100644 frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts create mode 100644 frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts create mode 100644 frontends/apps/simple-platform-vue/src/config/navMenu.ts create mode 100644 frontends/apps/simple-platform-vue/src/config/quickEntries.ts create mode 100644 frontends/apps/simple-platform-vue/src/types/simpleField.ts create mode 100644 frontends/apps/simple-platform-vue/src/utils/vrender.ts create mode 100644 frontends/apps/simple-platform-vue/src/views/admin/SimpleFieldManagementView.vue diff --git a/Doc/MIGU-API.md b/Doc/MIGU-API.md new file mode 100644 index 0000000..85faa00 --- /dev/null +++ b/Doc/MIGU-API.md @@ -0,0 +1,496 @@ +# SimpleLite 面向咪咕平台数据 API 文档 + +> **版本**:与 SimpleLite 源码同步 +> **更新日期**:2026-06-18 +> **服务栈**:EmbedIO WebApi(非 ASP.NET Core) +> **OpenAPI**:`Docs/openapi/simplelite-projection.json`(已在 MiGu.Server Swagger UI 中展示) + +--- + +## 1. 架构与访问路径 + +SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 SimpleLite。 + +| 层级 | 前缀 | 说明 | +|------|------|------| +| 直连 SimpleLite | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 | +| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 | + +``` +浏览器 / Vue SPA + │ Authorization: Bearer {JWT} + ▼ +MiGu.Server :8080 (/api/sl/*) + │ 追加 X-Platform-Internal-Token + ▼ +SimpleLite :8222 (/projection/*) +``` + +### 1.1 鉴权 + +| 场景 | 要求 | +|------|------| +| 经 MiGu.Server 访问 | 需登录 JWT(`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` | +| 直连 SimpleLite | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` | + +Token 解析优先级(SimpleLite 侧): + +1. `simple.json` → `platform.internalToken` +2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN` +3. `Platform.Server/data/.internal-token` 或 `MiGu.Server/data/.internal-token` + +### 1.2 通用响应格式 + +**投影快照类**(`ProjectionWebApiController`)直接返回 JSON 数组或对象,无统一信封。 + +**反射 / 地图编辑 / 工具栏 / 诊断 / AI 配置类** 使用统一信封: + +```json +{ + "success": true, + "code": 200, + "data": { }, + "message": "Success" +} +``` + +失败时 `success=false`,`code` 为 HTTP 语义码(400/500 等),`message` 为错误说明。 + +### 1.3 Swagger 查看 + +开发环境下启动 MiGu.Server 后访问: + +- **统一文档**:`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi) +- SimpleLite 相关接口标签前缀为 `SimpleLite/` + +--- + +## 2. 实时数据流(SSE) + +| 方法 | 路径(MiGu.Server) | 说明 | +|------|---------------------|------| +| GET | `/api/sl/projection/stream` | Server-Sent Events 实时推送 | + +**Content-Type**:`text/event-stream` + +**事件类型**: + +| event | 说明 | +|-------|------| +| `snapshot-tick` | 连接建立时立即推送当前快照 | +| `car-state` | 车辆状态增量 | +| `mission-status` | 任务状态增量 | +| `selection-detail` | 选中对象详情变更 | +| `heartbeat` | 保活(约 15s) | + +地图编辑写操作完成后也会通过此通道广播增量,供多 Tab / 3D 视口同步。 + +--- + +## 3. 投影快照 API + +**前缀**:`/api/sl/projection` +**控制器**:`ProjectionWebApiController.cs` +**用途**:运营监控、工作台列表、选中详情、配送单管理 + +### 3.1 只读快照 + +| 方法 | 路径 | 说明 | 查询参数 | +|------|------|------|----------| +| GET | `/cars` | 全部车辆快照 | — | +| GET | `/missions` | 全部任务快照 | — | +| GET | `/sites` | 全部站点 | — | +| GET | `/tracks` | 全部路径 | — | +| GET | `/deliveries` | 配送单列表 | `includeFinished`(默认 true)、`includeAborted`(默认 true) | +| GET | `/fleet/health` | 车队健康探测 | — | +| GET | `/workbench/{nav}` | 工作台分页列表 | `nav` ∈ map / car / process / scene / script | +| GET | `/selection/detail` | 选中对象详情面板 | `objectKind`/`kind`、`objectId`/`id`、`tab`(默认 properties) | + +**车辆字段示例**(`GET /cars`): + +```json +{ + "id": "C01", + "name": "AGV-1", + "typeName": "SimpleLite.RCS.CarTypes.Car", + "rawId": 1, + "x": 1200.5, + "y": 800.0, + "theta": 1.57, + "batterySoc": 0.85, + "state": "running", + "lastUpdate": "2026-06-09T08:00:00.0000000Z", + "group": "main", + "address": "tcp://192.168.1.10:5000", + "ip": "192.168.1.10", + "onboardUrl": "http://192.168.1.10:8080", + "lstatus": "运行中" +} +``` + +**车辆 state 枚举**:`idle` / `running` / `paused` / `charging` / `fault` / `offline`(由 `lstatus` 中文/英文子串映射) + +**任务 status 枚举**:`queued` / `running` / `paused` / `completed` / `cancelled` / `failed` + +### 3.2 配送单操作 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/deliveries/{deliveryId}/cancel` | 取消配送单 | +| POST | `/deliveries/{deliveryId}/resend` | 重发配送单 | +| POST | `/deliveries/{deliveryId}/force-complete` | 强制完成 | + +成功返回 `{ "success": true, "deliveryId": N }`,失败 HTTP 400。 + +--- + +## 4. 反射式通用 API + +**前缀**:`/api/sl/projection/reflection` +**控制器**:`ReflectionApiController.cs` +**用途**:工作台动态渲染、字段读写、方法执行、插件管理、项目/应用配置 + +### 4.1 元数据与列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/kinds` | 顶级分类 KPI(map/car/process/scene/script)及 subKinds | +| GET | `/assemblies` | 已加载用户程序集列表 | +| GET | `/objects/{kind}` | 对象列表;`scene` 为 site+track+special 合成 | +| GET | `/types/{kind}` | 可实例化的 .NET 类型列表 | +| GET | `/methods/{kind}/{id}` | 单对象可执行方法表 | +| GET | `/methods-by-type/{kind}` | 按类型聚合的方法表 | +| GET | `/status/{kind}/{id}` | 对象运行状态键值 | +| GET | `/fields/{kind}/{id}` | 用户字段 + 成员字段 | +| GET | `/bundle/{kind}/{id}` | 对象 + 字段 + 方法 + 状态聚合包 | + +**kind 取值**:`map` / `car` / `mission` / `site` / `track` / `special` / `scene`(合成)/ `script` / `process` + +### 4.2 字段读写 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/fields/{kind}/{id}/{field}` | 写入字段(body: `{ "value": "..." }`) | +| DELETE | `/fields/{kind}/{id}/{field}` | 删除用户自定义字段 | + +### 4.3 选中态 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/selection` | 当前选中对象 | +| POST | `/selection` | 设置选中(body: `{ "kind", "id" }`) | +| POST | `/selection/clear` | 清除选中 | + +### 4.4 对象 CRUD + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/objects/{kind}` | 创建对象(body 依 kind 类型) | +| DELETE | `/objects/{kind}/{id}` | 删除对象 | + +### 4.5 方法执行 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET / POST | `/execute/{kind}/{id}/{method}` | 调用 `[MethodMember]` 装饰的方法;POST body 为参数 JSON 数组 | +| POST | `/car/{id}/goto-site` | 车辆前往站点(body: `{ "siteId": N }`) | +| POST | `/car/{id}/gotosite` | 同上(别名路由) | + +### 4.6 脚本 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/scripts/{id}/source` | 脚本源码 | +| GET | `/scripts/{id}/exception-status` | 脚本异常状态 | + +### 4.7 插件管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/plugins` | 已加载插件列表 | +| POST | `/plugins/reload` | 重新扫描并增量加载 | +| POST | `/plugins/{name}/unload` | 卸载指定插件(需重启才彻底移除) | + +### 4.8 项目 / 应用 / 监控配置 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/project/fields` | 项目级字段 | +| POST | `/project/fields/{field}` | 写入项目字段 | +| POST | `/project/save` | 保存项目到磁盘 | +| GET | `/app-config/fields` | 应用配置字段 | +| POST | `/app-config/fields/{field}` | 写入应用配置字段 | +| POST | `/app-config/save` | 保存应用配置 | +| GET / POST | `/monitor-config` | 监控端配置读写 | + +### 4.9 视口与车辆样式 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET / PATCH | `/viewport-style` | 3D 视口样式 | +| GET | `/car-style/types` | 车辆类型样式列表 | +| GET | `/car-types/coder-fields` | 所有插件车型的 Coder Fields 字段袋定义(Site/Track/Plan/Car) | +| GET / POST / DELETE | `/car-style/{typeFullName}` | 单类型样式 CRUD | +| GET / POST | `/car-style/alarm-colors` | 告警颜色配置 | +| POST | `/car-style/save` | 持久化样式到磁盘 | + +### 4.10 车型 Coder Fields + +**路径**:`GET /api/sl/projection/reflection/car-types/coder-fields` + +返回所有已加载插件中带 `[CarType]` 的车型,及其脚本 Coder 使用的 **SiteFields / TrackFields / PlanFields / CarFields** 字段袋定义。字段来源: + +- 车型类上的 `[TemplateTrackCoderSettings]` / `[TemplateSiteCoderSettings]` +- `[ProgramTrackCoderSettings]` 关联的 `ITrackCoder`(如 `CommonTemplateTrackCoder`、`MagneticTrackCoder`) + +**响应示例**(`ReflectionEnvelope` 信封): + +```json +{ + "success": true, + "code": 200, + "message": "Success", + "data": [ + { + "typeName": "StandardScene.CarTypes.Kiva", + "shortName": "Kiva", + "label": "Kiva", + "assemblyName": "StandardScene.QrLidar", + "siteFields": { + "typeName": "StandardScene.CarTypes.KivaSiteFields", + "shortName": "KivaSiteFields", + "assemblyName": "StandardScene.Core", + "baseTypeName": "StandardScene.CarTypes.BasicSiteFields", + "fields": [ + { "name": "Shelf", "typeName": "System.String", "defaultValue": "" }, + { "name": "FetchSpeed", "typeName": "System.Single", "defaultValue": 0 } + ] + }, + "trackFields": { "typeName": "StandardScene.CarTypes.KivaTrackFields", "fields": [] }, + "planFields": { "typeName": "StandardScene.CarTypes.KivaPlanFields", "fields": [] }, + "carFields": null + } + ] +} +``` + +**说明**: + +| 字段 | 含义 | +|------|------| +| `siteFields` / `trackFields` / `planFields` / `carFields` | 该车型对应类别的字段袋;未注册时为 `null` | +| `fields[].name` | 字段名(对应 `site.fields` / `track.fields` 字典键) | +| `fields[].typeName` | .NET 字段类型全名 | +| `fields[].defaultValue` | 字段袋类声明时的默认值 | + +同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields` 的 `IOArea`、`Speed` 等基类字段)。 + +> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("SimpleLite")` 向 SimpleLite 开放。**插件在 collectible ALC 中加载时**,SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。 + +--- + +## 5. 地图编辑 API + +**前缀**:`/api/sl/projection/map-edit` +**控制器**:`MapEditApiController.cs` +**用途**:平台地图设计器(创建/删除图元、拾取、工程/地图管理、AI 生图、录制回放) + +### 5.1 对象操作 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/objects/{kind}` | 创建图元;kind ∈ site/track/bezier/arc/nurbs/image/text/model | +| DELETE | `/objects/{kind}/{id}` | 删除图元 | +| POST | `/objects/batch` | 批量创建/更新/删除 | +| POST | `/objects/{kind}/{id}/fields/copy-to` | 字段复制到另一对象 | +| POST | `/objects/track/{id}/sample-sites` | 路径自动采样站点 | +| POST | `/pick` | 拾取会话(canvas 点击返回坐标与命中对象) | + +### 5.2 仪表盘与资产 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/dashboard/summary` | 地图编辑 KPI 聚合 | +| POST | `/assets/upload` | 上传图片/模型资产 | + +### 5.3 工程管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/project/current` | 当前打开的工程信息 | +| POST | `/project/save` | 保存工程 | +| POST | `/project/load` | 加载工程 | +| GET | `/project/browse` | 浏览工程目录 | +| POST | `/project/native-pick-open` | 原生文件对话框打开 | +| POST | `/project/native-pick-save` | 原生文件对话框保存 | + +### 5.4 地图管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/maps` | 地图列表 | +| GET | `/maps/scene-task-status` | 场景任务状态 | +| POST | `/maps/save` | 保存当前地图 | +| POST | `/maps/open` | 打开地图 | +| POST | `/maps/use` | 切换使用地图 | +| POST | `/maps/rename` | 重命名地图 | +| DELETE | `/maps/{rawName}` | 删除地图 | +| POST | `/maps/merge` | 合并地图 | + +### 5.5 视图 / 图层 / AI / 录制 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET / POST | `/view-filter` | 视图过滤器 | +| GET | `/layers` | 图层列表 | +| POST | `/layers/visible` | 设置图层可见性 | +| POST | `/ai/map-generate` | AI 自然语言生成地图元素 | +| GET | `/recording/status` | 录制状态 | +| POST | `/recording/start` | 开始录制 | +| POST | `/recording/stop` | 停止录制 | +| GET | `/recordings` | 录像文件列表 | +| POST | `/recordings/delete` | 删除录像 | +| POST | `/playback/start` | 开始回放 | +| POST | `/playback/stop` | 停止回放 | + +--- + +## 6. 工作区工具栏 API + +**前缀**:`/api/sl/projection/toolbar` +**控制器**:`WorkspaceToolbarApiController.cs` +**用途**:Embed/CanvasOnly iframe 底栏(对齐、选择、显示、图层、录制、相机) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/state` | 聚合读取全部工具栏状态 | +| POST | `/align` | 对齐吸附开关(sites/cars/tracks) | +| POST | `/select` | 选择过滤(tracks/cars/decor/sites) | +| POST | `/display` | 显示内容(labels/primitives/cars) | +| POST | `/layer` | 图层可见性 | +| POST | `/recording/start` | 开始录制 | +| POST | `/recording/stop` | 停止录制 | +| POST | `/recording/rename` | 重命名录像 | +| DELETE | `/recording/{fileName}` | 删除录像 | +| POST | `/playback/start` | 开始回放 | +| POST | `/playback/stop` | 停止回放 | +| POST | `/view/toggle` | 切换视图模式 | +| POST | `/camera/follow` | 相机跟随车辆 | +| POST | `/camera/locate` | 相机定位到坐标/对象 | + +--- + +## 7. 场景插件 API(配置向导) + +**前缀**:`/api/sl/projection/scenes` +**控制器**:`SceneApiController.cs` +**用途**:咪咕平台「配置向导」选择性加载导航场景插件 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/available` | plugins 目录下全部场景清单 | +| GET | `/active` | 当前激活集合及已加载画像 | +| POST | `/apply` | 写入 `active-scenes.json` 并增量 reload | + +**POST /apply 请求体**: + +```json +{ + "activeScenes": ["scene-id-1", "scene-id-2"], + "alwaysLoad": ["common-plugin"], + "source": "wizard" +} +``` + +与 MiGu.Server `WizardController` 写配置后调用本接口联动。 + +--- + +## 8. 实时诊断 API + +**前缀**:`/api/sl/projection/diagnosis` +**控制器**:`DiagnosisApiController.cs` +**用途**:平台「日志管理 → 实时诊断」只读查看内核 Diagnosis 内存态 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/all` | 全部诊断条目(合订标签 + 滚动记录) | + +**响应 data 结构**: + +```json +{ + "serverTime": "2026-06-09T16:00:00.000", + "total": 42, + "taggedCount": 5, + "untaggedCount": 37, + "items": [ + { "index": 0, "time": "...", "tag": "Traffic", "tagged": true, "content": "..." } + ] +} +``` + +--- + +## 9. AI 服务配置 API + +**前缀**:`/api/sl/projection/ai-config` +**控制器**:`AiConfigController.cs` +**用途**:平台「系统配置 → AI 服务」表单读写 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 读取配置(apiKey 脱敏为 `***last4`) | +| POST | `/` | 保存配置(脱敏占位符不覆盖原密钥) | + +**配置字段**:`endpoint`、`apiKey`、`model`、`systemPrompt`、`temperature`、`maxTokens`、`timeoutSec` + +--- + +## 10. 已下线接口 + +**前缀**:`/api/sl/projection/persistence/*` +**状态**:已在 `ProjectionWebHost` 中取消挂载,调用返回 **404** + +| 原路径 | 说明 | +|--------|------| +| POST `/export-json` | 导出 JSON | +| POST `/import-json` | 导入 JSON | +| POST `/reload-from-db` | 从 DB 重载 | +| POST `/resume-missions` | 恢复任务 | +| GET `/status` | 持久化状态 | +| GET / PATCH `/scene-meta` | 场景元数据 | + +--- + +## 11. 前端 API 胶水对照 + +咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`: + +| 文件 | 对应 SimpleLite 前缀 | +|------|----------------------| +| `reflection.ts` | `/api/sl/projection/reflection` | +| `mapEdit.ts` | `/api/sl/projection/map-edit` | +| `workspaceToolbar.ts` | `/api/sl/projection/toolbar` | +| `logs.ts` | `/api/sl/projection/diagnosis` | + +--- + +## 12. 端口与部署速查 + +| 服务 | 默认端口 | 路径 | +|------|----------|------| +| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite | +| SimpleLite 投影 API | 8222 | `/projection/*` | +| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API) | + +**健康检查**:`GET http://localhost:8080/api/health/simplelite` + +**本地联调**: + +```powershell +# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起) +# 2. 启动 MiGu.Server +# 3. 浏览器打开 http://localhost:8080/swagger +# 4. 授权 Bearer token 后测试 /api/sl/projection/cars +``` diff --git a/MiGu.Server/Controllers/DashboardController.cs b/MiGu.Server/Controllers/DashboardController.cs new file mode 100644 index 0000000..0b6d72f --- /dev/null +++ b/MiGu.Server/Controllers/DashboardController.cs @@ -0,0 +1,54 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MiGu.Server.Dashboard; + +namespace MiGu.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/dashboard")] +public class DashboardController : ControllerBase +{ + private readonly DashboardShortcutService _shortcuts; + + public DashboardController(DashboardShortcutService shortcuts) => _shortcuts = shortcuts; + + public sealed record SaveQuickEntriesRequest(List? Keys); + + [HttpGet("quick-entries")] + public async Task GetQuickEntries(CancellationToken ct) + { + var (userId, scope, err) = ResolveSession(); + if (err != null) return err; + + var result = await _shortcuts.GetAsync(userId!, scope!, ct); + return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults }); + } + + [HttpPut("quick-entries")] + public async Task SaveQuickEntries( + [FromBody] SaveQuickEntriesRequest req, CancellationToken ct) + { + var (userId, scope, err) = ResolveSession(); + if (err != null) return err; + + var result = await _shortcuts.SaveAsync(userId!, scope!, req.Keys, ct); + return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults }); + } + + private (string? UserId, string? Scope, IActionResult? Error) ResolveSession() + { + var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + return (null, null, Unauthorized(new { message = "未识别用户" })); + + var scope = User.FindFirstValue("scope"); + if (string.IsNullOrWhiteSpace(scope)) + return (null, null, BadRequest(new { message = "会话缺少 scope" })); + + return (userId, scope, null); + } +} diff --git a/MiGu.Server/Controllers/SimpleFieldController.cs b/MiGu.Server/Controllers/SimpleFieldController.cs new file mode 100644 index 0000000..9f08616 --- /dev/null +++ b/MiGu.Server/Controllers/SimpleFieldController.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using MiGu.Server.SimpleFields; + +namespace MiGu.Server.Controllers; + +/// +/// Simple 字段管理 API:按车型维护 site / track / plan / car 四类反射字段的默认值与多语言名称。 +/// 数据持久化于 platform.dbsimple_fields 表。 +/// +/// 对应前端「字段管理」页(/admin/simple-fields,页面 key admin-simple-fields)。 +/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 全量替换。 +/// +[ApiController] +[Authorize] +[TypeFilter(typeof(SimpleFieldExceptionFilter))] +[Route("api/simple-fields")] +public sealed class SimpleFieldController : ControllerBase +{ + private readonly SimpleFieldService _service; + + public SimpleFieldController(SimpleFieldService service) => _service = service; + + /// + /// 查询字段列表,支持按字段类型、车型与关键字过滤。 + /// 结果按 car_type → field_type → key 排序。 + /// + /// 字段类型,如 siteFieldscarFields。 + /// 车型唯一标识:assemblyName.shortName。 + /// 关键字,匹配 key / car_type / 中英文名 / 其他语言 / 默认值。 + [HttpGet] + public Task> List([FromQuery] string? fieldType, [FromQuery] string? carType, [FromQuery] string? q) => _service.ListAsync(fieldType, carType, q); + + /// + /// 新增单条字段;同车型 + 字段类型下 key 不可重复 + /// + [HttpPost] + public Task Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req); + + /// + /// 按 id 更新单条字段 + /// + [HttpPut("{id:guid}")] + public Task Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id }); + + /// + /// 按 id 删除单条字段 + /// + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await _service.DeleteAsync(id); + + return NoContent(); + } + + /// + /// 批量保存字段 + /// .true 时先清空表再写入(前端「保存」使用此模式)。 + /// 返回实际写入条数 { count }。 + /// + [HttpPost("batch")] + public async Task SaveBatch([FromBody] SimpleFieldBatchRequest req) + { + var count = await _service.SaveBatchAsync(req); + + return Ok(new { count }); + } +} + +/// +/// 将 转为 HTTP 400,响应体 { message } +/// +public sealed class SimpleFieldExceptionFilter : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + if (context.Exception is not SimpleFieldException ex) { return; } + + context.Result = new BadRequestObjectResult(new { message = ex.Message }); + + context.ExceptionHandled = true; + } +} diff --git a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs new file mode 100644 index 0000000..fe38d76 --- /dev/null +++ b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs @@ -0,0 +1,99 @@ +using MiGu.Server.Auth; + +namespace MiGu.Server.Dashboard; + +/// +/// Dashboard 快捷入口 key 白名单。key 与前端 quickEntries.ts 对齐; +/// 用于 RBAC 校验(用户须有权访问对应页面)。 +/// +public sealed record ShortcutDef(string Key, string PageKey, string Scope); + +public static class DashboardShortcutCatalog +{ + /// 旧版快捷 key(别名)→ 菜单 key。保存时归一化,避免与菜单项重复。 + private static readonly Dictionary LegacyKeyAliases = + new(StringComparer.OrdinalIgnoreCase) + { + ["platform-config"] = "admin-map-editor", + ["mission"] = "admin-task-templates", + ["cars"] = "admin-cars", + ["auth"] = "admin-config-system-center", + ["system"] = "admin-config-system-center", + ["ops"] = "admin-config-ops-center", + ["tasks"] = "admin-config-strategy", + }; + + private static readonly ShortcutDef[] PlatformShortcuts = + [ + new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform), + new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform), + new("admin-maps", "admin-maps", PageCatalog.ScopePlatform), + new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform), + new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform), + new("admin-tracks", "admin-tracks", PageCatalog.ScopePlatform), + new("admin-cars", "admin-cars", PageCatalog.ScopePlatform), + new("admin-processes", "admin-processes", PageCatalog.ScopePlatform), + new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform), + new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform), + new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform), + new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform), + new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform), + new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform), + new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform), + new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform), + new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform), + ]; + + private static readonly ShortcutDef[] MonitorShortcuts = + [ + new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor), + new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor), + new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor), + new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor), + new("monitor-notes", "monitor-notes", PageCatalog.ScopeMonitor), + ]; + + private static readonly Dictionary ByKey = + PlatformShortcuts.Concat(MonitorShortcuts) + .ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase); + + public static readonly int MaxKeysPerUser = 16; + + public static readonly IReadOnlyList DefaultPlatformKeys = + [ + "admin-map-editor", + "admin-task-templates", + "admin-cars", + "admin-config-system-center", + "admin-config-ops-center", + "admin-config-strategy" + ]; + + public static readonly IReadOnlyList DefaultMonitorKeys = + ["monitor-vehicle-hub", "monitor-map", "monitor-ops"]; + + private static readonly HashSet ExcludedKeys = + new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" }; + + public static bool IsValidKey(string key) => + !ExcludedKeys.Contains(key) && ByKey.ContainsKey(key); + + public static ShortcutDef? TryGet(string key) => + ByKey.TryGetValue(key, out var def) ? def : null; + + public static IReadOnlyList DefaultKeysForScope(string scope) => + string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase) + ? DefaultMonitorKeys + : DefaultPlatformKeys; + + public static bool KeyMatchesScope(string key, string scope) + { + var def = TryGet(key); + return def != null && string.Equals(def.Scope, scope, StringComparison.OrdinalIgnoreCase); + } + + public static string PageKeyFor(string key) => TryGet(NormalizeKey(key))?.PageKey ?? NormalizeKey(key); + + public static string NormalizeKey(string key) => + LegacyKeyAliases.TryGetValue(key.Trim(), out var canon) ? canon : key.Trim(); +} diff --git a/MiGu.Server/Dashboard/DashboardShortcutService.cs b/MiGu.Server/Dashboard/DashboardShortcutService.cs new file mode 100644 index 0000000..583cdb2 --- /dev/null +++ b/MiGu.Server/Dashboard/DashboardShortcutService.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Auth; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Dashboard; + +public sealed class DashboardShortcutService +{ + private readonly PlatformDbContext _db; + private readonly RbacStore _rbac; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public DashboardShortcutService(PlatformDbContext db, RbacStore rbac) + { + _db = db; + _rbac = rbac; + } + + public sealed record QuickEntriesResult(IReadOnlyList Keys, bool UsingDefaults); + + public async Task GetAsync(string userId, string scope, CancellationToken ct = default) + { + scope = NormalizeScope(scope); + var allowed = AllowedPages(userId, scope); + + var row = await _db.UserDashboardShortcuts + .AsNoTracking() + .FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct); + + if (row == null) + { + var defaults = FilterKeys(DashboardShortcutCatalog.DefaultKeysForScope(scope), scope, allowed); + return new QuickEntriesResult( + defaults.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(), + UsingDefaults: true); + } + + var keys = ParseKeys(row.KeysJson); + var filtered = FilterKeys(keys, scope, allowed) + .Take(DashboardShortcutCatalog.MaxKeysPerUser) + .ToList(); + return new QuickEntriesResult(filtered, UsingDefaults: false); + } + + public async Task SaveAsync( + string userId, string scope, IReadOnlyList? keys, CancellationToken ct = default) + { + scope = NormalizeScope(scope); + var allowed = AllowedPages(userId, scope); + var sanitized = FilterKeys(Deduplicate(keys ?? []), scope, allowed); + if (sanitized.Count > DashboardShortcutCatalog.MaxKeysPerUser) + sanitized = sanitized.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(); + + var row = await _db.UserDashboardShortcuts + .FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct); + + var json = JsonSerializer.Serialize(sanitized, JsonOpts); + var now = DateTimeOffset.UtcNow; + + if (row == null) + { + _db.UserDashboardShortcuts.Add(new UserDashboardShortcut + { + UserId = userId, + Scope = scope, + KeysJson = json, + UpdatedAt = now + }); + } + else + { + row.KeysJson = json; + row.UpdatedAt = now; + } + + await _db.SaveChangesAsync(ct); + return new QuickEntriesResult(sanitized, UsingDefaults: false); + } + + private HashSet AllowedPages(string userId, string scope) + { + var user = _rbac.FindUserById(userId); + if (user == null || !user.Enabled) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + var eff = _rbac.ComputeEffective(user, scope); + return eff.Pages.ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static List FilterKeys( + IEnumerable keys, string scope, HashSet allowedPages) + { + var outKeys = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var raw in keys) + { + var key = DashboardShortcutCatalog.NormalizeKey(raw ?? ""); + if (string.IsNullOrEmpty(key)) continue; + if (!DashboardShortcutCatalog.IsValidKey(key)) continue; + if (!DashboardShortcutCatalog.KeyMatchesScope(key, scope)) continue; + if (seen.Contains(key)) continue; + + var pageKey = DashboardShortcutCatalog.PageKeyFor(key); + if (!allowedPages.Contains(pageKey)) continue; + + seen.Add(key); + outKeys.Add(key); + } + return outKeys; + } + + private static List Deduplicate(IReadOnlyList keys) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var list = new List(); + foreach (var k in keys) + { + if (string.IsNullOrWhiteSpace(k)) continue; + var t = k.Trim(); + if (seen.Add(t)) list.Add(t); + } + return list; + } + + private static List ParseKeys(string json) + { + try + { + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + } + catch + { + return []; + } + } + + private static string NormalizeScope(string scope) => + string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase) + ? PageCatalog.ScopeMonitor + : PageCatalog.ScopePlatform; +} diff --git a/MiGu.Server/Dashboard/UserDashboardShortcut.cs b/MiGu.Server/Dashboard/UserDashboardShortcut.cs new file mode 100644 index 0000000..7d39f3a --- /dev/null +++ b/MiGu.Server/Dashboard/UserDashboardShortcut.cs @@ -0,0 +1,10 @@ +namespace MiGu.Server.Dashboard; + +/// 用户 Dashboard 快捷入口配置(按 user + scope 一行)。 +public sealed class UserDashboardShortcut +{ + public string UserId { get; set; } = ""; + public string Scope { get; set; } = ""; + public string KeysJson { get; set; } = "[]"; + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs b/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs new file mode 100644 index 0000000..07d1468 --- /dev/null +++ b/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace MiGu.Server.OpenApi; + +/// +/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。 +/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json +/// +public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter +{ + private static readonly Dictionary VerbMap = new(StringComparer.OrdinalIgnoreCase) + { + ["get"] = OperationType.Get, + ["post"] = OperationType.Post, + ["put"] = OperationType.Put, + ["patch"] = OperationType.Patch, + ["delete"] = OperationType.Delete, + ["head"] = OperationType.Head, + ["options"] = OperationType.Options, + ["trace"] = OperationType.Trace + }; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var json = TryLoadJson(); + if (json == null) return; + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("tags", out var tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) + { + swaggerDoc.Tags ??= new List(); + foreach (var tag in tagsEl.EnumerateArray()) + { + if (!tag.TryGetProperty("name", out var nameEl)) continue; + var name = nameEl.GetString(); + if (string.IsNullOrEmpty(name) || swaggerDoc.Tags.Any(t => t.Name == name)) continue; + var desc = tag.TryGetProperty("description", out var d) ? d.GetString() : null; + swaggerDoc.Tags.Add(new OpenApiTag { Name = name, Description = desc }); + } + } + + if (!root.TryGetProperty("paths", out var pathsEl)) return; + + foreach (var pathProp in pathsEl.EnumerateObject()) + { + var fullPath = pathProp.Name.StartsWith("/api/sl", StringComparison.Ordinal) + ? pathProp.Name + : "/api/sl" + pathProp.Name; + + var pathItem = new OpenApiPathItem(); + foreach (var opProp in pathProp.Value.EnumerateObject()) + { + if (!VerbMap.TryGetValue(opProp.Name, out var verb)) continue; + pathItem.Operations[verb] = ParseOperation(opProp.Value); + } + + if (pathItem.Operations.Count > 0) + swaggerDoc.Paths[fullPath] = pathItem; + } + } + + private static OpenApiOperation ParseOperation(JsonElement el) + { + var op = new OpenApiOperation + { + Summary = el.TryGetProperty("summary", out var s) ? s.GetString() : null, + Description = el.TryGetProperty("description", out var d) ? d.GetString() : null + }; + + if (el.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Array) + { + foreach (var t in tags.EnumerateArray()) + { + var name = t.GetString(); + if (!string.IsNullOrEmpty(name)) + op.Tags.Add(new OpenApiTag { Name = name }); + } + } + + if (el.TryGetProperty("parameters", out var parameters) && parameters.ValueKind == JsonValueKind.Array) + { + foreach (var p in parameters.EnumerateArray()) + { + var param = new OpenApiParameter + { + Name = p.TryGetProperty("name", out var n) ? n.GetString() : null, + In = p.TryGetProperty("in", out var loc) ? ParameterLocationFrom(loc.GetString()) : null, + Required = p.TryGetProperty("required", out var req) && req.GetBoolean(), + Description = p.TryGetProperty("description", out var pd) ? pd.GetString() : null + }; + if (p.TryGetProperty("schema", out var schema)) + param.Schema = ParseSchema(schema); + op.Parameters.Add(param); + } + } + + if (el.TryGetProperty("requestBody", out var body)) + op.RequestBody = ParseRequestBody(body); + + if (el.TryGetProperty("responses", out var responses)) + { + foreach (var resp in responses.EnumerateObject()) + { + op.Responses[resp.Name] = new OpenApiResponse + { + Description = resp.Value.TryGetProperty("description", out var rd) + ? rd.GetString() ?? "" + : "" + }; + } + } + + return op; + } + + private static OpenApiRequestBody? ParseRequestBody(JsonElement el) + { + if (!el.TryGetProperty("content", out var content)) return null; + var body = new OpenApiRequestBody(); + foreach (var ct in content.EnumerateObject()) + { + var media = new OpenApiMediaType(); + if (ct.Value.TryGetProperty("schema", out var schema)) + media.Schema = ParseSchema(schema); + body.Content[ct.Name] = media; + } + return body.Content.Count > 0 ? body : null; + } + + private static OpenApiSchema ParseSchema(JsonElement el) + { + var schema = new OpenApiSchema(); + if (el.TryGetProperty("type", out var t)) schema.Type = t.GetString(); + if (el.TryGetProperty("description", out var d)) schema.Description = d.GetString(); + if (el.TryGetProperty("$ref", out var r)) + { + var refId = r.GetString()?.TrimStart('#', '/'); + if (!string.IsNullOrEmpty(refId)) + schema.Reference = new OpenApiReference { Id = refId, Type = ReferenceType.Schema }; + } + return schema; + } + + private static ParameterLocation? ParameterLocationFrom(string? loc) => loc switch + { + "query" => ParameterLocation.Query, + "path" => ParameterLocation.Path, + "header" => ParameterLocation.Header, + "cookie" => ParameterLocation.Cookie, + _ => null + }; + + private static string? TryLoadJson() + { + foreach (var candidate in ResolveCandidatePaths()) + { + try + { + if (File.Exists(candidate)) + return File.ReadAllText(candidate); + } + catch { /* next */ } + } + return null; + } + + private static IEnumerable ResolveCandidatePaths() + { + var roots = new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() } + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var root in roots) + { + yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json")); + yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json")); + yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json")); + } + } +} diff --git a/MiGu.Server/SimpleFields/SimpleFieldDateTime.cs b/MiGu.Server/SimpleFields/SimpleFieldDateTime.cs new file mode 100644 index 0000000..1ab0bd1 --- /dev/null +++ b/MiGu.Server/SimpleFields/SimpleFieldDateTime.cs @@ -0,0 +1,21 @@ +using System.Globalization; + +namespace MiGu.Server.SimpleFields; + +public static class SimpleFieldDateTime +{ + public const string StorageFormat = "yyyy-MM-dd HH:mm:ss"; + + public static DateTimeOffset Now => DateTimeOffset.Now; + + public static string ToStorage(DateTimeOffset value) => + value.LocalDateTime.ToString(StorageFormat, CultureInfo.InvariantCulture); + + public static DateTimeOffset FromStorage(string value) + { + if (DateTime.TryParseExact(value, StorageFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var local)) + return new DateTimeOffset(local); + + return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture); + } +} diff --git a/MiGu.Server/SimpleFields/SimpleFieldModels.cs b/MiGu.Server/SimpleFields/SimpleFieldModels.cs new file mode 100644 index 0000000..1f2f2a5 --- /dev/null +++ b/MiGu.Server/SimpleFields/SimpleFieldModels.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; + +namespace MiGu.Server.SimpleFields; + +public sealed class SimpleField +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// 车型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。 + [MaxLength(64)] + public string CarType { get; set; } = ""; + + /// + /// 字段类型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。 + /// + [MaxLength(64)] + public string FieldType { get; set; } = ""; + + /// + /// 字段唯一标识:key,如 Forklift.PositionX。 + /// + [MaxLength(128)] + public string Key { get; set; } = ""; + + /// + /// 字段值,如 10.0。 + /// + public string Value { get; set; } = ""; + + /// + /// 数据类型,如 System.String。 + /// + [MaxLength(128)] + public string DataType { get; set; } = ""; + + /// + /// 中文名称,如 位置 X。 + /// + [MaxLength(256)] + public string? Chinese { get; set; } + + /// + /// 英文名称,如 Position X。 + /// + [MaxLength(256)] + public string? English { get; set; } + + /// + /// 其他语言名称,如 位置 X。 + /// + [MaxLength(512)] + public string Other { get; set; } = ""; + + /// + /// 是否内置默认字段,如 true。 + /// + public bool IsDefault { get; set; } + + /// + /// 创建时间,如 2021-01-01 12:00:00。 + /// + public DateTimeOffset CreateTime { get; set; } + + /// + /// 更新时间,如 2021-01-01 12:00:00。 + /// + public DateTimeOffset UpdateTime { get; set; } +} + +public sealed record SimpleFieldRequest( + Guid? Id, + string CarType, + string FieldType, + string Key, + string? Value, + string? DataType, + string? Chinese, + string? English, + string? Other, + bool IsDefault); + +public sealed record SimpleFieldBatchRequest( + bool ReplaceAll, + List Items); diff --git a/MiGu.Server/SimpleFields/SimpleFieldService.cs b/MiGu.Server/SimpleFields/SimpleFieldService.cs new file mode 100644 index 0000000..b9a23ea --- /dev/null +++ b/MiGu.Server/SimpleFields/SimpleFieldService.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Persistence; + +namespace MiGu.Server.SimpleFields; + +public sealed class SimpleFieldService +{ + private readonly PlatformDbContext _db; + + public SimpleFieldService(PlatformDbContext db) => _db = db; + + public async Task> ListAsync(string? fieldType = null, string? carType = null, string? q = null) + { + var query = _db.SimpleFields.AsNoTracking() + .OrderBy(x => x.CarType).ThenBy(x => x.FieldType).ThenBy(x => x.Key) + .AsQueryable(); + if (!string.IsNullOrWhiteSpace(fieldType)) + query = query.Where(x => x.FieldType == fieldType); + if (!string.IsNullOrWhiteSpace(carType)) + query = query.Where(x => x.CarType == carType); + if (!string.IsNullOrWhiteSpace(q)) + { + var kw = q.Trim(); + query = query.Where(x => + x.Key.Contains(kw) || + x.CarType.Contains(kw) || + (x.Chinese != null && x.Chinese.Contains(kw)) || + (x.English != null && x.English.Contains(kw)) || + x.Other.Contains(kw) || + x.Value.Contains(kw)); + } + return await query.ToListAsync(); + } + + public async Task SaveAsync(SimpleFieldRequest req) + { + if (string.IsNullOrWhiteSpace(req.CarType)) throw new SimpleFieldException("car_type 不能为空"); + if (string.IsNullOrWhiteSpace(req.FieldType)) throw new SimpleFieldException("field_type 不能为空"); + if (string.IsNullOrWhiteSpace(req.Key)) throw new SimpleFieldException("key 不能为空"); + + var carType = req.CarType.Trim(); + var now = SimpleFieldDateTime.Now; + SimpleField entity; + if (req.Id is { } id && id != Guid.Empty) + { + entity = await _db.SimpleFields.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new SimpleFieldException("记录不存在"); + } + else + { + var dup = await _db.SimpleFields.AnyAsync(x => + x.CarType == carType && + x.FieldType == req.FieldType.Trim() && + x.Key == req.Key.Trim()); + if (dup) throw new SimpleFieldException("同车型与字段类型下 key 已存在"); + + entity = new SimpleField { Id = Guid.NewGuid(), CreateTime = now }; + _db.SimpleFields.Add(entity); + } + + entity.CarType = carType; + entity.FieldType = req.FieldType.Trim(); + entity.Key = req.Key.Trim(); + entity.Value = req.Value?.Trim() ?? ""; + entity.DataType = req.DataType?.Trim() ?? ""; + entity.Chinese = req.Chinese is null ? null : req.Chinese.Trim(); + entity.English = req.English is null ? null : req.English.Trim(); + entity.Other = req.Other?.Trim() ?? ""; + entity.IsDefault = req.IsDefault; + entity.UpdateTime = now; + if (entity.CreateTime == default) entity.CreateTime = now; + + await _db.SaveChangesAsync(); + return entity; + } + + public async Task DeleteAsync(Guid id) + { + var entity = await _db.SimpleFields.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new SimpleFieldException("记录不存在"); + _db.SimpleFields.Remove(entity); + await _db.SaveChangesAsync(); + } + + /// 批量保存全部字段;ReplaceAll=true 时清空表后写入。 + public async Task SaveBatchAsync(SimpleFieldBatchRequest req) + { + var items = req.Items ?? new List(); + if (items.Count == 0) throw new SimpleFieldException("没有可保存的字段"); + + if (req.ReplaceAll) + { + var all = await _db.SimpleFields.ToListAsync(); + _db.SimpleFields.RemoveRange(all); + } + + var now = SimpleFieldDateTime.Now; + var added = 0; + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.CarType) || + string.IsNullOrWhiteSpace(item.FieldType) || + string.IsNullOrWhiteSpace(item.Key)) + continue; + + _db.SimpleFields.Add(new SimpleField + { + Id = Guid.NewGuid(), + CarType = item.CarType.Trim(), + FieldType = item.FieldType.Trim(), + Key = item.Key.Trim(), + Value = item.Value?.Trim() ?? "", + DataType = item.DataType?.Trim() ?? "", + Chinese = item.Chinese is null ? null : item.Chinese.Trim(), + English = item.English is null ? null : item.English.Trim(), + Other = item.Other?.Trim() ?? "", + IsDefault = item.IsDefault, + CreateTime = now, + UpdateTime = now + }); + added++; + } + + await _db.SaveChangesAsync(); + return added; + } +} + +public sealed class SimpleFieldException : Exception +{ + public SimpleFieldException(string message) : base(message) { } +} diff --git a/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts b/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts new file mode 100644 index 0000000..8cec182 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts @@ -0,0 +1,51 @@ +import http from '@/api/http' + +export interface QuickEntriesDto { + keys: string[] + usingDefaults: boolean +} + +const MOCK_STORAGE_KEY = 'simple.mock.dashboard.quickEntries' + +function mockStorageKey(scope: string, userId: string) { + return `${MOCK_STORAGE_KEY}.${scope}.${userId}` +} + +function isMock() { + return import.meta.env.VITE_USE_MOCK === 'true' +} + +function mockLoad(scope: string, userId: string): QuickEntriesDto | null { + try { + const raw = localStorage.getItem(mockStorageKey(scope, userId)) + return raw ? (JSON.parse(raw) as QuickEntriesDto) : null + } catch { + return null + } +} + +function mockSave(scope: string, userId: string, dto: QuickEntriesDto) { + try { + localStorage.setItem(mockStorageKey(scope, userId), JSON.stringify(dto)) + } catch { /* ignore */ } +} + +export async function fetchQuickEntryKeys(userId: string, scope: string): Promise { + if (isMock()) { + return mockLoad(scope, userId) ?? { keys: [], usingDefaults: true } + } + const { data } = await http.get('/dashboard/quick-entries') + return data +} + +export async function saveQuickEntryKeys( + userId: string, scope: string, keys: string[] +): Promise { + if (isMock()) { + const dto: QuickEntriesDto = { keys, usingDefaults: false } + mockSave(scope, userId, dto) + return dto + } + const { data } = await http.put('/dashboard/quick-entries', { keys }) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/api/simpleField.ts b/frontends/apps/simple-platform-vue/src/api/simpleField.ts new file mode 100644 index 0000000..350e4ea --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/simpleField.ts @@ -0,0 +1,31 @@ +import http from './http' +import type { SimpleFieldBatchPayload, SimpleFieldPayload, SimpleFieldRecord } from '@/types/simpleField' + +function q(params?: Record) { + return { params } +} + +export async function listSimpleFields(fieldType?: string, carType?: string, keyword?: string) { + const { data } = await http.get('/simple-fields', q({ + fieldType, + carType, + q: keyword + })) + return data +} + +export async function saveSimpleField(payload: SimpleFieldPayload) { + const { data } = payload.id + ? await http.put(`/simple-fields/${payload.id}`, payload) + : await http.post('/simple-fields', payload) + return data +} + +export async function deleteSimpleField(id: string) { + await http.delete(`/simple-fields/${id}`) +} + +export async function saveSimpleFieldsBatch(payload: SimpleFieldBatchPayload) { + const { data } = await http.post<{ count: number }>('/simple-fields/batch', payload) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue b/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue new file mode 100644 index 0000000..d0576bd --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue @@ -0,0 +1,211 @@ + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue new file mode 100644 index 0000000..c81720e --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleHealthRow.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue new file mode 100644 index 0000000..0e4f226 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/fleet/VehicleMaintenanceSelect.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts b/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts new file mode 100644 index 0000000..c483e69 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useDashboardQuickEntries.ts @@ -0,0 +1,145 @@ +import { computed, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' +import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries' +import { + defaultQuickKeys, getQuickEntryCatalog, MAX_QUICK_ENTRIES, + normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef +} from '@/config/quickEntries' +import { useAuthStore } from '@/stores/auth' +import type { Scope } from '@/types/auth' + +export function useDashboardQuickEntries() { + const auth = useAuthStore() + const keys = ref([]) + const loading = ref(false) + const usingDefaults = ref(true) + const pickerOpen = ref(false) + + const scope = computed(() => auth.scope ?? 'Platform') + const userId = computed(() => auth.user?.id ?? '') + + /** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */ + function effectiveKeys(): string[] { + if (keys.value.length > 0) return keys.value + return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : [] + } + + function filterByPermission(list: string[]): string[] { + return list.filter((key) => { + const def = resolveQuickEntry(key, scope.value as Scope) + return def && auth.hasPage(def.pageKey) + }) + } + + const resolvedEntries = computed(() => { + return filterByPermission(effectiveKeys()) + .slice(0, MAX_QUICK_ENTRIES) + .map((k) => resolveQuickEntry(k, scope.value as Scope)) + .filter((d): d is QuickEntryDef => !!d) + }) + + const pinnedKeys = computed(() => filterByPermission(effectiveKeys())) + + /** 弹窗展示全部可访问菜单(含已固定项,已固定项在弹窗内置灰不可选) */ + const pickerCatalog = computed(() => + getQuickEntryCatalog(scope.value as Scope).filter((item) => auth.hasPage(item.pageKey)) + ) + + const canAddMore = computed(() => pinnedKeys.value.length < MAX_QUICK_ENTRIES) + + const availableToAdd = computed(() => { + const current = new Set(pinnedKeys.value) + return pickerCatalog.value.filter((item) => !current.has(item.key)) + }) + + async function load() { + if (!userId.value) { + keys.value = defaultQuickKeys(scope.value as Scope) + usingDefaults.value = true + return + } + loading.value = true + try { + const dto = await fetchQuickEntryKeys(userId.value, scope.value) + const loaded = normalizeQuickKeys(dto.keys) + keys.value = loaded.length > 0 + ? loaded + : (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : []) + usingDefaults.value = dto.usingDefaults + } catch (e) { + keys.value = defaultQuickKeys(scope.value as Scope) + usingDefaults.value = true + ElMessage.warning(`加载快捷入口失败:${e instanceof Error ? e.message : String(e)}`) + } finally { + loading.value = false + } + } + + async function persist(nextKeys: string[]) { + if (!userId.value) { + keys.value = nextKeys + return + } + try { + const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys)) + keys.value = normalizeQuickKeys(dto.keys) + usingDefaults.value = dto.usingDefaults + } catch (e) { + ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`) + throw e + } + } + + async function addKey(key: string) { + const base = [...effectiveKeys()] + if (base.includes(key)) { + ElMessage.info('该菜单已在快捷入口中') + return + } + if (base.length >= MAX_QUICK_ENTRIES) { + ElMessage.warning(`快捷入口最多 ${MAX_QUICK_ENTRIES} 个`) + return + } + await persist([...base, key]) + ElMessage.success('已添加快捷入口') + } + + async function removeKey(key: string) { + const base = [...effectiveKeys()] + await persist(base.filter((k) => k !== key)) + ElMessage.success('已移除快捷入口') + } + + async function swapKeys(keyA: string, keyB: string) { + if (keyA === keyB || keyA === 'add' || keyB === 'add') return + const list = [...pinnedKeys.value] + const i = list.indexOf(keyA) + const j = list.indexOf(keyB) + if (i < 0 || j < 0 || i === j) return + ;[list[i], list[j]] = [list[j], list[i]] + await persist(list) + } + + function openPicker() { + pickerOpen.value = true + } + + watch([userId, scope], () => { void load() }, { immediate: true }) + + return { + keys, + loading, + usingDefaults, + pickerOpen, + resolvedEntries, + pickerCatalog, + pinnedKeys, + availableToAdd, + canAddMore, + load, + addKey, + removeKey, + swapKeys, + openPicker + } +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts b/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts new file mode 100644 index 0000000..e41e188 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useQuickEntryDragSwap.ts @@ -0,0 +1,126 @@ +import { ref } from 'vue' + +const LONG_PRESS_MS = 450 +const PRE_DRAG_MOVE_PX = 10 + +export interface QuickDragTile { + key: string + label: string + icon: unknown + primary?: boolean +} + +export function useQuickEntryDragSwap( + swapKeys: (keyA: string, keyB: string) => Promise +) { + const dragKey = ref(null) + const hoverTargetKey = ref(null) + const ghostPos = ref({ x: 0, y: 0 }) + + let pressTimer: ReturnType | null = null + let suppressClick = false + let active = false + + function clearPressTimer() { + if (pressTimer) { + clearTimeout(pressTimer) + pressTimer = null + } + } + + function findTargetKey(clientX: number, clientY: number, sourceKey: string): string | null { + const el = document.elementFromPoint(clientX, clientY) + const tile = el?.closest('[data-quick-key]') as HTMLElement | null + const key = tile?.dataset.quickKey + if (!key || key === 'add' || key === sourceKey) return null + return key + } + + function onPointerDown(item: QuickDragTile, e: PointerEvent) { + if (item.key === 'add' || e.button !== 0) return + + const target = e.currentTarget as HTMLElement + const startX = e.clientX + const startY = e.clientY + let dragging = false + + clearPressTimer() + active = true + + const cleanup = () => { + clearPressTimer() + active = false + dragging = false + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + } + + const onMove = (ev: PointerEvent) => { + if (!dragging) { + const dx = ev.clientX - startX + const dy = ev.clientY - startY + if (dx * dx + dy * dy > PRE_DRAG_MOVE_PX * PRE_DRAG_MOVE_PX) { + clearPressTimer() + } + return + } + + ghostPos.value = { x: ev.clientX, y: ev.clientY } + hoverTargetKey.value = findTargetKey(ev.clientX, ev.clientY, item.key) + } + + const onUp = async (ev: PointerEvent) => { + clearPressTimer() + + if (dragging) { + suppressClick = true + const from = item.key + const to = hoverTargetKey.value ?? findTargetKey(ev.clientX, ev.clientY, from) + dragKey.value = null + hoverTargetKey.value = null + if (to) { + try { + await swapKeys(from, to) + } catch { + /* persist failed */ + } + } + } + + cleanup() + } + + pressTimer = setTimeout(() => { + pressTimer = null + dragging = true + suppressClick = false + dragKey.value = item.key + ghostPos.value = { x: e.clientX, y: e.clientY } + hoverTargetKey.value = null + try { + target.setPointerCapture(e.pointerId) + } catch { + /* ignore */ + } + }, LONG_PRESS_MS) + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } + + function shouldSuppressClick(): boolean { + if (!suppressClick) return false + suppressClick = false + return true + } + + return { + dragKey, + hoverTargetKey, + ghostPos, + onPointerDown, + shouldSuppressClick + } +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts new file mode 100644 index 0000000..56ba4ca --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleCardState.ts @@ -0,0 +1,147 @@ +import { computed, toValue, type MaybeRefOrGetter } from 'vue' +import type { VehicleCardModel } from '@/types/car' + +export interface VehicleCardStateOptions { + vehicle: MaybeRefOrGetter +} + +const stateLabels: Record = { + idle: '空闲', + running: '运行', + charging: '充电', + paused: '暂停', + fault: '故障', + offline: '离线' +} + +function formatMissionIdSuffix(missionId?: string | number | null): string { + if (missionId == null) return '' + const id = String(missionId).trim() + if (!id || id === '0') return '' + return `-${id}` +} + +function appendMissionId(base: string, missionId?: string | number | null): string { + const suffix = formatMissionIdSuffix(missionId) + return suffix ? `${base}${suffix}` : base +} + +export function useVehicleCardState(opts: VehicleCardStateOptions) { + const vehicle = computed(() => toValue(opts.vehicle)) + + const stateLabel = computed(() => stateLabels[vehicle.value.state] ?? vehicle.value.state) + + const batteryPct = computed(() => { + const raw = vehicle.value.batterySoc ?? 0 + const pct = raw > 1 ? raw : raw * 100 + return Math.max(0, Math.min(100, Math.round(pct))) + }) + + const batteryTone = computed(() => { + const p = batteryPct.value + if (p < 20) return 'tone-danger' + if (p < 50) return 'tone-warning' + return 'tone-success' + }) + + const switchOn = computed(() => vehicle.value.maintenanceMode === 'online') + + const statusPill = computed(() => { + const v = vehicle.value + if (v.reachable === false) return { label: '不可达', tone: 'tone-danger' } + if (v.isAlarmActive) return { label: '报警中', tone: 'tone-danger' } + if (v.maintenanceMode === 'offline') return { label: '下线维护', tone: 'tone-warning' } + if (v.maintenanceMode === 'repair') return { label: '现场检修', tone: 'tone-warning' } + if (v.maintenanceMode === 'blown') return { label: '返厂检修', tone: 'tone-danger' } + if (v.state === 'fault') return { label: '故障', tone: 'tone-danger' } + if (v.state === 'running') return { label: '运行中', tone: 'tone-success' } + if (v.state === 'charging') return { label: '充电中', tone: 'tone-info' } + if (v.state === 'offline') return { label: '离线', tone: 'tone-idle' } + return { label: stateLabel.value, tone: 'tone-idle' } + }) + + const missionIdSuffix = computed(() => formatMissionIdSuffix(vehicle.value.missionId)) + + const statusDisplayLabel = computed(() => + appendMissionId(statusPill.value.label, vehicle.value.missionId) + ) + + const runtimeStatusLabel = computed(() => + appendMissionId(vehicle.value.lstatus ?? stateLabel.value, vehicle.value.missionId) + ) + + const accentTone = computed(() => statusPill.value.tone) + + const latencyLabel = computed(() => { + const ms = vehicle.value.latencyMs + if (ms == null) return '—' + if (vehicle.value.reachable === false) return '超时' + return `${ms} ms` + }) + + const latencyClass = computed(() => { + const ms = vehicle.value.latencyMs + if (vehicle.value.reachable === false) return 'val-danger' + if (ms != null && ms > 80) return 'val-warn' + return '' + }) + + const faultLabel = computed(() => { + const v = vehicle.value.faultRatePercent + if (v == null) return '—' + return `${v.toFixed(2)}%` + }) + + const faultClass = computed(() => { + const v = vehicle.value.faultRatePercent ?? 0 + if (v >= 5) return 'val-danger' + if (v >= 1) return 'val-warn' + return '' + }) + + const cpuLabel = computed(() => { + const v = vehicle.value.cpuPercent + return v != null ? `${Math.round(v)}%` : '—' + }) + + const memLabel = computed(() => { + const v = vehicle.value.memPercent + return v != null ? `${Math.round(v)}%` : '—' + }) + + const cpuChipClass = computed(() => { + const v = vehicle.value.cpuPercent + if (v == null) return '' + if (v >= 90) return 'val-danger' + if (v >= 75) return 'val-warn' + return 'val-ok' + }) + + const memChipClass = computed(() => { + const v = vehicle.value.memPercent + if (v == null) return '' + if (v >= 90) return 'val-danger' + if (v >= 75) return 'val-warn' + return 'val-ok' + }) + + return { + stateLabel, + batteryPct, + batteryTone, + switchOn, + statusPill, + missionIdSuffix, + statusDisplayLabel, + runtimeStatusLabel, + accentTone, + latencyLabel, + latencyClass, + faultLabel, + faultClass, + cpuLabel, + memLabel, + cpuChipClass, + memChipClass + } +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts b/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts new file mode 100644 index 0000000..f1acbd7 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useVehicleMaintenanceActions.ts @@ -0,0 +1,54 @@ +import { ElMessage, ElMessageBox } from 'element-plus' +import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps' + +export const MAINTENANCE_OPTIONS: { value: VehicleMaintenanceMode; label: string }[] = [ + { value: 'online', label: '上线' }, + { value: 'offline', label: '下线维护' }, + { value: 'repair', label: '现场检修' }, + { value: 'blown', label: '返厂检修' } +] + +export function maintenanceModeLabel(mode?: VehicleMaintenanceMode): string { + return MAINTENANCE_OPTIONS.find((o) => o.value === mode)?.label ?? '上线' +} + +export async function confirmAndApplyMaintenance( + rawId: number | undefined, + mode: VehicleMaintenanceMode, + prevMode: VehicleMaintenanceMode +): Promise { + if (!Number.isFinite(rawId)) return false + if (mode === prevMode) return false + + try { + if (mode === 'blown') { + await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', { + type: 'error', + confirmButtonText: '确定', + cancelButtonText: '取消' + }) + } else if (mode === 'repair') { + await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', { + type: 'warning', + confirmButtonText: '确定', + cancelButtonText: '取消' + }) + } else if (mode === 'online' || mode === 'offline') { + await ElMessageBox.confirm( + mode === 'online' ? '确认将车辆上线?' : '确认将车辆下线维护?', + '维护确认', + { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' } + ) + } + + const ok = await setVehicleMaintenance(rawId!, mode) + if (ok) { + ElMessage.success('维护状态已更新') + return true + } + ElMessage.error('维护操作失败') + return false + } catch { + return false + } +} diff --git a/frontends/apps/simple-platform-vue/src/config/navMenu.ts b/frontends/apps/simple-platform-vue/src/config/navMenu.ts new file mode 100644 index 0000000..1223619 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/config/navMenu.ts @@ -0,0 +1,62 @@ +import { + Collection, Connection, Cpu, Document, DocumentCopy, EditPen, + Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, + Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera +} from '@element-plus/icons-vue' +import type { Component } from 'vue' + +export interface NavMenuItem { + path: string + label: string + icon?: Component + key?: string + group?: string + children?: NavMenuItem[] +} + +export const ADMIN_MENU: NavMenuItem[] = [ + { path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' }, + { path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor', group: '概览' }, + { + path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排', + children: [ + { path: '/admin/maps', label: '地图管理', icon: MapLocation, key: 'admin-maps', group: '设计与编排' }, + { path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' }, + { path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' }, + { path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' }, + { path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' }, + { path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' }, + { path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编排' }, + { path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' }, + { path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' } + ] + }, + { + path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心', + children: [ + { path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' }, + { path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' }, + { path: '/admin/config/facility', label: '设备与库位', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' }, + { path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' }, + { path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' }, + { path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' } + ] + } +] + +export const MONITOR_MENU: NavMenuItem[] = [ + { path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' }, + { path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' }, + { path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' }, + { path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' }, + { path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' } +] + +export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] { + const out: NavMenuItem[] = [] + for (const item of items) { + if (item.children?.length) out.push(...flattenNavMenu(item.children)) + else if (item.key) out.push(item) + } + return out +} diff --git a/frontends/apps/simple-platform-vue/src/config/quickEntries.ts b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts new file mode 100644 index 0000000..4cad02b --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/config/quickEntries.ts @@ -0,0 +1,110 @@ +import { Setting } from '@element-plus/icons-vue' +import type { Component } from 'vue' +import type { Scope } from '@/types/auth' +import { + ADMIN_MENU, MONITOR_MENU, flattenNavMenu, type NavMenuItem +} from '@/config/navMenu' + +export const MAX_QUICK_ENTRIES = 16 +export const QUICK_GRID_COLUMNS = 8 + +export interface QuickEntryDef { + key: string + label: string + path: string + icon: Component + hint?: string + primary?: boolean + pageKey: string + group?: string +} + +/** 当前页即总览,不作为快捷入口候选 */ +const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard']) + +/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */ +const LEGACY_KEY_ALIASES: Record = { + 'platform-config': 'admin-map-editor', + mission: 'admin-task-templates', + cars: 'admin-cars', + auth: 'admin-config-system-center', + system: 'admin-config-system-center', + ops: 'admin-config-ops-center', + tasks: 'admin-config-strategy' +} + +/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */ +export const DEFAULT_PLATFORM_QUICK_KEYS = [ + 'admin-map-editor', + 'admin-task-templates', + 'admin-cars', + 'admin-config-system-center', + 'admin-config-ops-center', + 'admin-config-strategy' +] as const + +export const DEFAULT_MONITOR_QUICK_KEYS = [ + 'monitor-vehicle-hub', 'monitor-map', 'monitor-ops' +] as const + +export function normalizeQuickKey(key: string): string { + return LEGACY_KEY_ALIASES[key] ?? key +} + +export function normalizeQuickKeys(keys: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const raw of keys) { + const k = normalizeQuickKey(raw.trim()) + if (!k || seen.has(k) || EXCLUDED_QUICK_ENTRY_KEYS.has(k)) continue + seen.add(k) + out.push(k) + } + return out +} + +function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null { + if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null + return { + key: item.key, + label: item.label, + path: item.path, + icon: item.icon ?? Setting, + pageKey: item.key, + group: item.group + } +} + +function buildCatalog(scope: Scope): Map { + const map = new Map() + const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU + for (const item of flattenNavMenu(menu)) { + const q = menuItemToQuick(item) + if (q) map.set(q.key, q) + } + return map +} + +export function getQuickEntryCatalog(scope: Scope): QuickEntryDef[] { + return [...buildCatalog(scope).values()] +} + +export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | undefined { + return buildCatalog(scope).get(normalizeQuickKey(key)) +} + +export function defaultQuickKeys(scope: Scope): string[] { + return scope === 'RCSMonitor' + ? [...DEFAULT_MONITOR_QUICK_KEYS] + : [...DEFAULT_PLATFORM_QUICK_KEYS] +} + +export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] { + const groups = new Map() + for (const item of items) { + const g = item.group ?? '其他' + if (!groups.has(g)) groups.set(g, []) + groups.get(g)!.push(item) + } + return [...groups.entries()].map(([group, list]) => ({ group, items: list })) +} diff --git a/frontends/apps/simple-platform-vue/src/types/simpleField.ts b/frontends/apps/simple-platform-vue/src/types/simpleField.ts new file mode 100644 index 0000000..394bd90 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/types/simpleField.ts @@ -0,0 +1,89 @@ +export type SimpleFieldCategory = 'siteFields' | 'trackFields' | 'planFields' | 'carFields' + +export const SIMPLE_FIELD_CATEGORIES: { key: SimpleFieldCategory; label: string }[] = [ + { key: 'siteFields', label: '站点字段 (siteFields)' }, + { key: 'trackFields', label: '路径字段 (trackFields)' }, + { key: 'planFields', label: '计划字段 (planFields)' }, + { key: 'carFields', label: '车辆字段 (carFields)' } +] + +export interface SimpleFieldRecord { + id: string + carType: string + fieldType: string + key: string + value: string + dataType: string + chinese: string | null + english: string | null + other: string + isDefault: boolean + createTime: string + updateTime: string +} + +export interface SimpleFieldPayload { + id?: string + carType: string + fieldType: string + key: string + value?: string + dataType?: string + chinese?: string | null + english?: string | null + other?: string + isDefault: boolean +} + +export interface CoderFieldDef { + name: string + typeName: string + defaultValue: unknown +} + +export interface CoderFieldGroup { + typeName: string + shortName: string + assemblyName: string + baseTypeName?: string + fields: CoderFieldDef[] +} + +export interface CarTypeCoderFields { + typeName: string + shortName: string + label: string + assemblyName: string + siteFields: CoderFieldGroup + trackFields: CoderFieldGroup + planFields: CoderFieldGroup + carFields: CoderFieldGroup +} + +export interface SimpleFieldBatchPayload { + replaceAll: boolean + items: SimpleFieldPayload[] +} + +export interface FieldRow { + rowKey: string + id?: string + carType: string + fieldType: SimpleFieldCategory + key: string + dataType: string + value: string + chinese: string | null + english: string | null + other: string + isDefault: boolean +} + +export const ALL_CAR_TYPES = '__all__' + +/** car_type 唯一标识:assemblyName.shortName */ +export function buildCarType(assemblyName: string, shortName: string): string { + const asm = assemblyName?.trim() ?? '' + const sn = shortName?.trim() ?? '' + return asm && sn ? `${asm}.${sn}` : sn || asm +} diff --git a/frontends/apps/simple-platform-vue/src/utils/vrender.ts b/frontends/apps/simple-platform-vue/src/utils/vrender.ts new file mode 100644 index 0000000..6158bc9 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/utils/vrender.ts @@ -0,0 +1,11 @@ +/** + * webVRender (SimpleLite 3D 视口, 默认 :8223) 的 host 解析。 + * + * 优先级:显式 VITE_VRENDER_HOST > 当前页面 hostname:8223。 + * 不能写死 localhost —— 从远程浏览器访问平台时 iframe 会去连访问者本机而非服务器。 + */ +export function defaultVrHost(): string { + const env = import.meta.env.VITE_VRENDER_HOST as string | undefined + if (env && env.trim()) return env.trim() + return `${window.location.hostname}:8223` +} diff --git a/frontends/apps/simple-platform-vue/src/views/admin/SimpleFieldManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/SimpleFieldManagementView.vue new file mode 100644 index 0000000..ccbbacb --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/SimpleFieldManagementView.vue @@ -0,0 +1,682 @@ + + + + + + +