diff --git a/.gitignore b/.gitignore index 38e2f31..23a269b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,17 @@ # MiGu.Server 运行时数据(含敏感 token) MiGu.Server/data/*.json +MiGu.Server/data/*.db +MiGu.Server/data/*.db-shm +MiGu.Server/data/*.db-wal MiGu.Server/data/.internal-token -# 前端构建产物,由 CI / npm run build 产出,不入库 +# 前端构建产物 MiGu.Server/wwwroot/ +frontends/apps/simple-platform-vue/auto-imports.d.ts + +# 临时构建输出 +.tmp-build*/ # IDE / OS .vs/ @@ -20,3 +27,5 @@ MiGu.Server/wwwroot/ *.swp Thumbs.db Desktop.ini +/.cursor/rules +/MiGu.Server/wwwroot diff --git a/Doc/PROJECT_MODULES_AND_RELATIONSHIPS.md b/Doc/PROJECT_MODULES_AND_RELATIONSHIPS.md new file mode 100644 index 0000000..3c5b61d --- /dev/null +++ b/Doc/PROJECT_MODULES_AND_RELATIONSHIPS.md @@ -0,0 +1,705 @@ +# 迷毂 2.0 项目模块与关系说明 + +> 本文基于当前仓库代码、README 与架构文档整理。结论以本仓库实际实现为主;`ARCHITECTURE.md` 中包含较多中长期蓝图,若与代码存在差异,本文会单独标注。 + +## 1. 项目定位 + +`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分: + +- 平台后端:`MiGu.Server`,ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。 +- 平台前端:`frontends/apps/simple-platform-vue`,Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`。 + +调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。 + +## 2. 技术栈总览 + +### 后端 + +- 运行时:`.NET 8` / ASP.NET Core Web API。 +- API 文档:Swagger,仅开发环境启用。 +- 鉴权:JWT Bearer + httpOnly Cookie 双轨。 +- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。 +- 代理:YARP Reverse Proxy。 +- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。 +- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`。 + +### 前端 + +- 框架:Vue 3.5、Vite 5、TypeScript。 +- 状态:Pinia。 +- UI:Element Plus、`@element-plus/icons-vue`。 +- 图表/编排:ECharts、Vue Flow。 +- HTTP:Axios,统一通过 `/api` baseURL 调用平台后端。 +- 开发 Mock:`VITE_USE_MOCK=true` 时部分 API 使用前端 mock 数据。 + +### 外部/相邻系统 + +- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。 +- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。 +- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。 + +## 3. 顶层目录职责 + +```text +MIGU2.0/ +├── MiGu.Server/ 平台后端工程 +├── MiGu.Server.sln 后端解决方案 +├── frontends/ 前端 pnpm workspace +│ └── apps/simple-platform-vue/ 单 SPA:管理员端 + 运营端 +├── Doc/ 代码审查、问题清单与本文档 +├── ARCHITECTURE.md 总体架构蓝图,含大量规划内容 +├── PLATFORM_V3_CHANGES.md v3 平台化实际改动记录 +├── README.md 仓库定位与启动说明 +└── build-platform-frontend.bat 前端构建并同步到 MiGu.Server/wwwroot +``` + +## 4. 当前实现的总体运行关系 + +当前代码中的主入口是 `MiGu.Server`: + +```mermaid +flowchart LR + User["浏览器用户"] --> Vue["simple-platform-vue
/login /admin /monitor"] + Vue -->|/api/*| Server["MiGu.Server :8080"] + Server --> Auth["Auth/RBAC/JWT"] + Server --> Config["ConfigStore
data/config-*.json"] + Server --> Wizard["部署向导
deployment profile"] + Server --> Launcher["SimpleLiteLauncher"] + Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe
相邻仓库"] + Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"] + Server -->|YARP /vr/*| VR["webVRender :8223"] + Vue -->|iframe 或 /vr 代理| VR +``` + +关键说明: + +- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite。 +- 用户登录时可选择 `DesktopAndWeb` 或 `WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local` 或 `--display-mode=web`。 +- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite。 +- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。 +- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。 + +## 5. 文档蓝图与当前代码的差异 + +仓库中存在两类叙述: + +- `README.md` 和 `MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。 +- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。 + +当前代码落地状态: + +- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。 +- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。 +- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。 + +## 6. 后端模块 + +### 6.1 启动与基础设施:`MiGu.Server/Program.cs` + +职责: + +- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。 +- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`。 +- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`。 +- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、SimpleLiteLauncher。 +- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。 +- 托管静态资源和 SPA fallback。 + +核心关系: + +- 调用 `JwtIssuer` 颁发/验签 JWT。 +- 调用 `InternalTokenStore` 管理转发给 SimpleLite 的 `X-Platform-Internal-Token`。 +- YARP `sl-route` 会自动注入内部 token。 +- `app.MapControllers()` 处理平台自身 API,`app.MapReverseProxy()` 处理 `/api/sl/*` 与 `/vr/*`。 + +### 6.2 鉴权与会话:`AuthController` + +职责: + +- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 SimpleLite,签发 JWT 和 Cookie。 +- `POST /api/auth/logout`:清理 Cookie。 +- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。 +- `POST /api/auth/switch-scope`:同一用户在 `Platform` 与 `RCSMonitor` 之间切换 scope,并由服务端重新计算权限。 + +依赖: + +- `RbacStore`:用户密码校验、scope 判断、有效权限计算。 +- `JwtIssuer`:签发 JWT。 +- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite。 +- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。 + +### 6.3 RBAC 权限模块:`Auth/` + +主要文件: + +- `RbacStore.cs`:RBAC 核心存储与计算。 +- `RbacModels.cs`:用户、角色、DTO。 +- `PageCatalog.cs`:页面权限目录。 +- `JwtIssuer.cs`:JWT 颁发和验签参数。 +- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。 + +职责: + +- 首次启动生成默认账号 `admin` 和 `ops`,并写入 `data/rbac.json`。 +- 密码使用 PBKDF2-SHA256,带 salt,固定时间比较。 +- 角色模型包含 scope、页面、操作码、控件可见性。 +- 有效权限 = 当前 scope 下适用角色的页面、操作码、控件授权并集。 +- 防止删除/停用最后一个具备管理权限的账号。 + +对外 API: + +- `RbacController` 挂载 `/api/rbac`。 +- 通过 `RbacAdmin` 策略保护,JWT 的 `ops` claim 需包含 `*` 或 `auth.manage`。 +- 支持角色与用户 CRUD、密码修改、权限目录查询。 + +### 6.4 配置中心:`Configs/` + `ConfigController` + +`ConfigStore` 当前是“内存 + JSON 文件”的配置中心,覆盖以下 section: + +- `system`:系统级配置。 +- `integrations`:外部系统对接。 +- `routing`:路径规划策略。 +- `vehicle`:车辆维护策略。 +- `charge`:充电策略。 +- `task`:任务分配机制。 +- `traffic`:交通管制规则。 +- `auth`:权限与角色配置模型。 +- `device`:设备管理。 +- `fleet`:车队生命周期。 +- `scenario`:场景模板。 +- `location`:库位管理。 +- `ops`:运营维护配置。 +- `widget`:自定义控件。 +- `deployment`:部署画像/配置向导结果。 + +`ConfigController`: + +- `GET /api/config`:列出所有 section 的版本与更新时间。 +- `GET /api/config/{section}`:读取某个配置。 +- `PUT /api/config/{section}`:保存配置并增加版本。 + +关系: + +- 前端 `useConfigStore` 统一读写这些 section。 +- 许多配置页只是不同 section 的编辑视图。 +- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。 + +注意: + +- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。 +- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。 + +### 6.5 SimpleLite 拉起与诊断:`Launcher/` + +主要文件: + +- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。 +- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`。 +- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。 + +职责: + +- 将业务启动模式翻译为 SimpleLite 内部 display mode: + - `WebOnly` -> `--display-mode=web` + - `DesktopAndWeb` -> `--display-mode=web+local` +- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。 +- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。 +- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite。 +- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。 +- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite。 +- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。 + +### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore` + +职责: + +- 对运营端发起的运维动作做白名单校验。 +- 二次校验 JWT 的 `ops` claim。 +- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。 +- `monitor.note.write` 仅写审计。 +- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。 +- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。 + +默认白名单: + +- 车辆:暂停、恢复、回库、重置会话、手动充电。 +- 任务:暂停、取消、重派、提升优先级。 +- 运营备注:写备注。 + +重要设计: + +- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。 +- 真实下发路径为 SimpleLite 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`。 + +### 6.7 投影与 SimpleLite 代理 + +模块组成: + +- `ProjectionController`:平台本地 mock 投影接口,提供 sites/tracks/cars/missions 示例数据。 +- YARP `/api/sl/{**catch-all}`:真实链路转发到 `http://127.0.0.1:8222/`。 +- 前端 `api/projection.ts`:优先调用 `/api/sl/projection/*`,车辆和任务在失败或空列表时回退到反射对象列表。 + +关系: + +- 管理端和运营端的地图监控、车辆面板、任务列表都依赖投影数据。 +- `/api/projection/*` 更偏本地开发 mock;真实联调主要走 `/api/sl/projection/*`。 + +### 6.8 健康检查:`HealthController` + +职责: + +- `GET /api/health`:平台后端健康检查,返回启动时间、运行时长、端口规划。 +- `GET /api/health/simplelite`:SimpleLite 路径和端口诊断。 +- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。 + +### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog` + +职责: + +- 首次部署时收集导航方式、功能模块、业务场景。 +- 保存后写入 `deployment` 配置 section。 +- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`。 +- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`。 + +对外 API: + +- `GET /api/wizard/options`:选项目录。 +- `GET /api/wizard/profile`:当前部署画像。 +- `GET /api/wizard/effective-pages`:菜单裁剪结果。 +- `PUT /api/wizard/profile`:保存画像。 +- `POST /api/wizard/reset`:重新进入向导。 + +### 6.10 日志管理:`LogsController` + +职责: + +- 读取 SimpleLite 工作目录下的 `log/` 文件。 +- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。 +- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。 + +安全: + +- 仅 `PlatformScope` 可访问,因为日志可能包含路径、状态和内部运行信息。 +- 对文件路径做安全解析,防止 `../` 目录穿越。 + +前端关系: + +- 由 `LogManagementView` 挂在“运维与回放”配置聚合页下。 + +### 6.11 地图 JSON 预览:`MapsContentController` + +职责: + +- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。 +- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。 +- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。 + +关系: + +- 依赖 SimpleLite 与 MiGu.Server 同机部署。 +- 依赖 `SimpleLiteOptions.ProjectionPort` 和 `InternalTokenStore`。 + +## 7. 前端模块 + +### 7.1 应用入口与路由 + +主要文件: + +- `src/main.ts`:创建 Vue 应用、Pinia、Element Plus,恢复主题。 +- `src/router/index.ts`:定义 `/login`、`/status`、`/wizard`、`/admin/*`、`/monitor/*`。 +- `src/layouts/AppShell.vue`:登录后的管理/运营统一壳层。 +- `src/layouts/BlankLayout.vue`:登录、状态、向导等空白布局。 + +路由守卫做四件事: + +- 未登录跳转 `/login`。 +- 进入受保护路由前调用 `auth.validate()`,用 `/api/auth/me` 实校 token。 +- 若 `needsWizard=true`,强制进入 `/wizard`。 +- 根据路径自动切换 scope,并按 `allowedPages` 做页面级权限控制。 + +### 7.2 登录与会话状态 + +主要文件: + +- `views/LoginView.vue` +- `stores/auth.ts` +- `api/auth.ts` +- `types/auth.ts` + +职责: + +- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。 +- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。 +- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。 +- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。 + +注意: + +- 登录页提示“任意非空用户名 + 任意密码即可登录”来自历史 Mock 文案;真实 API 模式下后端已校验 `data/rbac.json` 中的密码。 + +### 7.3 统一 HTTP 与 API 层 + +主要文件: + +- `api/http.ts`:Axios 实例、baseURL、Cookie 与 Bearer 双轨、错误翻译、401 清理。 +- `api/auth.ts`:登录、登出、me、scope 切换。 +- `api/config.ts`:配置中心读写。 +- `api/projection.ts`:站点、路径、车辆、任务投影。 +- `api/ops.ts`:运维白名单执行和审计。 +- `api/reflection.ts`:SimpleLite 反射对象、字段、方法执行。 +- `api/mapEdit.ts`:地图编辑相关 API。 +- `api/wizard.ts`:部署向导。 +- `api/logs.ts`:日志管理。 + +关系: + +- 所有平台 API 都以 `/api` 为 baseURL。 +- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite。 +- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。 + +### 7.4 Shell、主题和权限控制组件 + +主要文件: + +- `layouts/AppShell.vue`:侧边栏、顶栏、菜单、用户区、runMode 标签、scope 切换。 +- `components/ScopeSwitcher.vue`:scope 切换入口。 +- `components/PermissionGuard.vue`:按操作码或控件授权控制视图。 +- `components/ThemeSwitcher.vue`:主题下拉。 +- `components/ThemeCustomizer.vue`:主题自定义。 +- `stores/ui.ts`、`styles/themes.ts`、`styles/theme.css`:主题、侧边栏折叠和样式变量。 + +关系: + +- `AppShell` 中菜单的 key 与后端 `PageCatalog`、前端路由 `route.name` 对齐。 +- `auth.hasPage()` 决定菜单项是否显示。 +- `auth.hasOp()` 和 `auth.widgetOf()` 决定按钮、控件、面板的可见性与可交互程度。 + +### 7.5 3D 工作区:`Workspace3D.vue` + +职责: + +- 构造 webVRender iframe URL:`http://{host}/?scope=...&token=...&ro=...`。 +- 支持 `embedUi` 和 `canvasOnly` 两种嵌入模式。 +- 在纯画布/嵌入模式下尽力调用 `/declareCanvasOnly` 或 `/declareEmbedUi`。 +- 监听 iframe `postMessage`,向 Vue 页面转发 pick、select、shortcut 事件。 +- 提供重载和全屏能力。 + +关系: + +- `MapEditorView` 使用 `canvasOnly=true`,所有编辑 UI 由 Vue 接管。 +- `MapMonitorView` 使用 `canvasOnly=true`,叠加报警卡和车辆/任务工作台。 +- 运营端 `MonitorMapView` 复用 `MapMonitorView read-only`。 + +### 7.6 管理端页面:`/admin/*` + +主要页面: + +- `DashboardView.vue`:管理员总览。 +- `MapMonitorView.vue`:地图监控、车辆/任务工作台、浮动报警、选中信息。 +- `MapManagementView.vue`:地图管理与地图 JSON 预览。 +- `MapEditorView.vue`:地图编辑器。 +- `TrackTableView.vue`:场景/路径管理。 +- `CarPanelView.vue`:车辆管理。 +- `ProcessPanelView.vue`:进程管理。 +- `ScriptPanelView.vue`:脚本管理。 +- `TaskTemplateView.vue`:任务编排。 +- `ProjectPropertiesView.vue`:项目属性。 + +#### 地图编辑器 + +`MapEditorView.vue` 是前端较重的模块,组合了: + +- `EditTopBar`:项目/文件/编辑/视图/图层/对齐吸附等命令。 +- `EditToolRail`:CAD/地图编辑工具按钮。 +- `EditPropertyPanel`:对象字段、类型默认、图层、视口样式。 +- `EditStatusBar`:选中数、鼠标坐标、工具、撤销栈状态。 +- `AiGenerateDialog` 和 `AiAssistantPanel`:AI 生图/助手。 +- `Workspace3D`:纯画布 iframe。 +- `useHistory`:撤销/重做命令栈。 +- `useSelection`:对象选择状态。 +- `useAlignment`、`useBatchGenerate`、`useClipboard`:编辑辅助能力。 +- `mapEditApi`、`reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。 + +#### 地图监控 + +`MapMonitorView.vue` 组合: + +- KPI 统计:在线车辆、运行中任务、排队任务。 +- `Workspace3D`:地图画布。 +- `FloatingAlarmStack`:报警浮层。 +- `WorkspaceCanvasToolbar`:画布工具条。 +- `VehicleMonitorPanel`:车辆监控台。 +- `MissionListPanel`:任务列表。 +- `MonitorSelectionPanel`:选中对象详情和动作。 +- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。 + +### 7.7 运营端页面:`/monitor/*` + +主要页面: + +- `MonitorDashboardView.vue`:运营总览。 +- `VehicleHubView.vue`:车辆运维,管理端和运营端共用。 +- `MonitorMapView.vue`:只读地图监控,复用 `MapMonitorView read-only`。 +- `OpsActionPanelView.vue`:运维白名单操作。 +- `AnnotationView.vue`:运营备注。 + +设计关系: + +- 运营端不是独立后端,仍使用 MiGu.Server。 +- 运营端 scope 为 `RCSMonitor`,由后端 RBAC 返回受限页面和操作码。 +- 前端只隐藏无权限按钮;真正的安全边界在后端 `OpsController` 和 YARP/Controller 授权。 + +### 7.8 配置中心聚合页 + +当前前端将十多个配置页面收敛为 6 个聚合入口: + +- `StrategyConfigView.vue`:路径规划、任务分配、交通管制、充电策略。 +- `VehicleHubView.vue`:运维总览、维护策略、车队生命周期。 +- `FacilityConfigView.vue`:设备接入、库位管理。 +- `BusinessConfigView.vue`:外部系统对接、场景模板、自定义控件。 +- `OpsCenterView.vue`:调度回放、运营维护、日志管理、地图监控配置。 +- `SystemCenterView.vue`:系统级配置、权限与角色。 + +旧路径通过路由 redirect 到这些聚合页的对应 tab,降低深链接迁移成本。 + +### 7.9 部署配置向导 + +`WizardView.vue` 用于首次登录后的部署选型: + +- 选择导航方式:磁导航、二维码导航、激光导航。 +- 选择功能模块:当前包含 WMS、PTL。 +- 选择业务场景模板。 +- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic`、`scene.qrcode`、`scene.laser`。 + +保存后: + +- 调用 `PUT /api/wizard/profile`。 +- 后端写入 `deployment` section。 +- 后端写入 SimpleLite `plugins/active-scenes.json`。 +- 前端调用 `auth.markWizardDone()`,跳转到对应首页。 + +## 8. 数据与持久化 + +### 后端持久化文件 + +当前主要文件都在 `MiGu.Server/data/`: + +- `rbac.json`:用户、角色、密码哈希、页面/操作/控件权限。 +- `config-{section}.json`:配置中心各 section。 +- 运维审计文件:由 `OpsAuditStore` 管理。 +- `.internal-token`:内部 token 可能由 `InternalTokenStore` 生成或读取。 + +这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。 + +### SimpleLite 侧文件 + +MiGu.Server 会直接或间接使用 SimpleLite 工作目录: + +- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。 +- `log/**/*.log`:日志管理模块读取并分析。 +- 地图目录中的 `*.json`:地图管理预览读取。 + +## 9. 关键接口关系 + +### 浏览器到平台后端 + +- `/api/auth/*`:登录、退出、身份实校、scope 切换。 +- `/api/config/*`:配置中心。 +- `/api/rbac/*`:权限与角色管理。 +- `/api/wizard/*`:部署向导。 +- `/api/logs/*`:日志管理。 +- `/api/maps/{name}/content`:地图 JSON 预览。 +- `/api/health/*`:健康检查。 + +### 平台后端到 SimpleLite + +- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`。 +- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`。 +- `OpsController` 直接调用 `http://127.0.0.1:{ProjectionPort}/projection/reflection/execute/...`。 +- `MapsContentController` 直接调用 `http://127.0.0.1:{ProjectionPort}/projection/map-edit/maps`。 + +### 前端到 webVRender + +- 默认 iframe 直连:`http://localhost:8223/?scope=...&token=...&ro=...`。 +- 可选同源代理:`http://localhost:8080/vr/?scope=...`。 + +## 10. 模块间依赖关系 + +```mermaid +flowchart TB + subgraph Backend["MiGu.Server"] + Program["Program.cs"] + Auth["AuthController"] + RBAC["RbacStore / JwtIssuer"] + Config["ConfigStore"] + Wizard["WizardController"] + Launcher["SimpleLiteLauncher"] + Ops["OpsController"] + Logs["LogsController"] + Maps["MapsContentController"] + YARP["YARP ReverseProxy"] + end + + subgraph Frontend["simple-platform-vue"] + Router["router/index.ts"] + AuthStore["stores/auth.ts"] + ConfigStoreVue["stores/config.ts"] + Shell["AppShell.vue"] + Workspace["Workspace3D.vue"] + Admin["admin views"] + Monitor["monitor views"] + WizardVue["WizardView.vue"] + end + + subgraph SimpleLite["SimpleLite 相邻仓库"] + SLExe["SimpleLite.exe"] + Projection["Projection API :8222"] + VRender["webVRender :8223"] + LogsFile["log/**/*.log"] + Plugins["plugins/active-scenes.json"] + MapsDir["maps/*.json"] + end + + Router --> AuthStore + AuthStore --> Auth + ConfigStoreVue --> Config + Shell --> AuthStore + Admin --> Workspace + Monitor --> Workspace + Workspace --> VRender + WizardVue --> Wizard + + Program --> Auth + Program --> RBAC + Program --> Config + Program --> Launcher + Program --> YARP + Auth --> RBAC + Auth --> Launcher + Auth --> Config + Wizard --> Config + Wizard --> Launcher + Launcher --> SLExe + Launcher --> Plugins + Ops --> Projection + Maps --> Projection + Maps --> MapsDir + Logs --> LogsFile + YARP --> Projection + YARP --> VRender +``` + +## 11. 典型业务链路 + +### 11.1 登录并启动 SimpleLite + +1. 用户打开 `/login`。 +2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`。 +3. `AuthController` 调 `RbacStore.VerifyCredentials()` 校验密码。 +4. `AuthController` 检查用户是否可使用请求的 scope。 +5. `AuthController` 调 `SimpleLiteLauncher.MaybeStart()`。 +6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`。 +7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。 +8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。 + +### 11.2 管理员打开地图编辑器 + +1. 路由进入 `/admin/map-editor`。 +2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。 +3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。 +4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。 +5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi` 或 `reflectionApi` 调用 SimpleLite。 +6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。 + +### 11.3 运营端执行运维动作 + +1. 运营用户进入 `/monitor/ops` 或只读地图监控页中的动作入口。 +2. 前端通过 `auth.hasOp()` 隐藏无权限操作。 +3. 用户确认后调用 `/api/sl/ops/execute`。 +4. `OpsController` 校验白名单、JWT 操作码、幂等键。 +5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。 +6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。 +7. 审计落盘,前端显示执行结果。 + +### 11.4 首次部署配置向导 + +1. 登录或 `/api/auth/me` 返回 `needsWizard=true`。 +2. 路由守卫强制进入 `/wizard`。 +3. 前端加载 `/api/wizard/options` 和 `/api/wizard/profile`。 +4. 用户选择导航方式、模块、场景。 +5. 保存后端写 `deployment` section。 +6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`。 +7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。 + +## 12. 构建与运行 + +### 后端 + +```powershell +cd MiGu.Server +dotnet build MiGu.Server.csproj -c Debug +dotnet run +``` + +默认访问: + +- 平台:`http://localhost:8080/login` +- 健康检查:`http://localhost:8080/api/health` +- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite` +- Swagger:开发环境 `/swagger` + +### 前端开发 + +```powershell +cd frontends +pnpm install +pnpm dev +``` + +默认访问: + +- `http://localhost:5173/login` +- Vite 将 `/api` 代理到 `http://127.0.0.1:8080`。 + +### 前端构建并同步后端静态资源 + +```powershell +.\build-platform-frontend.bat +``` + +脚本会: + +1. 检查或安装依赖。 +2. 执行 `pnpm --filter simple-platform-vue build`。 +3. 用 `robocopy /MIR` 同步 `dist` 到 `MiGu.Server/wwwroot`。 +4. 默认启动 `MiGu.Server/build-and-run.bat`,可加 `--no-start` 禁止启动。 + +## 13. 风险与待完善点 + +- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。 +- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。 +- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。 +- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health` 与 `/api/health/simplelite` 的真实数据。 +- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`。 +- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。 +- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。 + +## 14. 快速索引 + +- 后端启动:`MiGu.Server/Program.cs` +- 登录和启动 SimpleLite:`MiGu.Server/Controllers/AuthController.cs` +- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs` +- 配置中心:`MiGu.Server/Configs/ConfigStore.cs` +- 权限中心:`MiGu.Server/Auth/RbacStore.cs` +- 权限 API:`MiGu.Server/Controllers/RbacController.cs` +- 运维 API:`MiGu.Server/Controllers/OpsController.cs` +- 部署向导 API:`MiGu.Server/Controllers/WizardController.cs` +- 日志 API:`MiGu.Server/Controllers/LogsController.cs` +- 前端路由:`frontends/apps/simple-platform-vue/src/router/index.ts` +- 前端会话状态:`frontends/apps/simple-platform-vue/src/stores/auth.ts` +- 前端配置状态:`frontends/apps/simple-platform-vue/src/stores/config.ts` +- 前端 3D iframe:`frontends/apps/simple-platform-vue/src/components/Workspace3D.vue` +- 管理端地图编辑:`frontends/apps/simple-platform-vue/src/views/admin/MapEditorView.vue` +- 管理端地图监控:`frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue` +- 运营端地图监控:`frontends/apps/simple-platform-vue/src/views/monitor/MonitorMapView.vue` +- 配置向导:`frontends/apps/simple-platform-vue/src/views/WizardView.vue` diff --git a/MiGu.Server/Controllers/WmsController.cs b/MiGu.Server/Controllers/WmsController.cs new file mode 100644 index 0000000..fab3dbb --- /dev/null +++ b/MiGu.Server/Controllers/WmsController.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using MiGu.Server.Wms; + +namespace MiGu.Server.Controllers; + +[ApiController] +[Authorize] +[TypeFilter(typeof(WmsExceptionFilter))] +[Route("api/wms")] +public sealed class WmsController : ControllerBase +{ + private readonly WmsService _service; + + public WmsController(WmsService service) + { + _service = service; + } + + [HttpGet("areas")] + public Task> Areas([FromQuery] string? q) => _service.Areas(q); + + [HttpPost("areas")] + public async Task SaveArea([FromBody] MasterDataRequest req) => Ok(await _service.SaveArea(req, User.ActorName())); + + [HttpPut("areas/{id:guid}")] + public async Task UpdateArea(Guid id, [FromBody] MasterDataRequest req) => + Ok(await _service.SaveArea(req with { Id = id }, User.ActorName())); + + [HttpDelete("areas/{id:guid}")] + public async Task DeleteArea(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + + [HttpGet("storages")] + public Task> Storages([FromQuery] string? q) => _service.Storages(q); + + [HttpPost("storages")] + public async Task SaveStorage([FromBody] StorageRequest req) => Ok(await _service.SaveStorage(req, User.ActorName())); + + [HttpPut("storages/{id:guid}")] + public async Task UpdateStorage(Guid id, [FromBody] StorageRequest req) => + Ok(await _service.SaveStorage(req with { Id = id }, User.ActorName())); + + [HttpDelete("storages/{id:guid}")] + public async Task DeleteStorage(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + + [HttpGet("containers")] + public Task> Containers([FromQuery] string? q) => _service.Containers(q); + + [HttpPost("containers")] + public async Task SaveContainer([FromBody] MasterDataRequest req) => Ok(await _service.SaveContainer(req, User.ActorName())); + + [HttpPut("containers/{id:guid}")] + public async Task UpdateContainer(Guid id, [FromBody] MasterDataRequest req) => + Ok(await _service.SaveContainer(req with { Id = id }, User.ActorName())); + + [HttpDelete("containers/{id:guid}")] + public async Task DeleteContainer(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + + [HttpGet("materials")] + public Task> Materials([FromQuery] string? q) => _service.Materials(q); + + [HttpPost("materials")] + public async Task SaveMaterial([FromBody] MaterialRequest req) => Ok(await _service.SaveMaterial(req, User.ActorName())); + + [HttpPut("materials/{id:guid}")] + public async Task UpdateMaterial(Guid id, [FromBody] MaterialRequest req) => + Ok(await _service.SaveMaterial(req with { Id = id }, User.ActorName())); + + [HttpDelete("materials/{id:guid}")] + public async Task DeleteMaterial(Guid id, [FromQuery] long? version) + { + await _service.DeleteEntity(id, version, User.ActorName()); + return NoContent(); + } + + [HttpGet("container-locations")] + public Task> ContainerLocations([FromQuery] string? locationType, [FromQuery] string? q) => + _service.ContainerLocations(locationType, q); + + [HttpPost("container-locations")] + public async Task SaveContainerLocation([FromBody] ContainerLocationRequest req) => + Ok(await _service.BindOrTransferLocation(req, User.ActorName())); + + [HttpPost("container-locations/transfer")] + public async Task TransferContainerLocation([FromBody] ContainerLocationRequest req) => + Ok(await _service.BindOrTransferLocation(req, User.ActorName())); + + [HttpDelete("container-locations/{containerId:guid}")] + public async Task UnbindContainerLocation(Guid containerId, [FromQuery] string? reason) + { + await _service.UnbindLocation(containerId, User.ActorName(), reason ?? ""); + return NoContent(); + } + + [HttpGet("container-materials")] + public Task> ContainerMaterials([FromQuery] string? q) => _service.ContainerMaterials(q); + + [HttpPost("container-materials")] + public async Task SaveContainerMaterial([FromBody] ContainerMaterialRequest req) => + Ok(await _service.SaveContainerMaterial(req, User.ActorName())); + + [HttpPut("container-materials/{id:guid}")] + public async Task UpdateContainerMaterial(Guid id, [FromBody] ContainerMaterialRequest req) => + Ok(await _service.SaveContainerMaterial(req with { Id = id }, User.ActorName())); + + [HttpDelete("container-materials/{id:guid}")] + public async Task UnloadContainerMaterial(Guid id, [FromQuery] string? reason) + { + await _service.UnloadMaterial(id, User.ActorName(), reason ?? ""); + return NoContent(); + } + + [HttpGet("container-location-history")] + public Task> ContainerLocationHistory([FromQuery] Guid? containerId) => + _service.LocationHistory(containerId); + + [HttpGet("container-material-history")] + public Task> ContainerMaterialHistory([FromQuery] Guid? containerId, [FromQuery] Guid? materialId) => + _service.MaterialHistory(containerId, materialId); +} + +public sealed class WmsExceptionFilter : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + if (context.Exception is not InvalidOperationException ex) return; + context.Result = new BadRequestObjectResult(new { message = ex.Message }); + context.ExceptionHandled = true; + } +} diff --git a/MiGu.Server/MiGu.Server.csproj b/MiGu.Server/MiGu.Server.csproj index 8bde202..5a3e57d 100644 --- a/MiGu.Server/MiGu.Server.csproj +++ b/MiGu.Server/MiGu.Server.csproj @@ -16,6 +16,11 @@ + + + + + diff --git a/MiGu.Server/Migrations/MySql/.gitkeep b/MiGu.Server/Migrations/MySql/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/MiGu.Server/Migrations/MySql/.gitkeep @@ -0,0 +1 @@ + diff --git a/MiGu.Server/Migrations/Npgsql/.gitkeep b/MiGu.Server/Migrations/Npgsql/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/MiGu.Server/Migrations/Npgsql/.gitkeep @@ -0,0 +1 @@ + diff --git a/MiGu.Server/Migrations/SqlServer/.gitkeep b/MiGu.Server/Migrations/SqlServer/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/MiGu.Server/Migrations/SqlServer/.gitkeep @@ -0,0 +1 @@ + diff --git a/MiGu.Server/Migrations/Sqlite/.gitkeep b/MiGu.Server/Migrations/Sqlite/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/MiGu.Server/Migrations/Sqlite/.gitkeep @@ -0,0 +1 @@ + diff --git a/MiGu.Server/Persistence/EntityBase.cs b/MiGu.Server/Persistence/EntityBase.cs new file mode 100644 index 0000000..8d5e51e --- /dev/null +++ b/MiGu.Server/Persistence/EntityBase.cs @@ -0,0 +1,17 @@ +namespace MiGu.Server.Persistence; + +public abstract class EntityBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string DeletedBy { get; set; } = ""; + public long Version { get; set; } + public bool IsLock { get; set; } + public string CreatedBy { get; set; } = ""; + public string UpdatedBy { get; set; } = ""; + public string Remark { get; set; } = ""; + public string Extend { get; set; } = "{}"; +} diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs new file mode 100644 index 0000000..488326e --- /dev/null +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -0,0 +1,124 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MiGu.Server.Wms; + +namespace MiGu.Server.Persistence; + +public sealed class PlatformDbContext : DbContext +{ + public PlatformDbContext(DbContextOptions options) : base(options) { } + + public DbSet WarehouseAreas => Set(); + public DbSet Storages => Set(); + public DbSet Containers => Set(); + public DbSet Materials => Set(); + public DbSet ContainerLocations => Set(); + public DbSet ContainerMaterials => Set(); + public DbSet ContainerLocationHistories => Set(); + public DbSet ContainerMaterialHistories => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var guid = new ValueConverter( + v => v.ToString("D"), + v => Guid.Parse(v)); + var nullableGuid = new ValueConverter( + v => v.HasValue ? v.Value.ToString("D") : null, + v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v)); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid))) + modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36); + foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?))) + modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36); + } + + ConfigureEntityBase(modelBuilder, "wms_areas"); + ConfigureEntityBase(modelBuilder, "wms_storages"); + ConfigureEntityBase(modelBuilder, "wms_containers"); + ConfigureEntityBase(modelBuilder, "wms_materials"); + ConfigureEntityBase(modelBuilder, "wms_container_locations"); + ConfigureEntityBase(modelBuilder, "wms_container_materials"); + + ConfigureHistory(modelBuilder, "wms_container_location_history"); + ConfigureHistory(modelBuilder, "wms_container_material_history"); + + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.AreaId); + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Code).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.ContainerId).IsUnique(); + modelBuilder.Entity().HasIndex(x => new { x.LocationType, x.LocationId }); + modelBuilder.Entity().HasIndex(x => new { x.ContainerId, x.MaterialId, x.BatchNo, x.SerialNo }).IsUnique(); + + modelBuilder.Entity().Property(x => x.Quantity).HasPrecision(18, 4); + modelBuilder.Entity().Property(x => x.QuantityDelta).HasPrecision(18, 4); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + StampEntities(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + StampEntities(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void StampEntities() + { + var now = DateTimeOffset.UtcNow; + foreach (var e in ChangeTracker.Entries()) + { + if (e.State == EntityState.Added) + { + if (e.Entity.Id == Guid.Empty) e.Entity.Id = Guid.NewGuid(); + e.Entity.CreatedAt = now; + e.Entity.UpdatedAt = now; + e.Entity.Version = Math.Max(1, e.Entity.Version); + if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}"; + } + else if (e.State == EntityState.Modified) + { + e.Entity.UpdatedAt = now; + e.Entity.Version += 1; + if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}"; + } + } + } + + private static void ConfigureEntityBase(ModelBuilder modelBuilder, string table) where T : EntityBase + { + var e = modelBuilder.Entity(); + e.ToTable(table); + e.HasKey(x => x.Id); + e.Property(x => x.CreatedBy).HasMaxLength(128); + e.Property(x => x.UpdatedBy).HasMaxLength(128); + e.Property(x => x.DeletedBy).HasMaxLength(128); + e.Property(x => x.Remark).HasMaxLength(1000); + e.Property(x => x.Extend).HasColumnType("text"); + e.HasQueryFilter(x => !x.IsDeleted); + } + + private static void ConfigureHistory(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase + { + var e = modelBuilder.Entity(); + e.ToTable(table); + e.HasKey(x => x.Id); + e.Property(x => x.EventType).HasMaxLength(64); + e.Property(x => x.BeforeJson).HasColumnType("text"); + e.Property(x => x.AfterJson).HasColumnType("text"); + e.Property(x => x.Operator).HasMaxLength(128); + e.Property(x => x.Source).HasMaxLength(32); + e.Property(x => x.Reason).HasMaxLength(500); + e.Property(x => x.Remark).HasMaxLength(1000); + e.Property(x => x.Extend).HasColumnType("text"); + e.HasIndex(x => x.ContainerId); + e.HasIndex(x => x.OperatedAt); + e.HasIndex(x => x.EventType); + } +} diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs new file mode 100644 index 0000000..4b09c7d --- /dev/null +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using MiGu.Server.Wms; + +namespace MiGu.Server.Persistence; + +public static class PlatformPersistence +{ + public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext((sp, options) => + { + var env = sp.GetRequiredService(); + var provider = configuration["Database:Provider"] ?? "sqlite"; + var connection = ResolveConnectionString(configuration, env, provider); + + switch (provider.Trim().ToLowerInvariant()) + { + case "sqlite": + options.UseSqlite(connection); + break; + case "mysql": + options.UseMySql(connection, ServerVersion.AutoDetect(connection)); + break; + case "postgres": + case "postgresql": + case "npgsql": + options.UseNpgsql(connection); + break; + case "sqlserver": + case "mssql": + options.UseSqlServer(connection); + break; + default: + throw new InvalidOperationException($"未知数据库 Provider: {provider}"); + } + }); + + services.AddScoped(); + services.AddScoped(); + return services; + } + + public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider) + { + var key = provider.Trim().ToLowerInvariant() switch + { + "postgres" or "postgresql" or "npgsql" => "PostgreSQL", + "mssql" => "SqlServer", + _ => provider + }; + var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured; + } + + var dataDir = Path.Combine(env.ContentRootPath, "data"); + Directory.CreateDirectory(dataDir); + return $"Data Source={Path.Combine(dataDir, "platform.db")}"; + } + + private static bool IsSqlite(string provider) => + string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase); + + private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env) + { + var builder = new SqliteConnectionStringBuilder(connection); + if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection; + if (builder.DataSource is ":memory:") return connection; + if (!Path.IsPathRooted(builder.DataSource)) + { + builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource); + } + var dir = Path.GetDirectoryName(builder.DataSource); + if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); + return builder.ToString(); + } +} diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index ef65798..a01362b 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -6,7 +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; static string? FindSourceContentRoot(string startDir) @@ -216,6 +216,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。 builder.Services.AddHttpClient(); +builder.Services.AddPlatformPersistence(builder.Configuration); // 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。 // 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。 @@ -223,6 +224,7 @@ builder.Services.Configure(builder.Configuration.GetSection(" builder.Services.AddSingleton(); var app = builder.Build(); +await app.Services.EnsurePlatformDatabaseAsync(); // 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。 _ = app.Services.GetRequiredService(); diff --git a/MiGu.Server/Wms/WmsModels.cs b/MiGu.Server/Wms/WmsModels.cs new file mode 100644 index 0000000..792857f --- /dev/null +++ b/MiGu.Server/Wms/WmsModels.cs @@ -0,0 +1,120 @@ +using System.ComponentModel.DataAnnotations; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Wms; + +public abstract class WarehouseHistoryBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? RelationId { get; set; } + public string EventType { get; set; } = ""; + public string BeforeJson { get; set; } = "{}"; + public string AfterJson { get; set; } = "{}"; + public Guid? ContainerId { get; set; } + public string Operator { get; set; } = ""; + public DateTimeOffset OperatedAt { get; set; } + public string Source { get; set; } = "Manual"; + public string Reason { get; set; } = ""; + public string Remark { get; set; } = ""; + public string Extend { get; set; } = "{}"; +} + +public sealed class WarehouseArea : EntityBase +{ + [MaxLength(64)] public string Code { get; set; } = ""; + [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(64)] public string Type { get; set; } = "Storage"; + public bool Enabled { get; set; } = true; + public int SortOrder { get; set; } +} + +public sealed class Storage : EntityBase +{ + public Guid AreaId { get; set; } + [MaxLength(64)] public string Code { get; set; } = ""; + [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(64)] public string StorageType { get; set; } = "Storage"; + [MaxLength(64)] public string SiteId { get; set; } = ""; + public int Capacity { get; set; } + public bool Enabled { get; set; } = true; +} + +public sealed class Container : EntityBase +{ + [MaxLength(64)] public string Code { get; set; } = ""; + [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(64)] public string ContainerType { get; set; } = "Box"; + [MaxLength(64)] public string Status { get; set; } = "Idle"; + public bool Enabled { get; set; } = true; +} + +public sealed class Material : EntityBase +{ + [MaxLength(64)] public string Code { get; set; } = ""; + [MaxLength(128)] public string Name { get; set; } = ""; + [MaxLength(128)] public string Spec { get; set; } = ""; + [MaxLength(32)] public string Unit { get; set; } = "pcs"; + [MaxLength(64)] public string Category { get; set; } = ""; + public bool Enabled { get; set; } = true; +} + +public sealed class ContainerLocation : EntityBase +{ + public Guid ContainerId { get; set; } + [MaxLength(32)] public string LocationType { get; set; } = ContainerLocationTypes.Storage; + [MaxLength(64)] public string LocationId { get; set; } = ""; + [MaxLength(64)] public string LocationCode { get; set; } = ""; + [MaxLength(128)] public string LocationName { get; set; } = ""; + [MaxLength(32)] public string Status { get; set; } = ContainerLocationStatuses.Active; + public DateTimeOffset EnteredAt { get; set; } +} + +public sealed class ContainerMaterial : EntityBase +{ + public Guid ContainerId { get; set; } + public Guid MaterialId { get; set; } + public decimal Quantity { get; set; } + [MaxLength(64)] public string BatchNo { get; set; } = ""; + [MaxLength(64)] public string SerialNo { get; set; } = ""; + [MaxLength(32)] public string Status { get; set; } = ContainerMaterialStatuses.Loaded; + public DateTimeOffset LoadedAt { get; set; } + public DateTimeOffset? UnloadedAt { get; set; } +} + +public sealed class ContainerLocationHistory : WarehouseHistoryBase +{ + [MaxLength(32)] public string FromLocationType { get; set; } = ""; + [MaxLength(64)] public string FromLocationId { get; set; } = ""; + [MaxLength(32)] public string ToLocationType { get; set; } = ""; + [MaxLength(64)] public string ToLocationId { get; set; } = ""; +} + +public sealed class ContainerMaterialHistory : WarehouseHistoryBase +{ + public Guid? MaterialId { get; set; } + public decimal QuantityDelta { get; set; } +} + +public static class ContainerLocationTypes +{ + public const string Storage = "Storage"; + public const string Car = "Car"; + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Storage, Car }; +} + +public static class ContainerLocationStatuses +{ + public const string Active = "Active"; + public const string Locked = "Locked"; + public const string Exception = "Exception"; + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Active, Locked, Exception }; +} + +public static class ContainerMaterialStatuses +{ + public const string Loaded = "Loaded"; + public const string Unloaded = "Unloaded"; + public const string Adjusted = "Adjusted"; + public const string Frozen = "Frozen"; + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) { Loaded, Unloaded, Adjusted, Frozen }; +} diff --git a/MiGu.Server/Wms/WmsReferenceValidator.cs b/MiGu.Server/Wms/WmsReferenceValidator.cs new file mode 100644 index 0000000..7e4427e --- /dev/null +++ b/MiGu.Server/Wms/WmsReferenceValidator.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Wms; + +public sealed class WmsReferenceValidator +{ + private readonly PlatformDbContext _db; + + public WmsReferenceValidator(PlatformDbContext db) + { + _db = db; + } + + public async Task EnsureAreaAsync(Guid id) + { + if (!await _db.WarehouseAreas.AnyAsync(x => x.Id == id)) + throw new InvalidOperationException("库区不存在"); + } + + public async Task EnsureStorageAsync(Guid id) + { + if (!await _db.Storages.AnyAsync(x => x.Id == id)) + throw new InvalidOperationException("库位不存在"); + } + + public async Task EnsureContainerAsync(Guid id) + { + if (!await _db.Containers.AnyAsync(x => x.Id == id)) + throw new InvalidOperationException("容器不存在"); + } + + public async Task EnsureMaterialAsync(Guid id) + { + if (!await _db.Materials.AnyAsync(x => x.Id == id)) + throw new InvalidOperationException("物料不存在"); + } + + public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId) + { + if (!ContainerLocationTypes.All.Contains(locationType)) + throw new InvalidOperationException("位置类型无效"); + if (string.IsNullOrWhiteSpace(locationId)) + throw new InvalidOperationException("位置 ID 不能为空"); + + if (string.Equals(locationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase)) + { + if (!Guid.TryParse(locationId, out var id)) throw new InvalidOperationException("库位 ID 格式无效"); + var s = await _db.Storages.FirstOrDefaultAsync(x => x.Id == id); + if (s == null) throw new InvalidOperationException("库位不存在"); + return (s.Code, s.Name); + } + + // 车辆来自现有调度/投影系统,首期不建库内外键,保留原始 ID 并作为快照展示。 + return (locationId, locationId); + } +} diff --git a/MiGu.Server/Wms/WmsService.cs b/MiGu.Server/Wms/WmsService.cs new file mode 100644 index 0000000..ff1afdd --- /dev/null +++ b/MiGu.Server/Wms/WmsService.cs @@ -0,0 +1,510 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using MiGu.Server.Persistence; + +namespace MiGu.Server.Wms; + +public sealed class WmsService +{ + private readonly PlatformDbContext _db; + private readonly WmsReferenceValidator _refs; + private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); + + public WmsService(PlatformDbContext db, WmsReferenceValidator refs) + { + _db = db; + _refs = refs; + } + + public Task> Areas(string? q = null) => + FilterByKeyword(_db.WarehouseAreas.AsNoTracking().OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync(); + + public Task> Storages(string? q = null) => + FilterByKeyword(_db.Storages.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + + public Task> Containers(string? q = null) => + FilterByKeyword(_db.Containers.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + + public Task> Materials(string? q = null) => + FilterByKeyword(_db.Materials.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync(); + + public Task> ContainerLocations(string? locationType = null, string? q = null) + { + var query = _db.ContainerLocations.AsNoTracking().OrderBy(x => x.ContainerId).AsQueryable(); + if (!string.IsNullOrWhiteSpace(locationType)) query = query.Where(x => x.LocationType == locationType); + return FilterByKeyword(query, q).ToListAsync(); + } + + public Task> ContainerMaterials(string? q = null) => + FilterByKeyword(_db.ContainerMaterials.AsNoTracking().OrderBy(x => x.ContainerId), q).ToListAsync(); + + public async Task SaveArea(MasterDataRequest req, string actor) + { + WarehouseArea entity; + if (req.Id.HasValue) + { + entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version); + } + else + { + entity = new WarehouseArea(); + StampCreate(entity, actor); + _db.WarehouseAreas.Add(entity); + } + await EnsureUnique(_db.WarehouseAreas, x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在"); + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.Type = req.Type.TrimOr("Storage"); + entity.Enabled = req.Enabled; + entity.SortOrder = req.SortOrder; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task SaveStorage(StorageRequest req, string actor) + { + await _refs.EnsureAreaAsync(req.AreaId); + Storage entity; + if (req.Id.HasValue) + { + entity = await FindEditable(_db.Storages, req.Id.Value, req.Version); + } + else + { + entity = new Storage(); + StampCreate(entity, actor); + _db.Storages.Add(entity); + } + await EnsureUnique(_db.Storages, x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在"); + entity.AreaId = req.AreaId; + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.StorageType = req.StorageType.TrimOr("Storage"); + entity.SiteId = req.SiteId.TrimOr(""); + entity.Capacity = req.Capacity; + entity.Enabled = req.Enabled; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task SaveContainer(MasterDataRequest req, string actor) + { + Container entity; + if (req.Id.HasValue) + { + entity = await FindEditable(_db.Containers, req.Id.Value, req.Version); + } + else + { + entity = new Container(); + StampCreate(entity, actor); + _db.Containers.Add(entity); + } + await EnsureUnique(_db.Containers, x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在"); + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.ContainerType = req.Type.TrimOr("Box"); + entity.Status = req.Status.TrimOr("Idle"); + entity.Enabled = req.Enabled; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task SaveMaterial(MaterialRequest req, string actor) + { + Material entity; + if (req.Id.HasValue) + { + entity = await FindEditable(_db.Materials, req.Id.Value, req.Version); + } + else + { + entity = new Material(); + StampCreate(entity, actor); + _db.Materials.Add(entity); + } + await EnsureUnique(_db.Materials, x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在"); + entity.Code = req.Code.Trim(); + entity.Name = req.Name.Trim(); + entity.Spec = req.Spec.TrimOr(""); + entity.Unit = req.Unit.TrimOr("pcs"); + entity.Category = req.Category.TrimOr(""); + entity.Enabled = req.Enabled; + ApplyCommon(entity, req, actor); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task DeleteEntity(Guid id, long? version, string actor) where T : EntityBase + { + var set = _db.Set(); + var entity = await FindEditable(set, id, version); + entity.IsDeleted = true; + entity.DeletedAt = DateTimeOffset.UtcNow; + entity.DeletedBy = actor; + entity.UpdatedBy = actor; + await _db.SaveChangesAsync(); + } + + public async Task BindOrTransferLocation(ContainerLocationRequest req, string actor) + { + await _refs.EnsureContainerAsync(req.ContainerId); + var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId); + if (!ContainerLocationStatuses.All.Contains(req.Status)) + throw new InvalidOperationException("容器位置状态无效"); + + var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId); + var before = current == null ? null : Snapshot(current); + var now = DateTimeOffset.UtcNow; + + if (current == null) + { + current = new ContainerLocation { ContainerId = req.ContainerId, EnteredAt = now }; + StampCreate(current, actor); + _db.ContainerLocations.Add(current); + } + else + { + EnsureVersion(current, req.Version); + EnsureUnlocked(current); + } + + current.LocationType = req.LocationType; + current.LocationId = req.LocationId.Trim(); + current.LocationCode = code; + current.LocationName = name; + current.Status = req.Status; + current.EnteredAt = req.EnteredAt ?? now; + ApplyCommon(current, req, actor); + + _db.ContainerLocationHistories.Add(new ContainerLocationHistory + { + RelationId = current.Id, + ContainerId = current.ContainerId, + EventType = before == null ? "Bind" : "Transfer", + FromLocationType = before?.LocationType ?? "", + FromLocationId = before?.LocationId ?? "", + ToLocationType = current.LocationType, + ToLocationId = current.LocationId, + BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json), + AfterJson = JsonSerializer.Serialize(Snapshot(current), _json), + Operator = actor, + OperatedAt = now, + Source = req.Source.TrimOr("Manual"), + Reason = req.Reason.TrimOr(""), + Remark = req.Remark.TrimOr(""), + Extend = NormalizeExtend(req.Extend) + }); + + await _db.SaveChangesAsync(); + return current; + } + + public async Task UnbindLocation(Guid containerId, string actor, string reason = "") + { + var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId) + ?? throw new InvalidOperationException("容器当前位置不存在"); + EnsureUnlocked(current); + var before = Snapshot(current); + _db.ContainerLocationHistories.Add(new ContainerLocationHistory + { + RelationId = current.Id, + ContainerId = current.ContainerId, + EventType = "Unbind", + FromLocationType = current.LocationType, + FromLocationId = current.LocationId, + BeforeJson = JsonSerializer.Serialize(before, _json), + AfterJson = "{}", + Operator = actor, + OperatedAt = DateTimeOffset.UtcNow, + Source = "Manual", + Reason = reason + }); + _db.ContainerLocations.Remove(current); + await _db.SaveChangesAsync(); + } + + public async Task SaveContainerMaterial(ContainerMaterialRequest req, string actor) + { + await _refs.EnsureContainerAsync(req.ContainerId); + await _refs.EnsureMaterialAsync(req.MaterialId); + if (req.Quantity <= 0) throw new InvalidOperationException("数量必须大于 0"); + if (!ContainerMaterialStatuses.All.Contains(req.Status)) + throw new InvalidOperationException("容器物料状态无效"); + + var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => + x.ContainerId == req.ContainerId && x.MaterialId == req.MaterialId && + x.BatchNo == req.BatchNo.TrimOr("") && x.SerialNo == req.SerialNo.TrimOr("")); + var before = current == null ? null : Snapshot(current); + + if (current == null) + { + current = new ContainerMaterial { ContainerId = req.ContainerId, MaterialId = req.MaterialId, LoadedAt = req.LoadedAt ?? DateTimeOffset.UtcNow }; + StampCreate(current, actor); + _db.ContainerMaterials.Add(current); + } + else + { + EnsureVersion(current, req.Version); + EnsureUnlocked(current); + } + + var oldQty = current.Quantity; + current.Quantity = req.Quantity; + current.BatchNo = req.BatchNo.TrimOr(""); + current.SerialNo = req.SerialNo.TrimOr(""); + current.Status = req.Status; + current.LoadedAt = req.LoadedAt ?? current.LoadedAt; + current.UnloadedAt = req.UnloadedAt; + ApplyCommon(current, req, actor); + + _db.ContainerMaterialHistories.Add(new ContainerMaterialHistory + { + RelationId = current.Id, + ContainerId = current.ContainerId, + MaterialId = current.MaterialId, + EventType = before == null ? "Load" : "Adjust", + QuantityDelta = current.Quantity - oldQty, + BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json), + AfterJson = JsonSerializer.Serialize(Snapshot(current), _json), + Operator = actor, + OperatedAt = DateTimeOffset.UtcNow, + Source = req.Source.TrimOr("Manual"), + Reason = req.Reason.TrimOr(""), + Remark = req.Remark.TrimOr(""), + Extend = NormalizeExtend(req.Extend) + }); + + await _db.SaveChangesAsync(); + return current; + } + + public async Task UnloadMaterial(Guid id, string actor, string reason = "") + { + var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new InvalidOperationException("容器物料不存在"); + EnsureUnlocked(current); + var before = Snapshot(current); + _db.ContainerMaterialHistories.Add(new ContainerMaterialHistory + { + RelationId = current.Id, + ContainerId = current.ContainerId, + MaterialId = current.MaterialId, + EventType = "Unload", + QuantityDelta = -current.Quantity, + BeforeJson = JsonSerializer.Serialize(before, _json), + AfterJson = "{}", + Operator = actor, + OperatedAt = DateTimeOffset.UtcNow, + Source = "Manual", + Reason = reason + }); + _db.ContainerMaterials.Remove(current); + await _db.SaveChangesAsync(); + } + + public async Task> LocationHistory(Guid? containerId = null) + { + var rows = await (containerId.HasValue + ? _db.ContainerLocationHistories.AsNoTracking().Where(x => x.ContainerId == containerId.Value) + : _db.ContainerLocationHistories.AsNoTracking()) + .ToListAsync(); + + return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList(); + } + + public async Task> MaterialHistory(Guid? containerId = null, Guid? materialId = null) + { + var query = _db.ContainerMaterialHistories.AsNoTracking().AsQueryable(); + if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value); + if (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value); + + var rows = await query.ToListAsync(); + return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList(); + } + + private async Task FindEditable(DbSet set, Guid id, long? version) where T : EntityBase + { + var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在"); + EnsureVersion(entity, version); + EnsureUnlocked(entity); + return entity; + } + + private static void EnsureVersion(EntityBase entity, long? version) + { + if (version.HasValue && entity.Version != version.Value) + throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试"); + } + + private static void EnsureUnlocked(EntityBase entity) + { + if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改"); + } + + private static void StampCreate(EntityBase entity, string actor) + { + entity.CreatedBy = actor; + entity.UpdatedBy = actor; + } + + private void ApplyCommon(EntityBase entity, CommonRequest req, string actor) + { + entity.IsLock = req.IsLock; + entity.Remark = req.Remark.TrimOr(""); + entity.Extend = NormalizeExtend(req.Extend); + entity.UpdatedBy = actor; + } + + private static async Task EnsureUnique(IQueryable query, System.Linq.Expressions.Expression> predicate, string message) + { + if (await query.AnyAsync(predicate)) throw new InvalidOperationException(message); + } + + private string NormalizeExtend(string? extend) + { + if (string.IsNullOrWhiteSpace(extend)) return "{}"; + if (extend.Length > 4000) throw new InvalidOperationException("扩展字段过长"); + using var doc = JsonDocument.Parse(extend); + if (doc.RootElement.ValueKind != JsonValueKind.Object) throw new InvalidOperationException("扩展字段必须是 JSON object"); + return extend; + } + + private static IQueryable FilterByKeyword(IQueryable query, string? q) + { + if (string.IsNullOrWhiteSpace(q)) return query; + var s = q.Trim(); + return typeof(T).Name switch + { + nameof(WarehouseArea) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), + nameof(Storage) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.SiteId.Contains(s)), + nameof(Container) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)), + nameof(Material) => (IQueryable)((IQueryable)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Spec.Contains(s)), + nameof(ContainerLocation) => (IQueryable)((IQueryable)query).Where(x => x.LocationCode.Contains(s) || x.LocationName.Contains(s)), + nameof(ContainerMaterial) => query, + _ => query + }; + } + + private static ContainerLocationSnapshot Snapshot(ContainerLocation x) => new( + x.Id, x.ContainerId, x.LocationType, x.LocationId, x.LocationCode, x.LocationName, x.Status, x.EnteredAt, x.Version); + + private static ContainerMaterialSnapshot Snapshot(ContainerMaterial x) => new( + x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status, x.LoadedAt, x.UnloadedAt, x.Version); +} + +public sealed record ContainerLocationSnapshot( + Guid Id, + Guid ContainerId, + string LocationType, + string LocationId, + string LocationCode, + string LocationName, + string Status, + DateTimeOffset EnteredAt, + long Version); + +public sealed record ContainerMaterialSnapshot( + Guid Id, + Guid ContainerId, + Guid MaterialId, + decimal Quantity, + string BatchNo, + string SerialNo, + string Status, + DateTimeOffset LoadedAt, + DateTimeOffset? UnloadedAt, + long Version); + +public abstract record CommonRequest( + Guid? Id, + long? Version, + bool IsLock, + string Remark, + string Extend); + +public sealed record MasterDataRequest( + Guid? Id, + long? Version, + string Code, + string Name, + string Type, + string Status, + bool Enabled, + int SortOrder, + bool IsLock, + string Remark, + string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + +public sealed record StorageRequest( + Guid? Id, + long? Version, + Guid AreaId, + string Code, + string Name, + string StorageType, + string SiteId, + int Capacity, + bool Enabled, + bool IsLock, + string Remark, + string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + +public sealed record MaterialRequest( + Guid? Id, + long? Version, + string Code, + string Name, + string Spec, + string Unit, + string Category, + bool Enabled, + bool IsLock, + string Remark, + string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + +public sealed record ContainerLocationRequest( + Guid? Id, + long? Version, + Guid ContainerId, + string LocationType, + string LocationId, + string Status, + DateTimeOffset? EnteredAt, + string Source, + string Reason, + bool IsLock, + string Remark, + string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + +public sealed record ContainerMaterialRequest( + Guid? Id, + long? Version, + Guid ContainerId, + Guid MaterialId, + decimal Quantity, + string BatchNo, + string SerialNo, + string Status, + DateTimeOffset? LoadedAt, + DateTimeOffset? UnloadedAt, + string Source, + string Reason, + bool IsLock, + string Remark, + string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend); + +public static class WmsStringExtensions +{ + public static string TrimOr(this string? value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + public static string ActorName(this ClaimsPrincipal user) => + user.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.UniqueName)?.Value + ?? user.Identity?.Name + ?? "system"; +} diff --git a/MiGu.Server/data/.internal-token b/MiGu.Server/data/.internal-token new file mode 100644 index 0000000..a014e5f --- /dev/null +++ b/MiGu.Server/data/.internal-token @@ -0,0 +1 @@ +Gl4ghnjUoi2/dEh1Uv4DE5qjuqefIqSpVwg5/ZbIlvdq1g93+vFuwX30N+NRSdzU \ No newline at end of file diff --git a/MiGu.Server/data/config-auth.json b/MiGu.Server/data/config-auth.json new file mode 100644 index 0000000..3dcb70b --- /dev/null +++ b/MiGu.Server/data/config-auth.json @@ -0,0 +1,61 @@ +{ + "section": "auth", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4979022+00:00", + "payload": { + "roles": [ + { + "id": "role-admin", + "name": "\u7BA1\u7406\u5458", + "scope": "Platform", + "permissions": [ + "*" + ], + "widgetGrants": [] + }, + { + "id": "role-ops", + "name": "\u8FD0\u8425", + "scope": "RCSMonitor", + "permissions": [ + "ops.car.pause", + "ops.car.resume", + "ops.car.gohome", + "ops.task.pause", + "ops.task.cancel", + "ops.task.reassign", + "ops.task.boostPriority", + "monitor.note.write" + ], + "widgetGrants": [ + { + "widgetId": "MapEditor", + "visibility": "readonly" + }, + { + "widgetId": "CadToolbar", + "visibility": "hidden" + } + ] + } + ], + "users": [ + { + "id": "u-admin", + "username": "admin", + "roles": [ + "role-admin" + ], + "enabled": true + }, + { + "id": "u-ops", + "username": "ops", + "roles": [ + "role-ops" + ], + "enabled": true + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-charge.json b/MiGu.Server/data/config-charge.json new file mode 100644 index 0000000..5b282f3 --- /dev/null +++ b/MiGu.Server/data/config-charge.json @@ -0,0 +1,21 @@ +{ + "section": "charge", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4849334+00:00", + "payload": { + "allowMidTaskCharge": false, + "idleChargeAfterSec": 300, + "priority": [ + { + "id": "CP1", + "condition": "soc\u003C0.2", + "weight": 100 + }, + { + "id": "CP2", + "condition": "idle\u003E5min", + "weight": 30 + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-deployment.json b/MiGu.Server/data/config-deployment.json new file mode 100644 index 0000000..51ffa1e --- /dev/null +++ b/MiGu.Server/data/config-deployment.json @@ -0,0 +1,20 @@ +{ + "section": "deployment", + "version": 7, + "updatedAt": "2026-06-09T01:43:46.9419888+00:00", + "payload": { + "configured": true, + "platformType": "standard", + "modules": [ + "wms" + ], + "navigationKinds": [ + "qrcode", + "laser" + ], + "scenarios": [ + "tpl-p2p" + ], + "updatedBy": "admin" + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-device.json b/MiGu.Server/data/config-device.json new file mode 100644 index 0000000..b9207cb --- /dev/null +++ b/MiGu.Server/data/config-device.json @@ -0,0 +1,64 @@ +{ + "section": "device", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5034914+00:00", + "payload": { + "drivers": [ + { + "id": "drv-elev", + "deviceType": "\u7535\u68AF", + "driverName": "OpcUaElevatorDriver", + "version": "1.2.0" + }, + { + "id": "drv-chrg", + "deviceType": "\u5145\u7535\u6869", + "driverName": "ModbusChargerDriver", + "version": "1.0.5" + }, + { + "id": "drv-cam", + "deviceType": "\u6444\u50CF\u5934", + "driverName": "OnvifCameraDriver", + "version": "2.1.0" + } + ], + "devices": [ + { + "id": "dev-elev-1", + "name": "#1 \u7535\u68AF", + "deviceType": "\u7535\u68AF", + "protocol": "opc-ua", + "address": "opc.tcp://10.0.2.20:4840", + "driverId": "drv-elev", + "enabled": true + }, + { + "id": "dev-chrg-1", + "name": "\u5145\u7535\u6869-A1", + "deviceType": "\u5145\u7535\u6869", + "protocol": "modbus-tcp", + "address": "10.0.2.30:502", + "driverId": "drv-chrg", + "enabled": true + } + ], + "healthPolicy": { + "heartbeatSec": 5, + "offlineSec": 30 + }, + "alarmPolicy": { + "enabled": true, + "rules": [ + { + "level": "warn", + "condition": "offline\u003E30s" + }, + { + "level": "error", + "condition": "driverException" + } + ] + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-fleet.json b/MiGu.Server/data/config-fleet.json new file mode 100644 index 0000000..c0e03f1 --- /dev/null +++ b/MiGu.Server/data/config-fleet.json @@ -0,0 +1,43 @@ +{ + "section": "fleet", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5109848+00:00", + "payload": { + "groups": [ + { + "id": "G-A", + "name": "A \u533A\u8F66\u961F", + "floor": "F1", + "region": "A", + "carIds": [ + "C01", + "C02", + "C03" + ] + }, + { + "id": "G-B", + "name": "B \u533A\u8F66\u961F", + "floor": "F1", + "region": "B", + "carIds": [ + "C04", + "C05" + ] + } + ], + "ota": { + "enabled": true, + "batchSize": 2, + "rollbackOnFail": true + }, + "batchOps": { + "confirmationRequired": true, + "maxBatch": 10 + }, + "networkDiag": { + "rttThresholdMs": 80, + "packetLossThreshold": 0.02 + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-integrations.json b/MiGu.Server/data/config-integrations.json new file mode 100644 index 0000000..041c060 --- /dev/null +++ b/MiGu.Server/data/config-integrations.json @@ -0,0 +1,32 @@ +{ + "section": "integrations", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4625787+00:00", + "payload": { + "mes": [ + { + "id": "mes-1", + "name": "MES \u4E3B\u7EBF", + "url": "http://mes.lan/api", + "enabled": true + } + ], + "wms": [ + { + "id": "wms-1", + "name": "WMS \u4ED3\u50A8", + "url": "http://wms.lan/api", + "enabled": true + } + ], + "rcs": [], + "ptl": [ + { + "id": "ptl-1", + "name": "PTL \u62E3\u9009", + "url": "http://ptl.lan/api", + "enabled": true + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-location.json b/MiGu.Server/data/config-location.json new file mode 100644 index 0000000..a4b5d3a --- /dev/null +++ b/MiGu.Server/data/config-location.json @@ -0,0 +1,41 @@ +{ + "section": "location", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5220964+00:00", + "payload": { + "locations": [ + { + "id": "L01", + "code": "A-01", + "name": "A \u533A\u8D27\u67B6 1", + "siteId": "S001", + "capacity": 20, + "occupied": 12 + }, + { + "id": "L02", + "code": "A-02", + "name": "A \u533A\u8D27\u67B6 2", + "siteId": "S002", + "capacity": 20, + "occupied": 7 + }, + { + "id": "L03", + "code": "B-01", + "name": "B \u533A\u7F13\u5B58", + "siteId": "S003", + "capacity": 30, + "occupied": 25 + } + ], + "inventoryRules": [ + { + "id": "IR1", + "itemType": "PalletA", + "minQty": 5, + "maxQty": 30 + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-ops.json b/MiGu.Server/data/config-ops.json new file mode 100644 index 0000000..5fcae8d --- /dev/null +++ b/MiGu.Server/data/config-ops.json @@ -0,0 +1,36 @@ +{ + "section": "ops", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5273514+00:00", + "payload": { + "playback": { + "retentionDays": 30, + "samplingHz": 5 + }, + "logRetention": { + "hotDays": 7, + "coldDays": 180 + }, + "version": { + "keepReleases": 5 + }, + "monitor": { + "car": { + "propertyKeys": [], + "statusKeys": [], + "actionKeys": [] + }, + "site": { + "propertyKeys": [], + "statusKeys": [], + "actionKeys": [] + }, + "track": { + "propertyKeys": [], + "statusKeys": [], + "actionKeys": [] + }, + "carActionByType": {} + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-routing.json b/MiGu.Server/data/config-routing.json new file mode 100644 index 0000000..e125d4c --- /dev/null +++ b/MiGu.Server/data/config-routing.json @@ -0,0 +1,26 @@ +{ + "section": "routing", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4685445+00:00", + "payload": { + "algorithm": "astar", + "weights": { + "distance": 1, + "congestion": 0.5, + "turnPenalty": 0.2 + }, + "avoidance": [ + { + "id": "AV1", + "zoneId": "Z-NORTH", + "rule": "no-entry-while-loading" + } + ], + "zoneSpeedLimits": [ + { + "zoneId": "Z-NARROW", + "maxSpeedMps": 0.5 + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-scenario.json b/MiGu.Server/data/config-scenario.json new file mode 100644 index 0000000..3bb2869 --- /dev/null +++ b/MiGu.Server/data/config-scenario.json @@ -0,0 +1,49 @@ +{ + "section": "scenario", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5171692+00:00", + "payload": { + "templates": [ + { + "id": "tpl-sps", + "name": "SPS \u7269\u6599\u914D\u9001", + "category": "SPS", + "version": "1.0.0", + "baselineJson": "{}" + }, + { + "id": "tpl-pack", + "name": "\u7535\u6C60 Pack \u81EA\u52A8\u5316\u4EA7\u7EBF", + "category": "BatteryPack", + "version": "1.0.0", + "baselineJson": "{}" + }, + { + "id": "tpl-loop", + "name": "\u73AF\u7EBF\u8FD0\u884C", + "category": "Loop", + "version": "1.0.0", + "baselineJson": "{}" + }, + { + "id": "tpl-p2p", + "name": "\u70B9\u5BF9\u70B9\u67D4\u6027\u642C\u8FD0", + "category": "P2P", + "version": "1.0.0", + "baselineJson": "{}" + } + ], + "dslPolicy": { + "enabled": true, + "schemaVersion": "1" + }, + "lowCode": { + "enabled": false, + "editor": "json" + }, + "versionPolicy": { + "keepVersions": 10, + "allowRollback": true + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-system.json b/MiGu.Server/data/config-system.json new file mode 100644 index 0000000..f37b3fe --- /dev/null +++ b/MiGu.Server/data/config-system.json @@ -0,0 +1,20 @@ +{ + "section": "system", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4470202+00:00", + "payload": { + "dispatchLoopHz": 50, + "log": { + "level": "info", + "rollDays": 7, + "maxSizeMB": 256 + }, + "security": { + "jwtExpireMin": 1440, + "enableSwagger": false, + "corsWhitelist": [ + "http://localhost:5173" + ] + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-task.json b/MiGu.Server/data/config-task.json new file mode 100644 index 0000000..1b57f9d --- /dev/null +++ b/MiGu.Server/data/config-task.json @@ -0,0 +1,10 @@ +{ + "section": "task", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4893595+00:00", + "payload": { + "mode": "leastLoad", + "loadBalance": true, + "maxQueuePerCar": 3 + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-traffic.json b/MiGu.Server/data/config-traffic.json new file mode 100644 index 0000000..d275892 --- /dev/null +++ b/MiGu.Server/data/config-traffic.json @@ -0,0 +1,33 @@ +{ + "section": "traffic", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4927371+00:00", + "payload": { + "intersections": [ + { + "id": "IX1", + "siteIds": [ + "S006", + "S007" + ], + "mode": "mutex" + } + ], + "mutex": [ + { + "id": "MZ1", + "zoneIds": [ + "Z-CROSS" + ] + } + ], + "yields": [ + { + "id": "YD1", + "from": "A \u533A", + "to": "B \u533A", + "condition": "priority\u003Cpeer" + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-vehicle.json b/MiGu.Server/data/config-vehicle.json new file mode 100644 index 0000000..5c22205 --- /dev/null +++ b/MiGu.Server/data/config-vehicle.json @@ -0,0 +1,19 @@ +{ + "section": "vehicle", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.4789283+00:00", + "payload": { + "lowBatteryThreshold": 0.3, + "criticalBatteryThreshold": 0.15, + "faultReport": { + "enabled": true, + "emailTo": [ + "ops@example.com" + ] + }, + "autoRepair": { + "enabled": false, + "cooldownSec": 600 + } + } +} \ No newline at end of file diff --git a/MiGu.Server/data/config-widget.json b/MiGu.Server/data/config-widget.json new file mode 100644 index 0000000..8d6c5c1 --- /dev/null +++ b/MiGu.Server/data/config-widget.json @@ -0,0 +1,18 @@ +{ + "section": "widget", + "version": 1, + "updatedAt": "2026-06-08T09:43:37.5362337+00:00", + "payload": { + "items": [ + { + "id": "widget-call-button", + "name": "\u547C\u53EB\u6309\u94AE", + "schemaJson": "{\u0022fields\u0022:[{\u0022name\u0022:\u0022siteId\u0022}]}", + "layoutJson": "{\u0022x\u0022:0,\u0022y\u0022:0,\u0022w\u0022:2,\u0022h\u0022:1}", + "bindToScopes": [ + "RCSMonitor" + ] + } + ] + } +} \ No newline at end of file diff --git a/MiGu.Server/data/platform.db b/MiGu.Server/data/platform.db new file mode 100644 index 0000000..3eff1fb Binary files /dev/null and b/MiGu.Server/data/platform.db differ diff --git a/MiGu.Server/data/rbac.json b/MiGu.Server/data/rbac.json new file mode 100644 index 0000000..26f5066 --- /dev/null +++ b/MiGu.Server/data/rbac.json @@ -0,0 +1,94 @@ +{ + "version": 1, + "roles": [ + { + "id": "role-admin", + "name": "\u8D85\u7EA7\u7BA1\u7406\u5458", + "description": "\u62E5\u6709\u5168\u90E8\u9875\u9762\u4E0E\u64CD\u4F5C\u6743\u9650\u7684\u5185\u7F6E\u89D2\u8272", + "scope": "*", + "pages": [ + "*" + ], + "ops": [ + "*" + ], + "widgetGrants": [], + "system": true + }, + { + "id": "role-ops", + "name": "\u8FD0\u8425\u4EBA\u5458", + "description": "\u8FD0\u8425\u76D1\u63A7\u7AEF\u9ED8\u8BA4\u89D2\u8272\uFF1A\u53EF\u6267\u884C\u8FD0\u7EF4\u64CD\u4F5C\u3001\u67E5\u770B\u76D1\u63A7", + "scope": "RCSMonitor", + "pages": [ + "monitor-dashboard", + "monitor-map", + "monitor-ops", + "monitor-notes" + ], + "ops": [ + "ops.car.pause", + "ops.car.resume", + "ops.car.gohome", + "ops.car.resetSession", + "ops.car.manualCharge", + "ops.task.pause", + "ops.task.cancel", + "ops.task.reassign", + "ops.task.boostPriority", + "monitor.note.write" + ], + "widgetGrants": [ + { + "widgetId": "MapEditor", + "visibility": "readonly" + }, + { + "widgetId": "CadToolbar", + "visibility": "hidden" + }, + { + "widgetId": "CarPanel", + "visibility": "readonly" + }, + { + "widgetId": "MissionEditor", + "visibility": "readonly" + }, + { + "widgetId": "OpsActionPanel", + "visibility": "interactive" + }, + { + "widgetId": "ConfigCenter", + "visibility": "hidden" + } + ], + "system": true + } + ], + "users": [ + { + "id": "u-admin", + "username": "admin", + "displayName": "\u7CFB\u7EDF\u7BA1\u7406\u5458", + "enabled": true, + "roleIds": [ + "role-admin" + ], + "salt": "pQlotjtkEe0S5MTxecJd4A==", + "passwordHash": "qs6A3I/Y3OwV\u002Bs95orIWvzcUz8bF4ZY9l1jq4SQd1cI=" + }, + { + "id": "u-ops", + "username": "ops", + "displayName": "\u8FD0\u8425\u4EBA\u5458", + "enabled": true, + "roleIds": [ + "role-ops" + ], + "salt": "G6c5\u002BnLEjf3fEor1LgNjyg==", + "passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg=" + } + ] +} \ No newline at end of file diff --git a/frontends/apps/simple-platform-vue/auto-imports.d.ts b/frontends/apps/simple-platform-vue/auto-imports.d.ts deleted file mode 100644 index 9d24007..0000000 --- a/frontends/apps/simple-platform-vue/auto-imports.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable */ -/* prettier-ignore */ -// @ts-nocheck -// noinspection JSUnusedGlobalSymbols -// Generated by unplugin-auto-import -// biome-ignore lint: disable -export {} -declare global { - -} diff --git a/frontends/apps/simple-platform-vue/src/api/wms.ts b/frontends/apps/simple-platform-vue/src/api/wms.ts new file mode 100644 index 0000000..cdac737 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/wms.ts @@ -0,0 +1,101 @@ +import http from './http' +import type { + WarehouseArea, Storage, Container, Material, + ContainerLocation, ContainerMaterial, ContainerLocationHistory, ContainerMaterialHistory, + MasterDataPayload, StoragePayload, MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload +} from '@/types/wms' + +function q(params?: Record) { + return { params } +} + +export async function listAreas(keyword?: string) { + const { data } = await http.get('/wms/areas', q({ q: keyword })) + return data +} +export async function saveArea(payload: MasterDataPayload) { + const { data } = payload.id + ? await http.put(`/wms/areas/${payload.id}`, payload) + : await http.post('/wms/areas', payload) + return data +} +export async function deleteArea(id: string, version?: number) { + await http.delete(`/wms/areas/${id}`, q({ version })) +} + +export async function listStorages(keyword?: string) { + const { data } = await http.get('/wms/storages', q({ q: keyword })) + return data +} +export async function saveStorage(payload: StoragePayload) { + const { data } = payload.id + ? await http.put(`/wms/storages/${payload.id}`, payload) + : await http.post('/wms/storages', payload) + return data +} +export async function deleteStorage(id: string, version?: number) { + await http.delete(`/wms/storages/${id}`, q({ version })) +} + +export async function listContainers(keyword?: string) { + const { data } = await http.get('/wms/containers', q({ q: keyword })) + return data +} +export async function saveContainer(payload: MasterDataPayload) { + const { data } = payload.id + ? await http.put(`/wms/containers/${payload.id}`, payload) + : await http.post('/wms/containers', payload) + return data +} +export async function deleteContainer(id: string, version?: number) { + await http.delete(`/wms/containers/${id}`, q({ version })) +} + +export async function listMaterials(keyword?: string) { + const { data } = await http.get('/wms/materials', q({ q: keyword })) + return data +} +export async function saveMaterial(payload: MaterialPayload) { + const { data } = payload.id + ? await http.put(`/wms/materials/${payload.id}`, payload) + : await http.post('/wms/materials', payload) + return data +} +export async function deleteMaterial(id: string, version?: number) { + await http.delete(`/wms/materials/${id}`, q({ version })) +} + +export async function listContainerLocations(params?: { locationType?: string; q?: string }) { + const { data } = await http.get('/wms/container-locations', q(params)) + return data +} +export async function saveContainerLocation(payload: ContainerLocationPayload) { + const { data } = await http.post('/wms/container-locations', payload) + return data +} +export async function deleteContainerLocation(containerId: string, reason?: string) { + await http.delete(`/wms/container-locations/${containerId}`, q({ reason })) +} + +export async function listContainerMaterials(keyword?: string) { + const { data } = await http.get('/wms/container-materials', q({ q: keyword })) + return data +} +export async function saveContainerMaterial(payload: ContainerMaterialPayload) { + const { data } = payload.id + ? await http.put(`/wms/container-materials/${payload.id}`, payload) + : await http.post('/wms/container-materials', payload) + return data +} +export async function deleteContainerMaterial(id: string, reason?: string) { + await http.delete(`/wms/container-materials/${id}`, q({ reason })) +} + +export async function listContainerLocationHistory(containerId?: string) { + const { data } = await http.get('/wms/container-location-history', q({ containerId })) + return data +} +export async function listContainerMaterialHistory(containerId?: string, materialId?: string) { + const { data } = await http.get('/wms/container-material-history', q({ containerId, materialId })) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue b/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue index 20534b6..8e14e3b 100644 --- a/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue +++ b/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue @@ -75,8 +75,9 @@ function onDragStart(e: DragEvent, type: string) { diff --git a/frontends/apps/simple-platform-vue/src/views/admin/OrchestrationView.vue b/frontends/apps/simple-platform-vue/src/views/admin/OrchestrationView.vue new file mode 100644 index 0000000..ebd8c27 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/OrchestrationView.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue b/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue new file mode 100644 index 0000000..9e461a0 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/admin/WarehouseManagementView.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts b/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts index a4adff3..88ef76e 100644 --- a/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts +++ b/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts @@ -1,12 +1,12 @@ import type { ParamFieldDef } from '@/types/workflow' export const ASSIGN_STRATEGY_OPTIONS = [ - { label: '指定车辆', value: 'fixed' }, - { label: '指定类型', value: 'byType' } + { label: '指定车辆', value: 'carId' }, + { label: '指定类型', value: 'carType' } ] as const -const FIXED = { key: 'strategy', equals: 'fixed' as const } -const BY_TYPE = { key: 'strategy', equals: 'byType' as const } +const BY_CAR_ID = { key: 'strategy', equals: 'carId' as const } +const BY_CAR_TYPE = { key: 'strategy', equals: 'carType' as const } /** 分配车辆:指定车辆 / 指定类型 */ export function assignCarParams(): ParamFieldDef[] { @@ -16,7 +16,7 @@ export function assignCarParams(): ParamFieldDef[] { label: '分配策略', type: 'select', required: true, - defaultValue: 'fixed', + defaultValue: 'carId', options: [...ASSIGN_STRATEGY_OPTIONS] }, { @@ -25,7 +25,7 @@ export function assignCarParams(): ParamFieldDef[] { type: 'string', required: true, placeholder: '如 C01', - when: FIXED + when: BY_CAR_ID }, { key: 'carType', @@ -33,7 +33,7 @@ export function assignCarParams(): ParamFieldDef[] { type: 'string', required: true, placeholder: '如 Forklift / SimpleLite.RCS.Cars.AgvCar', - when: BY_TYPE + when: BY_CAR_TYPE } ] } diff --git a/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts b/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts index b581553..3d1f0c0 100644 --- a/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts +++ b/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts @@ -132,7 +132,7 @@ export const DEMO_WORKFLOW: WorkflowDefinition = { viewport: { x: 0, y: 0, zoom: 1 }, nodes: [ { id: 'n1', type: 'start', label: '开始', position: { x: 60, y: 200 }, params: {} }, - { id: 'n2', type: 'assignCar', label: '分配车辆', position: { x: 240, y: 180 }, params: { strategy: 'fixed', carId: 'C01' } }, + { id: 'n2', type: 'assignCar', label: '分配车辆', position: { x: 240, y: 180 }, params: { strategy: 'carId', carId: 'C01' } }, { id: 'n3', type: 'gotoSite', label: '前往入库点', position: { x: 460, y: 180 }, params: { siteId: 'S001' } }, { id: 'n4', @@ -140,7 +140,7 @@ export const DEMO_WORKFLOW: WorkflowDefinition = { label: '取货', position: { x: 680, y: 180 }, params: { - actionType: 'pick', + actionType: 'fetch', actionParameters: '[{"key":"loadType","value":"pallet"}]' } }, diff --git a/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts b/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts index af6cc4e..605a25d 100644 --- a/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts +++ b/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts @@ -2,9 +2,8 @@ import type { ParamFieldDef } from '@/types/workflow' /** 动作类型选项 */ export const ACTION_TYPE_OPTIONS = [ - { label: '取货', value: 'pick' }, - { label: '放货', value: 'drop' }, - { label: '充电', value: 'startCharging' } + { label: '取货', value: 'fetch' }, + { label: '放货', value: 'put' } ] as const /** 执行动作:对齐 VDA5050 Order.actions[] 单条 Action 结构(不含 actionId,下发时由运行时生成) */ @@ -15,7 +14,7 @@ export function executeActionParams(): ParamFieldDef[] { label: '动作类型', type: 'select', required: true, - defaultValue: 'pick', + defaultValue: 'fetch', options: [...ACTION_TYPE_OPTIONS] }, { diff --git a/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts b/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts index 43079af..b2240ad 100644 --- a/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts +++ b/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts @@ -1,7 +1,7 @@ import { toRaw } from 'vue' import type { ParamFieldDef, WorkflowDefinition, WorkflowEdgeDef, WorkflowNodeDef } from '@/types/workflow' import type { WorkflowCatalogExport } from '@/workflow/workflowLibrary' -import { NODE_CATALOG_MAP, defaultParamsForType } from '@/workflow/nodeCatalog' +import { defaultParamsForType, NODE_CATALOG_MAP } from '@/workflow/nodeCatalog' import { WAIT_EVENT_HANDLES, WAIT_EVENT_HANDLE_LABELS } from '@/workflow/waitEventParams' /** 深拷贝流程定义(workflow 为纯 JSON 结构,避免 structuredClone 对 Proxy/不可克隆对象报错) */ @@ -72,8 +72,10 @@ export const WORKFLOW_CATALOG_VERSION = 3 export const WORKFLOW_EDITOR_STORAGE_KEY = 'workflowEditor.draft' const DEPRECATED_ASSIGN_STRATEGY: Record = { - nearest: 'fixed', - idle: 'byType' + nearest: 'carId', + idle: 'carType', + fixed: 'carId', + byType: 'carType' } function isFieldVisible(field: ParamFieldDef, params: Record): boolean { @@ -268,11 +270,12 @@ export function attachNextIds( }) } -/** 导出用:附带 nextId 的流程快照 */ +/** 导出用:附带 nextId 的流程快照(params 按当前控件 schema 规范化) */ export function workflowForExport(def: WorkflowDefinition): WorkflowDefinition { + const normalized = normalizeWorkflow(def) return { - ...def, - nodes: attachNextIds(def.nodes, def.edges) + ...normalized, + nodes: attachNextIds(normalized.nodes, normalized.edges) } }