新增分段管理

This commit is contained in:
18086616529
2026-06-23 17:22:40 +08:00
parent a6086f1f8b
commit 88c688c0df
19 changed files with 1882 additions and 1 deletions
+496
View File
@@ -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` | 顶级分类 KPImap/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
```
+1
View File
@@ -40,6 +40,7 @@ public static class PageCatalog
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
+6
View File
@@ -120,6 +120,12 @@ public sealed class RbacStore
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
&& hasProcessAndScript)
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)
@@ -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.Storage.ValueConversion;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
namespace MiGu.Server.Persistence;
@@ -16,6 +17,7 @@ public sealed class PlatformDbContext : DbContext
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
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<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)
@@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Data.Sqlite;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
namespace MiGu.Server.Persistence;
@@ -38,6 +40,7 @@ public static class PlatformPersistence
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
services.AddScoped<SimpleFieldService>();
return services;
}
@@ -46,6 +49,97 @@ public static class PlatformPersistence
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
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)
+1
View File
@@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models;
using MiGu.Server.Auth;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.OpenApi;
using MiGu.Server.Persistence;
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: [] })
: get<CarStyleTypesPayload>('/car-style/types'),
/** 车型编码字段元数据(site/track/plan/car 四类字段及默认值)。 */
getCarTypeCoderFields: () => MOCK
? Promise.resolve<CarTypeCoderFieldsRow[]>([])
: get<CarTypeCoderFieldsRow[]>('/car-types/coder-fields'),
getCarStyle: (typeFullName: string) => MOCK
? Promise.resolve(defaultCarStyleDto())
: get<CarStyleDto>(`/car-style/${encodeURIComponent(typeFullName)}`),
@@ -741,6 +746,31 @@ export interface CarStyleTypesPayload {
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 {
key: string
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/processes', label: '进程管理', key: 'admin-processes' },
{ 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-scripts', 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-vehicle-hub', 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: '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: '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: '项目属性' } },
// ── 平台配置中心:聚合页(每个聚合页一个 page key = route.name,对齐后端 PageCatalog)。
// 原十余个独立配置页按业务收敛为下列 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>