新增分段管理
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
|
||||||
|
```
|
||||||
@@ -40,6 +40,7 @@ public static class PageCatalog
|
|||||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||||
|
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||||
|
|
||||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ public sealed class RbacStore
|
|||||||
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
||||||
&& hasProcessAndScript)
|
&& hasProcessAndScript)
|
||||||
r.Pages.Add("admin-task-templates");
|
r.Pages.Add("admin-task-templates");
|
||||||
|
|
||||||
|
// Simple 字段管理:与任务编排同属设计与编排,有任务编排权限时自动补齐。
|
||||||
|
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||||
|
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||||
|
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||||
|
r.Pages.Add("admin-simple-fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
||||||
|
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
||||||
|
///
|
||||||
|
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/projection")]
|
||||||
|
public class ProjectionController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("sites")]
|
||||||
|
public IActionResult Sites() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
|
||||||
|
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
|
||||||
|
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("tracks")]
|
||||||
|
public IActionResult Tracks() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
|
||||||
|
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("cars")]
|
||||||
|
public IActionResult Cars() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
|
||||||
|
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("missions")]
|
||||||
|
public IActionResult Missions() => Ok(new[]
|
||||||
|
{
|
||||||
|
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
|
||||||
|
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using MiGu.Server.Wms;
|
using MiGu.Server.Wms;
|
||||||
|
using MiGu.Server.SimpleFields;
|
||||||
|
|
||||||
namespace MiGu.Server.Persistence;
|
namespace MiGu.Server.Persistence;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
||||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||||
|
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,31 @@ public sealed class PlatformDbContext : DbContext
|
|||||||
|
|
||||||
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
||||||
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||||
|
|
||||||
|
ConfigureSimpleField(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
var e = modelBuilder.Entity<SimpleField>();
|
||||||
|
e.ToTable("simple_fields");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Id).HasColumnName("id");
|
||||||
|
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
|
||||||
|
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
|
||||||
|
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
|
||||||
|
e.Property(x => x.Value).HasColumnName("value");
|
||||||
|
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
|
||||||
|
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
|
||||||
|
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
|
||||||
|
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
|
||||||
|
e.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||||
|
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||||
|
v => SimpleFieldDateTime.ToStorage(v),
|
||||||
|
v => SimpleFieldDateTime.FromStorage(v));
|
||||||
|
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
|
||||||
|
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
|
||||||
|
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using MiGu.Server.Wms;
|
using MiGu.Server.Wms;
|
||||||
|
using MiGu.Server.SimpleFields;
|
||||||
|
|
||||||
namespace MiGu.Server.Persistence;
|
namespace MiGu.Server.Persistence;
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ public static class PlatformPersistence
|
|||||||
|
|
||||||
services.AddScoped<WmsReferenceValidator>();
|
services.AddScoped<WmsReferenceValidator>();
|
||||||
services.AddScoped<WmsService>();
|
services.AddScoped<WmsService>();
|
||||||
|
services.AddScoped<SimpleFieldService>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,97 @@ public static class PlatformPersistence
|
|||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
|
||||||
|
await EnsureSimpleFieldsTableAsync(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||||
|
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS simple_fields (
|
||||||
|
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
|
||||||
|
car_type TEXT NOT NULL DEFAULT '',
|
||||||
|
field_type TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
data_type TEXT NOT NULL DEFAULT '',
|
||||||
|
chinese TEXT,
|
||||||
|
english TEXT,
|
||||||
|
other TEXT NOT NULL DEFAULT '',
|
||||||
|
is_default INTEGER NOT NULL,
|
||||||
|
create_time TEXT NOT NULL,
|
||||||
|
update_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
|
||||||
|
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
UPDATE simple_fields SET car_type = other
|
||||||
|
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
UPDATE simple_fields SET other = ''
|
||||||
|
WHERE other <> '' AND other = car_type;
|
||||||
|
""");
|
||||||
|
await db.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
|
||||||
|
ON simple_fields (car_type, field_type, "key");
|
||||||
|
""");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
|
||||||
|
if (!await TableExistsAsync(db, "simple_fields"))
|
||||||
|
{
|
||||||
|
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||||
|
await creator.CreateTablesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查表是否存在
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="db">数据库上下文</param>
|
||||||
|
/// <param name="table">表名</param>
|
||||||
|
/// <returns>表是否存在</returns>
|
||||||
|
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
if (db.Database.IsSqlServer())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else if (db.Database.IsNpgsql())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else if (db.Database.IsMySql())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
|
||||||
|
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (conn.State == System.Data.ConnectionState.Open)
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models;
|
|||||||
using MiGu.Server.Auth;
|
using MiGu.Server.Auth;
|
||||||
using MiGu.Server.Configs;
|
using MiGu.Server.Configs;
|
||||||
using MiGu.Server.Launcher;
|
using MiGu.Server.Launcher;
|
||||||
|
using MiGu.Server.OpenApi;
|
||||||
using MiGu.Server.Persistence;
|
using MiGu.Server.Persistence;
|
||||||
using Yarp.ReverseProxy.Transforms;
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -568,6 +568,11 @@ export const reflectionApi = {
|
|||||||
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
? Promise.resolve<CarStyleTypesPayload>({ globalDefault: defaultCarStyleDto(), types: [] })
|
||||||
: get<CarStyleTypesPayload>('/car-style/types'),
|
: get<CarStyleTypesPayload>('/car-style/types'),
|
||||||
|
|
||||||
|
/** 车型编码字段元数据(site/track/plan/car 四类字段及默认值)。 */
|
||||||
|
getCarTypeCoderFields: () => MOCK
|
||||||
|
? Promise.resolve<CarTypeCoderFieldsRow[]>([])
|
||||||
|
: get<CarTypeCoderFieldsRow[]>('/car-types/coder-fields'),
|
||||||
|
|
||||||
getCarStyle: (typeFullName: string) => MOCK
|
getCarStyle: (typeFullName: string) => MOCK
|
||||||
? Promise.resolve(defaultCarStyleDto())
|
? Promise.resolve(defaultCarStyleDto())
|
||||||
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
|
||||||
@@ -741,6 +746,31 @@ export interface CarStyleTypesPayload {
|
|||||||
types: CarStyleTypeRow[]
|
types: CarStyleTypeRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoderFieldDef {
|
||||||
|
name: string
|
||||||
|
typeName: string
|
||||||
|
defaultValue: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoderFieldGroup {
|
||||||
|
typeName: string
|
||||||
|
shortName: string
|
||||||
|
assemblyName: string
|
||||||
|
baseTypeName?: string
|
||||||
|
fields: CoderFieldDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarTypeCoderFieldsRow {
|
||||||
|
typeName: string
|
||||||
|
shortName: string
|
||||||
|
label: string
|
||||||
|
assemblyName: string
|
||||||
|
siteFields: CoderFieldGroup
|
||||||
|
trackFields: CoderFieldGroup
|
||||||
|
planFields: CoderFieldGroup
|
||||||
|
carFields: CoderFieldGroup
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlarmColorEntry {
|
export interface AlarmColorEntry {
|
||||||
key: string
|
key: string
|
||||||
colorArgb: number
|
colorArgb: number
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="dtp-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="dtp-header">
|
||||||
|
<span class="dtp-title">{{ title }}</span>
|
||||||
|
<div class="dtp-actions">
|
||||||
|
<el-input v-if="searchable" v-model="kw" :placeholder="searchPlaceholder" clearable size="small" style="width: 220px" />
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="filtered" stripe size="small" :max-height="maxHeight" border>
|
||||||
|
<el-table-column v-for="c in columns" :key="c.prop" :prop="c.prop" :label="c.label" :width="c.width" :min-width="c.minWidth">
|
||||||
|
<template #default="scope">
|
||||||
|
<slot :name="`col-${c.prop}`" :row="scope.row">
|
||||||
|
{{ scope.row[c.prop] }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<slot name="extra-columns" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Column { prop: string; label: string; width?: number | string; minWidth?: number | string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
data: Array<Record<string, unknown>>
|
||||||
|
columns: Column[]
|
||||||
|
searchable?: boolean
|
||||||
|
searchPlaceholder?: string
|
||||||
|
maxHeight?: number | string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const kw = ref('')
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
if (!props.searchable || !kw.value) return props.data
|
||||||
|
const q = kw.value.trim().toLowerCase()
|
||||||
|
return props.data.filter((row) =>
|
||||||
|
Object.values(row).some((v) => String(v ?? '').toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dtp-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.dtp-title { font-weight: 600; }
|
||||||
|
.dtp-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -154,7 +154,8 @@ const ADMIN_MENU: MenuItem[] = [
|
|||||||
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
|
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
|
||||||
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
|
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
|
||||||
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
|
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
|
||||||
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' }
|
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' },
|
||||||
|
{ path: '/admin/simple-fields', label: '字段管理', key: 'admin-simple-fields' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const PAGES: PageDef[] = [
|
|||||||
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-processes', label: '进程管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-scripts', label: '脚本管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
||||||
|
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
|
||||||
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
||||||
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
||||||
{ key: 'admin-config-facility', label: '设备与库位', group: '平台配置中心', scope: 'Platform' },
|
{ key: 'admin-config-facility', label: '设备与库位', group: '平台配置中心', scope: 'Platform' },
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
{ path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } },
|
||||||
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
{ path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } },
|
||||||
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
{ path: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||||||
|
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||||||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||||||
// ── 平台配置中心:聚合页(每个聚合页一个 page key = route.name,对齐后端 PageCatalog)。
|
// ── 平台配置中心:聚合页(每个聚合页一个 page key = route.name,对齐后端 PageCatalog)。
|
||||||
// 原十余个独立配置页按业务收敛为下列 6 个入口,子页改为聚合页内的 tab。 ──
|
// 原十余个独立配置页按业务收敛为下列 6 个入口,子页改为聚合页内的 tab。 ──
|
||||||
|
|||||||
@@ -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,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