merge
This commit is contained in:
+496
@@ -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
|
||||||
|
```
|
||||||
@@ -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<string>? Keys);
|
||||||
|
|
||||||
|
[HttpGet("quick-entries")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple 字段管理 API:按车型维护 site / track / plan / car 四类反射字段的默认值与多语言名称。
|
||||||
|
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
|
||||||
|
///
|
||||||
|
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
|
||||||
|
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[TypeFilter(typeof(SimpleFieldExceptionFilter))]
|
||||||
|
[Route("api/simple-fields")]
|
||||||
|
public sealed class SimpleFieldController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SimpleFieldService _service;
|
||||||
|
|
||||||
|
public SimpleFieldController(SimpleFieldService service) => _service = service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询字段列表,支持按字段类型、车型与关键字过滤。
|
||||||
|
/// 结果按 car_type → field_type → key 排序。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fieldType">字段类型,如 <c>siteFields</c>、<c>carFields</c>。</param>
|
||||||
|
/// <param name="carType">车型唯一标识:<c>assemblyName.shortName</c>。</param>
|
||||||
|
/// <param name="q">关键字,匹配 key / car_type / 中英文名 / 其他语言 / 默认值。</param>
|
||||||
|
[HttpGet]
|
||||||
|
public Task<List<SimpleField>> List([FromQuery] string? fieldType, [FromQuery] string? carType, [FromQuery] string? q) => _service.ListAsync(fieldType, carType, q);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按 id 更新单条字段
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按 id 删除单条字段
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await _service.DeleteAsync(id);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量保存字段
|
||||||
|
/// <paramref name="req"/>.<see cref="SimpleFieldBatchRequest.ReplaceAll"/> 为 <c>true</c> 时先清空表再写入(前端「保存」使用此模式)。
|
||||||
|
/// 返回实际写入条数 <c>{ count }</c>。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("batch")]
|
||||||
|
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
||||||
|
{
|
||||||
|
var count = await _service.SaveBatchAsync(req);
|
||||||
|
|
||||||
|
return Ok(new { count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 <see cref="SimpleFieldException"/> 转为 HTTP 400,响应体 <c>{ message }</c>
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using MiGu.Server.Auth;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard 快捷入口 key 白名单。key 与前端 <c>quickEntries.ts</c> 对齐;
|
||||||
|
/// <see cref="PageKey"/> 用于 RBAC 校验(用户须有权访问对应页面)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ShortcutDef(string Key, string PageKey, string Scope);
|
||||||
|
|
||||||
|
public static class DashboardShortcutCatalog
|
||||||
|
{
|
||||||
|
/// <summary>旧版快捷 key(别名)→ 菜单 key。保存时归一化,避免与菜单项重复。</summary>
|
||||||
|
private static readonly Dictionary<string, string> 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<string, ShortcutDef> ByKey =
|
||||||
|
PlatformShortcuts.Concat(MonitorShortcuts)
|
||||||
|
.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static readonly int MaxKeysPerUser = 16;
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<string> DefaultPlatformKeys =
|
||||||
|
[
|
||||||
|
"admin-map-editor",
|
||||||
|
"admin-task-templates",
|
||||||
|
"admin-cars",
|
||||||
|
"admin-config-system-center",
|
||||||
|
"admin-config-ops-center",
|
||||||
|
"admin-config-strategy"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
|
||||||
|
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ExcludedKeys =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
|
||||||
|
|
||||||
|
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<string> 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();
|
||||||
|
}
|
||||||
@@ -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<string> Keys, bool UsingDefaults);
|
||||||
|
|
||||||
|
public async Task<QuickEntriesResult> 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<QuickEntriesResult> SaveAsync(
|
||||||
|
string userId, string scope, IReadOnlyList<string>? 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<string> AllowedPages(string userId, string scope)
|
||||||
|
{
|
||||||
|
var user = _rbac.FindUserById(userId);
|
||||||
|
if (user == null || !user.Enabled)
|
||||||
|
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var eff = _rbac.ComputeEffective(user, scope);
|
||||||
|
return eff.Pages.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> FilterKeys(
|
||||||
|
IEnumerable<string> keys, string scope, HashSet<string> allowedPages)
|
||||||
|
{
|
||||||
|
var outKeys = new List<string>();
|
||||||
|
var seen = new HashSet<string>(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<string> Deduplicate(IReadOnlyList<string> keys)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var list = new List<string>();
|
||||||
|
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<string> ParseKeys(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeScope(string scope) =>
|
||||||
|
string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? PageCatalog.ScopeMonitor
|
||||||
|
: PageCatalog.ScopePlatform;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MiGu.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
|
||||||
|
public sealed class UserDashboardShortcut
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string Scope { get; set; } = "";
|
||||||
|
public string KeysJson { get; set; } = "[]";
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace MiGu.Server.OpenApi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
||||||
|
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, OperationType> 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<OpenApiTag>();
|
||||||
|
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<string> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace MiGu.Server.SimpleFields;
|
||||||
|
|
||||||
|
public sealed class SimpleField
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>车型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。</summary>
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string CarType { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字段类型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string FieldType { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字段唯一标识:key,如 Forklift.PositionX。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字段值,如 10.0。
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据类型,如 System.String。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string DataType { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 中文名称,如 位置 X。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string? Chinese { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 英文名称,如 Position X。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string? English { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 其他语言名称,如 位置 X。
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
public string Other { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否内置默认字段,如 true。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建时间,如 2021-01-01 12:00:00。
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset CreateTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间,如 2021-01-01 12:00:00。
|
||||||
|
/// </summary>
|
||||||
|
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<SimpleFieldRequest> Items);
|
||||||
@@ -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<List<SimpleField>> 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<SimpleField> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>批量保存全部字段;ReplaceAll=true 时清空表后写入。</summary>
|
||||||
|
public async Task<int> SaveBatchAsync(SimpleFieldBatchRequest req)
|
||||||
|
{
|
||||||
|
var items = req.Items ?? new List<SimpleFieldRequest>();
|
||||||
|
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) { }
|
||||||
|
}
|
||||||
@@ -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<QuickEntriesDto> {
|
||||||
|
if (isMock()) {
|
||||||
|
return mockLoad(scope, userId) ?? { keys: [], usingDefaults: true }
|
||||||
|
}
|
||||||
|
const { data } = await http.get<QuickEntriesDto>('/dashboard/quick-entries')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveQuickEntryKeys(
|
||||||
|
userId: string, scope: string, keys: string[]
|
||||||
|
): Promise<QuickEntriesDto> {
|
||||||
|
if (isMock()) {
|
||||||
|
const dto: QuickEntriesDto = { keys, usingDefaults: false }
|
||||||
|
mockSave(scope, userId, dto)
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
const { data } = await http.put<QuickEntriesDto>('/dashboard/quick-entries', { keys })
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import http from './http'
|
||||||
|
import type { SimpleFieldBatchPayload, SimpleFieldPayload, SimpleFieldRecord } from '@/types/simpleField'
|
||||||
|
|
||||||
|
function q(params?: Record<string, unknown>) {
|
||||||
|
return { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSimpleFields(fieldType?: string, carType?: string, keyword?: string) {
|
||||||
|
const { data } = await http.get<SimpleFieldRecord[]>('/simple-fields', q({
|
||||||
|
fieldType,
|
||||||
|
carType,
|
||||||
|
q: keyword
|
||||||
|
}))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSimpleField(payload: SimpleFieldPayload) {
|
||||||
|
const { data } = payload.id
|
||||||
|
? await http.put<SimpleFieldRecord>(`/simple-fields/${payload.id}`, payload)
|
||||||
|
: await http.post<SimpleFieldRecord>('/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
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="添加快捷入口"
|
||||||
|
width="960px"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
class="quick-entry-dialog"
|
||||||
|
@closed="emit('closed')">
|
||||||
|
<p class="qed-desc">
|
||||||
|
从下方选择常用菜单页,固定到总览快捷入口(总览最多 {{ maxCount }} 个,已固定 {{ pinnedCount }} 个)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!items.length" class="qed-empty">暂无可添加的菜单页</div>
|
||||||
|
|
||||||
|
<div v-else class="qed-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="qed-chip"
|
||||||
|
:class="{ 'is-pinned': isPinned(item.key), 'is-disabled': isDisabled(item.key) }"
|
||||||
|
:title="chipTitle(item)"
|
||||||
|
:disabled="isDisabled(item.key)"
|
||||||
|
@click="onPick(item.key)">
|
||||||
|
<span class="qed-chip-icon">
|
||||||
|
<el-icon :size="22"><component :is="item.icon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="qed-chip-label">{{ item.label }}</span>
|
||||||
|
<span v-if="isPinned(item.key)" class="qed-chip-badge">已固定</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { MAX_QUICK_ENTRIES, type QuickEntryDef } from '@/config/quickEntries'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
items: QuickEntryDef[]
|
||||||
|
pinnedKeys?: string[]
|
||||||
|
canAdd?: boolean
|
||||||
|
maxCount?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [boolean]
|
||||||
|
pick: [key: string]
|
||||||
|
closed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const maxCount = computed(() => props.maxCount ?? MAX_QUICK_ENTRIES)
|
||||||
|
const pinnedSet = computed(() => new Set(props.pinnedKeys ?? []))
|
||||||
|
const pinnedCount = computed(() => pinnedSet.value.size)
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v)
|
||||||
|
})
|
||||||
|
|
||||||
|
function isPinned(key: string) {
|
||||||
|
return pinnedSet.value.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDisabled(key: string) {
|
||||||
|
return isPinned(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipTitle(item: QuickEntryDef) {
|
||||||
|
if (isPinned(item.key)) return `${item.label}(已在快捷入口中)`
|
||||||
|
if (props.canAdd === false) return `${item.label}(快捷入口已满,请先移除再添加)`
|
||||||
|
return item.hint ?? item.label
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(key: string) {
|
||||||
|
if (isPinned(key)) return
|
||||||
|
if (props.canAdd === false) {
|
||||||
|
ElMessage.warning(`快捷入口已满(最多 ${maxCount.value} 个),请先移除已有项`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('pick', key)
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qed-desc {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--qed-text-muted, rgba(45, 27, 105, 0.72));
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.qed-empty {
|
||||||
|
padding: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--qed-text-muted, rgba(45, 27, 105, 0.55));
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.qed-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 14px 12px;
|
||||||
|
}
|
||||||
|
.qed-chip {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 8px 12px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--qed-text, #2d1b69);
|
||||||
|
transition: all .22s cubic-bezier(.25, .8, .25, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.qed-chip:hover:not(:disabled) {
|
||||||
|
border-color: rgba(var(--mg-accent-rgb), 0.85);
|
||||||
|
background: #fff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(var(--mg-primary-rgb), 0.22);
|
||||||
|
}
|
||||||
|
.qed-chip.is-pinned,
|
||||||
|
.qed-chip.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
background: rgba(45, 27, 105, 0.04);
|
||||||
|
}
|
||||||
|
.qed-chip-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(var(--mg-primary-hover-rgb), 0.55) 0%,
|
||||||
|
rgba(var(--mg-primary-rgb), 0.42) 100%);
|
||||||
|
border: 1px solid rgba(var(--mg-accent-rgb), 0.45);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(var(--mg-primary-rgb), 0.28),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.18) inset;
|
||||||
|
}
|
||||||
|
.qed-chip-label {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--qed-text, #2d1b69);
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.qed-chip-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
color: rgba(45, 27, 105, 0.72);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.qed-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* el-dialog teleport 到 body,需非 scoped;兼容 fame-lavender 白底弹窗 */
|
||||||
|
.quick-entry-dialog.el-dialog,
|
||||||
|
.quick-entry-dialog .el-dialog {
|
||||||
|
--qed-text: #2d1b69;
|
||||||
|
--qed-text-muted: rgba(45, 27, 105, 0.72);
|
||||||
|
background: #fff !important;
|
||||||
|
border: 1px solid rgba(var(--mg-accent-rgb), 0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 24px 48px rgba(45, 27, 105, 0.18);
|
||||||
|
}
|
||||||
|
.quick-entry-dialog .el-dialog__header {
|
||||||
|
border-bottom: 1px solid rgba(var(--mg-accent-rgb), 0.14);
|
||||||
|
margin-right: 0;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
.quick-entry-dialog .el-dialog__title {
|
||||||
|
color: #2d1b69 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.quick-entry-dialog .el-dialog__headerbtn .el-dialog__close {
|
||||||
|
color: rgba(45, 27, 105, 0.55);
|
||||||
|
}
|
||||||
|
.quick-entry-dialog .el-dialog__body {
|
||||||
|
color: #2d1b69;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="v-row"
|
||||||
|
:class="rowClass"
|
||||||
|
@click="onRowClick"
|
||||||
|
@dblclick="onRowDblClick"
|
||||||
|
>
|
||||||
|
<span class="v-row__accent" :class="accentTone" />
|
||||||
|
|
||||||
|
<div class="v-row__main">
|
||||||
|
<span class="live-dot" :class="accentTone" />
|
||||||
|
<div class="v-row__identity">
|
||||||
|
<span class="name" :title="vehicle.name">{{ vehicle.name }}</span>
|
||||||
|
<span class="id">{{ vehicle.id }}{{ missionIdSuffix }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="mg-pill v-row__status" :class="statusPillClass">{{ statusDisplayLabel }}</span>
|
||||||
|
|
||||||
|
<div class="v-row__battery" :class="batteryTone" :title="`电量 ${batteryPct}%`">
|
||||||
|
<div class="bat-track"><div class="bat-fill" :style="{ width: `${batteryPct}%` }" /></div>
|
||||||
|
<span class="bat-pct">{{ batteryPct }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="v-row__meta mono" :title="vehicle.ip ?? ''">{{ vehicle.ip ?? '—' }}</span>
|
||||||
|
<span class="v-row__meta" :class="latencyClass">{{ latencyLabel }}</span>
|
||||||
|
<span class="v-row__meta" :class="faultClass">{{ faultLabel }}</span>
|
||||||
|
<span class="v-row__meta muted">{{ vehicle.group ?? '—' }}</span>
|
||||||
|
|
||||||
|
<div class="v-row__flags">
|
||||||
|
<span v-if="vehicle.isAlarmActive" class="flag bad">报警</span>
|
||||||
|
<span v-if="vehicle.reachable === false" class="flag bad">不可达</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="v-row__tools" @click.stop>
|
||||||
|
<VehicleMaintenanceSelect
|
||||||
|
:vehicle="vehicle"
|
||||||
|
:can-write="canWrite"
|
||||||
|
@changed="emit('maintenanceChanged')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { VehicleCardModel } from '@/types/car'
|
||||||
|
import { openOnboardWeb } from '@/api/vehicleOps'
|
||||||
|
import {
|
||||||
|
useVehicleCardState,
|
||||||
|
type VehicleCardStateOptions
|
||||||
|
} from '@/composables/useVehicleCardState'
|
||||||
|
import VehicleMaintenanceSelect from '@/components/fleet/VehicleMaintenanceSelect.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
vehicle: VehicleCardModel
|
||||||
|
selected?: boolean
|
||||||
|
canWrite?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
maintenanceChanged: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stateOpts: VehicleCardStateOptions = { vehicle: () => props.vehicle }
|
||||||
|
|
||||||
|
const {
|
||||||
|
stateLabel,
|
||||||
|
batteryPct,
|
||||||
|
batteryTone,
|
||||||
|
statusPill,
|
||||||
|
missionIdSuffix,
|
||||||
|
statusDisplayLabel,
|
||||||
|
accentTone,
|
||||||
|
latencyLabel,
|
||||||
|
latencyClass,
|
||||||
|
faultLabel,
|
||||||
|
faultClass
|
||||||
|
} = useVehicleCardState(stateOpts)
|
||||||
|
|
||||||
|
const rowClass = computed(() => ({
|
||||||
|
'is-selected': props.selected,
|
||||||
|
'is-alarm': props.vehicle.isAlarmActive,
|
||||||
|
'is-unreachable': props.vehicle.reachable === false,
|
||||||
|
[accentTone.value]: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
const statusPillClass = computed(() => {
|
||||||
|
switch (accentTone.value) {
|
||||||
|
case 'tone-success':
|
||||||
|
return 'is-success'
|
||||||
|
case 'tone-danger':
|
||||||
|
return 'is-danger'
|
||||||
|
case 'tone-warning':
|
||||||
|
return 'is-warning'
|
||||||
|
case 'tone-info':
|
||||||
|
return 'is-info'
|
||||||
|
default:
|
||||||
|
return 'is-idle'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onRowClick() {
|
||||||
|
emit('select', props.vehicle.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowDblClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest('.v-row__tools')) return
|
||||||
|
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 12px 8px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(var(--mg-bg-card-rgb, 38, 24, 78), 0.55);
|
||||||
|
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row:hover {
|
||||||
|
background: rgba(var(--mg-bg-card-hi-rgb, 58, 38, 110), 0.65);
|
||||||
|
border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row.is-selected {
|
||||||
|
border-color: rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.75);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__accent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__accent.tone-success { background: var(--mg-status-success, #22c55e); }
|
||||||
|
.v-row__accent.tone-warning { background: var(--mg-status-warning, #f59e0b); }
|
||||||
|
.v-row__accent.tone-danger { background: var(--mg-status-danger, #ef4444); }
|
||||||
|
.v-row__accent.tone-info { background: var(--mg-status-info, #3b82f6); }
|
||||||
|
|
||||||
|
.v-row__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot.tone-success { background: var(--mg-status-success, #22c55e); }
|
||||||
|
.live-dot.tone-danger { background: var(--mg-status-danger, #ef4444); }
|
||||||
|
.live-dot.tone-warning { background: var(--mg-status-warning, #f59e0b); }
|
||||||
|
|
||||||
|
.v-row__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mg-text-light, #fff);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.5));
|
||||||
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__status {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__battery {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bat-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bat-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--mg-status-success, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__battery.tone-warning .bat-fill { background: var(--mg-status-warning, #f59e0b); }
|
||||||
|
.v-row__battery.tone-danger .bat-fill { background: var(--mg-status-danger, #ef4444); }
|
||||||
|
|
||||||
|
.bat-pct {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.65));
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mg-text-light, #fff);
|
||||||
|
min-width: 48px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__meta.mono {
|
||||||
|
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__meta.muted {
|
||||||
|
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__meta.val-danger { color: var(--mg-status-danger, #ef4444); }
|
||||||
|
.v-row__meta.val-warn { color: var(--mg-status-warning, #f59e0b); }
|
||||||
|
|
||||||
|
.v-row__flags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag.bad {
|
||||||
|
color: var(--mg-status-danger, #b91c1c);
|
||||||
|
background: rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.12);
|
||||||
|
border: 1px solid rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-row__tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.v-row__meta.muted,
|
||||||
|
.v-row__flags { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
:model-value="currentMode"
|
||||||
|
size="small"
|
||||||
|
class="veh-maint-select"
|
||||||
|
:class="toneClass"
|
||||||
|
:disabled="!canWrite"
|
||||||
|
:teleported="true"
|
||||||
|
@change="onChange"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in MAINTENANCE_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { VehicleCardModel } from '@/types/car'
|
||||||
|
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||||
|
import {
|
||||||
|
MAINTENANCE_OPTIONS,
|
||||||
|
confirmAndApplyMaintenance
|
||||||
|
} from '@/composables/useVehicleMaintenanceActions'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
vehicle: VehicleCardModel
|
||||||
|
canWrite?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ changed: [] }>()
|
||||||
|
|
||||||
|
const currentMode = ref<VehicleMaintenanceMode>(props.vehicle.maintenanceMode ?? 'online')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.vehicle.maintenanceMode,
|
||||||
|
(m) => {
|
||||||
|
currentMode.value = m ?? 'online'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const toneClass = computed(() => {
|
||||||
|
const m = currentMode.value
|
||||||
|
if (m === 'blown' || m === 'offline') return 'tone-danger'
|
||||||
|
if (m === 'repair') return 'tone-warning'
|
||||||
|
return 'tone-online'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onChange(mode: VehicleMaintenanceMode) {
|
||||||
|
const prev = props.vehicle.maintenanceMode ?? 'online'
|
||||||
|
if (mode === prev) return
|
||||||
|
|
||||||
|
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
|
||||||
|
const ok = await confirmAndApplyMaintenance(rawId, mode, prev)
|
||||||
|
if (ok) {
|
||||||
|
currentMode.value = mode
|
||||||
|
emit('changed')
|
||||||
|
} else {
|
||||||
|
currentMode.value = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.veh-maint-select {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veh-maint-select :deep(.el-select__wrapper) {
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veh-maint-select :deep(.el-select__selected-item),
|
||||||
|
.veh-maint-select :deep(.el-select__placeholder) {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veh-maint-select :deep(.el-select__caret) {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veh-maint-select.tone-online :deep(.el-select__wrapper) {
|
||||||
|
border-color: rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.45);
|
||||||
|
}
|
||||||
|
.veh-maint-select.tone-warning :deep(.el-select__wrapper) {
|
||||||
|
border-color: rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.5);
|
||||||
|
}
|
||||||
|
.veh-maint-select.tone-danger :deep(.el-select__wrapper) {
|
||||||
|
border-color: rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veh-maint-select.is-disabled :deep(.el-select__wrapper) {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<string[]>([])
|
||||||
|
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<QuickEntryDef[]>(() => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>
|
||||||
|
) {
|
||||||
|
const dragKey = ref<string | null>(null)
|
||||||
|
const hoverTargetKey = ref<string | null>(null)
|
||||||
|
const ghostPos = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
let pressTimer: ReturnType<typeof setTimeout> | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||||
|
import type { VehicleCardModel } from '@/types/car'
|
||||||
|
|
||||||
|
export interface VehicleCardStateOptions {
|
||||||
|
vehicle: MaybeRefOrGetter<VehicleCardModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateLabels: Record<string, string> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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<string>()
|
||||||
|
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<string, QuickEntryDef> {
|
||||||
|
const map = new Map<string, QuickEntryDef>()
|
||||||
|
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<string, QuickEntryDef[]>()
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<template>
|
||||||
|
<div class="simple-field-page">
|
||||||
|
<el-card v-loading="initializing" shadow="never" class="page-card" element-loading-text="正在加载字段数据…">
|
||||||
|
<template #header>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>字段管理</h2>
|
||||||
|
<p>「默认」从 SimpleLite 拉取全部车型字段;「刷新」从数据库读取;「保存」将全部字段写入数据库。</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :loading="initializing || loadingDefaults" @click="loadDefaults">默认</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="initializing || !fieldRows.length" @click="saveAll">保存</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="initializing || loading" @click="loadFromDb()">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="car-type-filter">
|
||||||
|
<span class="filter-label">车辆类型:</span>
|
||||||
|
<el-select
|
||||||
|
v-model="filterCarType"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择车型"
|
||||||
|
class="car-type-select bordered-select"
|
||||||
|
popper-class="car-type-option-popper"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="c in carTypes"
|
||||||
|
:key="c.typeName"
|
||||||
|
:label="carTypeSelectLabel(c)"
|
||||||
|
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||||
|
>
|
||||||
|
<span class="car-type-option">
|
||||||
|
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||||
|
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="keyword-filter">
|
||||||
|
<span class="filter-label">搜索:</span>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="当前车型下搜索属性 / key / 中文 / 英文 / 其他语言"
|
||||||
|
clearable
|
||||||
|
class="keyword-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" :disabled="initializing" @click="openDialog()">新增字段</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeCategory" class="field-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||||
|
:key="cat.key"
|
||||||
|
:name="cat.key"
|
||||||
|
:label="cat.label"
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
v-loading="initializing || loading || loadingDefaults"
|
||||||
|
:data="filteredRows"
|
||||||
|
:row-class-name="searchRowClassName"
|
||||||
|
border
|
||||||
|
size="small"
|
||||||
|
height="520"
|
||||||
|
>
|
||||||
|
<el-table-column prop="carType" label="车型 (car_type)" min-width="200" />
|
||||||
|
<el-table-column prop="key" label="属性" min-width="140" sortable />
|
||||||
|
<el-table-column prop="dataType" label="数据类型" min-width="130" sortable />
|
||||||
|
<el-table-column prop="value" label="默认值" min-width="100" />
|
||||||
|
<el-table-column prop="chinese" label="中文名" min-width="100" />
|
||||||
|
<el-table-column prop="english" label="英文名" min-width="100" />
|
||||||
|
<el-table-column prop="other" label="其他语言" min-width="100" />
|
||||||
|
<el-table-column prop="isDefault" label="默认" width="72" sortable :sort-method="sortByIsDefault">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isDefault ? 'info' : 'success'" size="small">{{ row.isDefault ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="removeRow(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
||||||
|
<el-form label-width="96px">
|
||||||
|
<el-form-item label="车型" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.carType"
|
||||||
|
filterable
|
||||||
|
:disabled="!!editingRowKey"
|
||||||
|
class="bordered-select"
|
||||||
|
popper-class="car-type-option-popper"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="c in carTypes"
|
||||||
|
:key="c.typeName"
|
||||||
|
:label="carTypeSelectLabel(c)"
|
||||||
|
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||||
|
>
|
||||||
|
<span class="car-type-option">
|
||||||
|
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||||
|
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="字段类型" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.fieldType"
|
||||||
|
:disabled="!!editingRowKey"
|
||||||
|
class="bordered-select"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||||
|
:key="cat.key"
|
||||||
|
:label="cat.label"
|
||||||
|
:value="cat.key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="属性" required>
|
||||||
|
<el-input v-model="form.key" :disabled="!!editingRowKey" placeholder="属性名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="数据类型" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.dataType"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
:disabled="!!editingRowKey"
|
||||||
|
class="bordered-select"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="t in DATA_TYPE_OPTIONS" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认值" required>
|
||||||
|
<el-input v-model="form.value" placeholder="默认值(字符串存储)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="中文名">
|
||||||
|
<el-input v-model="form.chinese" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="英文名">
|
||||||
|
<el-input v-model="form.english" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="其他语言">
|
||||||
|
<el-input v-model="form.other" placeholder="日语、德语等其他语言名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="editingRowKey" label="是否内置">
|
||||||
|
<el-switch v-model="form.isDefault" class="builtin-switch" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="applyDialog">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
||||||
|
import * as simpleFieldApi from '@/api/simpleField'
|
||||||
|
import {
|
||||||
|
SIMPLE_FIELD_CATEGORIES,
|
||||||
|
buildCarType,
|
||||||
|
type FieldRow,
|
||||||
|
type SimpleFieldCategory,
|
||||||
|
type SimpleFieldRecord
|
||||||
|
} from '@/types/simpleField'
|
||||||
|
|
||||||
|
const DATA_TYPE_OPTIONS = [
|
||||||
|
'System.Boolean',
|
||||||
|
'System.Int32',
|
||||||
|
'System.Single',
|
||||||
|
'System.Double',
|
||||||
|
'System.String'
|
||||||
|
]
|
||||||
|
|
||||||
|
const initializing = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingDefaults = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const carTypes = ref<ReflectionCreatableType[]>([])
|
||||||
|
const fieldRows = ref<FieldRow[]>([])
|
||||||
|
const filterCarType = ref('')
|
||||||
|
const activeCategory = ref<SimpleFieldCategory>('siteFields')
|
||||||
|
const keyword = ref('')
|
||||||
|
const highlightRowKey = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingRowKey = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
carType: '',
|
||||||
|
fieldType: 'siteFields' as SimpleFieldCategory,
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
dataType: 'System.String',
|
||||||
|
chinese: '',
|
||||||
|
english: '',
|
||||||
|
other: '',
|
||||||
|
isDefault: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (editingRowKey.value ? '编辑字段' : '新增字段'))
|
||||||
|
|
||||||
|
const categoryRows = computed(() =>
|
||||||
|
rowsForCurrentCar().filter((r) => r.fieldType === activeCategory.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
function rowsForCurrentCar() {
|
||||||
|
if (!filterCarType.value) return fieldRows.value
|
||||||
|
return fieldRows.value.filter((r) => r.carType === filterCarType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesKeyword(row: FieldRow, kw: string) {
|
||||||
|
return (
|
||||||
|
row.key.toLowerCase().includes(kw) ||
|
||||||
|
(row.chinese ?? '').toLowerCase().includes(kw) ||
|
||||||
|
(row.english ?? '').toLowerCase().includes(kw) ||
|
||||||
|
row.other.toLowerCase().includes(kw) ||
|
||||||
|
row.value.toLowerCase().includes(kw) ||
|
||||||
|
row.dataType.toLowerCase().includes(kw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstSearchMatch(kw: string): FieldRow | undefined {
|
||||||
|
const rows = rowsForCurrentCar()
|
||||||
|
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||||
|
const match = rows.find((r) => r.fieldType === cat.key && rowMatchesKeyword(r, kw))
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchRowClassName({ row }: { row: FieldRow }) {
|
||||||
|
return row.rowKey === highlightRowKey.value ? 'search-hit-row' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByIsDefault(a: FieldRow, b: FieldRow) {
|
||||||
|
return Number(a.isDefault) - Number(b.isDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHighlightedRow() {
|
||||||
|
nextTick(() => {
|
||||||
|
document.querySelector('.field-tabs .search-hit-row')?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateSearchToFirstMatch() {
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
highlightRowKey.value = ''
|
||||||
|
if (!kw) return
|
||||||
|
const first = findFirstSearchMatch(kw)
|
||||||
|
if (!first) return
|
||||||
|
activeCategory.value = first.fieldType
|
||||||
|
highlightRowKey.value = first.rowKey
|
||||||
|
scrollToHighlightedRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([keyword, filterCarType], () => {
|
||||||
|
navigateSearchToFirstMatch()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const rows = categoryRows.value
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return rows
|
||||||
|
return rows.filter((r) => rowMatchesKeyword(r, kw))
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatValue(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
if (typeof v === 'object') return JSON.stringify(v)
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRowKey(carType: string, fieldType: string, key: string) {
|
||||||
|
return `${carType}:${fieldType}:${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordToRow(r: SimpleFieldRecord): FieldRow {
|
||||||
|
return {
|
||||||
|
rowKey: makeRowKey(r.carType, r.fieldType, r.key),
|
||||||
|
id: r.id,
|
||||||
|
carType: r.carType,
|
||||||
|
fieldType: r.fieldType as SimpleFieldCategory,
|
||||||
|
key: r.key,
|
||||||
|
dataType: r.dataType,
|
||||||
|
value: r.value,
|
||||||
|
chinese: r.chinese,
|
||||||
|
english: r.english,
|
||||||
|
other: r.other,
|
||||||
|
isDefault: r.isDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function carTypeToRows(car: CarTypeCoderFieldsRow): FieldRow[] {
|
||||||
|
const carType = buildCarType(car.assemblyName, car.shortName)
|
||||||
|
const rows: FieldRow[] = []
|
||||||
|
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||||
|
const group = car[cat.key]
|
||||||
|
for (const f of group?.fields ?? []) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: makeRowKey(carType, cat.key, f.name),
|
||||||
|
carType,
|
||||||
|
fieldType: cat.key,
|
||||||
|
key: f.name,
|
||||||
|
dataType: f.typeName,
|
||||||
|
value: formatValue(f.defaultValue),
|
||||||
|
chinese: '',
|
||||||
|
english: '',
|
||||||
|
other: '',
|
||||||
|
isDefault: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function allCarsToRows(cars: CarTypeCoderFieldsRow[]): FieldRow[] {
|
||||||
|
return cars.flatMap(carTypeToRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已加载的数据库记录推导车型下拉(不请求 SimpleLite)。 */
|
||||||
|
function syncCarTypesFromFieldRows() {
|
||||||
|
const labelByKey = new Map(
|
||||||
|
carTypes.value.map((c) => [buildCarType(c.assemblyName, c.shortName), c.label])
|
||||||
|
)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const options: ReflectionCreatableType[] = []
|
||||||
|
for (const r of fieldRows.value) {
|
||||||
|
if (seen.has(r.carType)) continue
|
||||||
|
seen.add(r.carType)
|
||||||
|
const carType = r.carType
|
||||||
|
const dot = carType.lastIndexOf('.')
|
||||||
|
const assemblyName = dot >= 0 ? carType.slice(0, dot) : ''
|
||||||
|
const shortName = dot >= 0 ? carType.slice(dot + 1) : carType
|
||||||
|
options.push({
|
||||||
|
typeName: carType,
|
||||||
|
shortName,
|
||||||
|
label: labelByKey.get(carType) || shortName,
|
||||||
|
assemblyName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
carTypes.value = options
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 SimpleLite 补全车型中文名(轻量接口,不拉字段定义)。 */
|
||||||
|
async function enrichCarTypeLabels() {
|
||||||
|
try {
|
||||||
|
const types = await reflectionApi.listCreatableTypes('car')
|
||||||
|
const labelMap = new Map(
|
||||||
|
types.map((t) => [buildCarType(t.assemblyName, t.shortName), t.label || t.shortName])
|
||||||
|
)
|
||||||
|
carTypes.value = carTypes.value.map((c) => {
|
||||||
|
const key = buildCarType(c.assemblyName, c.shortName)
|
||||||
|
const label = labelMap.get(key)
|
||||||
|
return label ? { ...c, label } : c
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* SimpleLite 未连接时保留现有显示 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDefaultCarType() {
|
||||||
|
if (filterCarType.value) return
|
||||||
|
if (carTypes.value.length) {
|
||||||
|
const first = carTypes.value[0]
|
||||||
|
filterCarType.value = buildCarType(first.assemblyName, first.shortName)
|
||||||
|
} else if (fieldRows.value.length) {
|
||||||
|
filterCarType.value = fieldRows.value[0].carType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function carTypeCn(c: ReflectionCreatableType) {
|
||||||
|
if (c.label && c.label !== c.shortName) return c.label
|
||||||
|
return c.label || c.shortName
|
||||||
|
}
|
||||||
|
|
||||||
|
function carTypeSelectLabel(c: ReflectionCreatableType, fullCarType = true) {
|
||||||
|
const en = fullCarType ? buildCarType(c.assemblyName, c.shortName) : c.shortName
|
||||||
|
return `${carTypeCn(c)} (${en})`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromDb(silent = false) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const records = await simpleFieldApi.listSimpleFields()
|
||||||
|
fieldRows.value = records.map(recordToRow)
|
||||||
|
syncCarTypesFromFieldRows()
|
||||||
|
await enrichCarTypeLabels()
|
||||||
|
ensureDefaultCarType()
|
||||||
|
if (!silent) ElMessage.success(`已从数据库加载 ${fieldRows.value.length} 条字段`)
|
||||||
|
} catch (e) {
|
||||||
|
fieldRows.value = []
|
||||||
|
carTypes.value = []
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '从数据库加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDefaults() {
|
||||||
|
loadingDefaults.value = true
|
||||||
|
try {
|
||||||
|
const cars = await reflectionApi.getCarTypeCoderFields()
|
||||||
|
if (!cars.length) {
|
||||||
|
ElMessage.warning('未获取到车型列表,请确认 SimpleLite 已启动')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
carTypes.value = cars.map((c) => ({
|
||||||
|
typeName: c.typeName,
|
||||||
|
shortName: c.shortName,
|
||||||
|
label: c.label,
|
||||||
|
assemblyName: c.assemblyName
|
||||||
|
}))
|
||||||
|
ensureDefaultCarType()
|
||||||
|
fieldRows.value = allCarsToRows(cars)
|
||||||
|
ElMessage.success(`已从 SimpleLite 加载 ${cars.length} 种车型、共 ${fieldRows.value.length} 条默认字段(请点击保存写入数据库)`)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '加载默认字段失败')
|
||||||
|
} finally {
|
||||||
|
loadingDefaults.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAll() {
|
||||||
|
if (!fieldRows.value.length) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const { count } = await simpleFieldApi.saveSimpleFieldsBatch({
|
||||||
|
replaceAll: true,
|
||||||
|
items: fieldRows.value.map((r) => ({
|
||||||
|
carType: r.carType,
|
||||||
|
fieldType: r.fieldType,
|
||||||
|
key: r.key,
|
||||||
|
value: r.value,
|
||||||
|
dataType: r.dataType,
|
||||||
|
chinese: r.chinese ?? '',
|
||||||
|
english: r.english ?? '',
|
||||||
|
other: r.other,
|
||||||
|
isDefault: r.isDefault
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
ElMessage.success(`已保存 ${count} 条字段到数据库`)
|
||||||
|
await loadFromDb(true)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingRowKey.value = ''
|
||||||
|
form.carType = filterCarType.value
|
||||||
|
|| (carTypes.value[0] ? buildCarType(carTypes.value[0].assemblyName, carTypes.value[0].shortName) : '')
|
||||||
|
form.fieldType = activeCategory.value
|
||||||
|
form.key = ''
|
||||||
|
form.value = ''
|
||||||
|
form.dataType = 'System.String'
|
||||||
|
form.chinese = ''
|
||||||
|
form.english = ''
|
||||||
|
form.other = ''
|
||||||
|
form.isDefault = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(row?: FieldRow) {
|
||||||
|
resetForm()
|
||||||
|
if (row) {
|
||||||
|
editingRowKey.value = row.rowKey
|
||||||
|
form.carType = row.carType
|
||||||
|
form.fieldType = row.fieldType
|
||||||
|
form.key = row.key
|
||||||
|
form.value = row.value
|
||||||
|
form.dataType = row.dataType
|
||||||
|
form.chinese = row.chinese ?? ''
|
||||||
|
form.english = row.english ?? ''
|
||||||
|
form.other = row.other
|
||||||
|
form.isDefault = row.isDefault
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDialog() {
|
||||||
|
if (!form.carType.trim()) {
|
||||||
|
ElMessage.warning('请选择车型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.key.trim()) {
|
||||||
|
ElMessage.warning('请填写属性')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.dataType.trim()) {
|
||||||
|
ElMessage.warning('请选择数据类型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.trim()) {
|
||||||
|
ElMessage.warning('请填写默认值')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rowKey = makeRowKey(form.carType.trim(), form.fieldType, form.key.trim())
|
||||||
|
const payload: FieldRow = {
|
||||||
|
rowKey,
|
||||||
|
carType: form.carType.trim(),
|
||||||
|
fieldType: form.fieldType,
|
||||||
|
key: form.key.trim(),
|
||||||
|
dataType: form.dataType,
|
||||||
|
value: form.value,
|
||||||
|
chinese: form.chinese,
|
||||||
|
english: form.english,
|
||||||
|
other: form.other,
|
||||||
|
isDefault: editingRowKey.value ? form.isDefault : false,
|
||||||
|
}
|
||||||
|
if (editingRowKey.value) {
|
||||||
|
const idx = fieldRows.value.findIndex((r) => r.rowKey === editingRowKey.value)
|
||||||
|
if (idx >= 0) {
|
||||||
|
fieldRows.value[idx] = {
|
||||||
|
...fieldRows.value[idx],
|
||||||
|
value: form.value,
|
||||||
|
chinese: form.chinese,
|
||||||
|
english: form.english,
|
||||||
|
other: form.other,
|
||||||
|
isDefault: form.isDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (fieldRows.value.some((r) => r.rowKey === rowKey)) {
|
||||||
|
ElMessage.warning('该车型下该字段已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fieldRows.value.push(payload)
|
||||||
|
}
|
||||||
|
activeCategory.value = form.fieldType
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRow(row: FieldRow) {
|
||||||
|
await ElMessageBox.confirm(`确定删除字段「${row.carType} / ${row.key}」?`, '确认', { type: 'warning' })
|
||||||
|
fieldRows.value = fieldRows.value.filter((r) => r.rowKey !== row.rowKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
initializing.value = true
|
||||||
|
try {
|
||||||
|
await loadFromDb(true)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '初始化失败')
|
||||||
|
} finally {
|
||||||
|
initializing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.simple-field-page {
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.page-card { min-height: calc(100vh - 88px); }
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||||
|
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.car-type-filter,
|
||||||
|
.keyword-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.filter-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.car-type-select {
|
||||||
|
width: 480px;
|
||||||
|
}
|
||||||
|
.car-type-select :deep(.el-select__selected-item),
|
||||||
|
.car-type-select :deep(.el-select__placeholder) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.keyword-input {
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
.bordered-select :deep(.el-select__wrapper) {
|
||||||
|
min-height: 32px;
|
||||||
|
background-color: #fff !important;
|
||||||
|
border: 1px solid #dcdfe6 !important;
|
||||||
|
border-radius: var(--el-border-radius-base, 4px);
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.bordered-select :deep(.el-select__wrapper:hover) {
|
||||||
|
border-color: #c0c4cc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.bordered-select :deep(.el-select__wrapper.is-focused),
|
||||||
|
.bordered-select :deep(.el-select__wrapper.is-hovering.is-focused) {
|
||||||
|
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary, #7c3aed) inset !important;
|
||||||
|
}
|
||||||
|
.bordered-select :deep(.el-select__selected-item),
|
||||||
|
.bordered-select :deep(.el-select__placeholder) {
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.field-tabs { margin-top: 4px; }
|
||||||
|
.field-tabs :deep(.search-hit-row > td.el-table__cell) {
|
||||||
|
background-color: #f5f3ff !important;
|
||||||
|
}
|
||||||
|
.builtin-switch :deep(.el-switch__core) {
|
||||||
|
border: 1px solid #c0c4cc;
|
||||||
|
background-color: #dcdfe6 !important;
|
||||||
|
}
|
||||||
|
.builtin-switch.is-checked :deep(.el-switch__core) {
|
||||||
|
background-color: var(--el-color-primary, #7c3aed) !important;
|
||||||
|
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||||
|
}
|
||||||
|
.builtin-switch :deep(.el-switch__action) {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.car-type-option-popper {
|
||||||
|
min-width: 520px !important;
|
||||||
|
}
|
||||||
|
.car-type-option-popper .car-type-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.car-type-option-popper .car-type-option-cn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 11em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.car-type-option-popper .car-type-option-en {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user