引入WMS仓储主数据与关系管理全流程能力

后端实现基于EF Core的库区/库位/容器/物料/关系/历史等模型、服务与RESTful接口,支持多数据库Provider。前端新增类型、API与聚合页面,支持主数据及容器位置/物料关系的增删改查、绑定/解绑、装料/卸料、历史追溯。完善权限、菜单与文档,平台具备完整WMS能力。
This commit is contained in:
ArtoriasWu
2026-06-22 09:15:42 +08:00
parent e9847f581c
commit f2ef32a22b
33 changed files with 2979 additions and 0 deletions
+705
View File
@@ -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。
- UIElement Plus、`@element-plus/icons-vue`
- 图表/编排:ECharts、Vue Flow。
- HTTPAxios,统一通过 `/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<br/>/login /admin /monitor"]
Vue -->|/api/*| Server["MiGu.Server :8080"]
Server --> Auth["Auth/RBAC/JWT"]
Server --> Config["ConfigStore<br/>data/config-*.json"]
Server --> Wizard["部署向导<br/>deployment profile"]
Server --> Launcher["SimpleLiteLauncher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
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`
+143
View File
@@ -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<List<WarehouseArea>> Areas([FromQuery] string? q) => _service.Areas(q);
[HttpPost("areas")]
public async Task<IActionResult> SaveArea([FromBody] MasterDataRequest req) => Ok(await _service.SaveArea(req, User.ActorName()));
[HttpPut("areas/{id:guid}")]
public async Task<IActionResult> UpdateArea(Guid id, [FromBody] MasterDataRequest req) =>
Ok(await _service.SaveArea(req with { Id = id }, User.ActorName()));
[HttpDelete("areas/{id:guid}")]
public async Task<IActionResult> DeleteArea(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<WarehouseArea>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("storages")]
public Task<List<Storage>> Storages([FromQuery] string? q) => _service.Storages(q);
[HttpPost("storages")]
public async Task<IActionResult> SaveStorage([FromBody] StorageRequest req) => Ok(await _service.SaveStorage(req, User.ActorName()));
[HttpPut("storages/{id:guid}")]
public async Task<IActionResult> UpdateStorage(Guid id, [FromBody] StorageRequest req) =>
Ok(await _service.SaveStorage(req with { Id = id }, User.ActorName()));
[HttpDelete("storages/{id:guid}")]
public async Task<IActionResult> DeleteStorage(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Storage>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("containers")]
public Task<List<Container>> Containers([FromQuery] string? q) => _service.Containers(q);
[HttpPost("containers")]
public async Task<IActionResult> SaveContainer([FromBody] MasterDataRequest req) => Ok(await _service.SaveContainer(req, User.ActorName()));
[HttpPut("containers/{id:guid}")]
public async Task<IActionResult> UpdateContainer(Guid id, [FromBody] MasterDataRequest req) =>
Ok(await _service.SaveContainer(req with { Id = id }, User.ActorName()));
[HttpDelete("containers/{id:guid}")]
public async Task<IActionResult> DeleteContainer(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Container>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("materials")]
public Task<List<Material>> Materials([FromQuery] string? q) => _service.Materials(q);
[HttpPost("materials")]
public async Task<IActionResult> SaveMaterial([FromBody] MaterialRequest req) => Ok(await _service.SaveMaterial(req, User.ActorName()));
[HttpPut("materials/{id:guid}")]
public async Task<IActionResult> UpdateMaterial(Guid id, [FromBody] MaterialRequest req) =>
Ok(await _service.SaveMaterial(req with { Id = id }, User.ActorName()));
[HttpDelete("materials/{id:guid}")]
public async Task<IActionResult> DeleteMaterial(Guid id, [FromQuery] long? version)
{
await _service.DeleteEntity<Material>(id, version, User.ActorName());
return NoContent();
}
[HttpGet("container-locations")]
public Task<List<ContainerLocation>> ContainerLocations([FromQuery] string? locationType, [FromQuery] string? q) =>
_service.ContainerLocations(locationType, q);
[HttpPost("container-locations")]
public async Task<IActionResult> SaveContainerLocation([FromBody] ContainerLocationRequest req) =>
Ok(await _service.BindOrTransferLocation(req, User.ActorName()));
[HttpPost("container-locations/transfer")]
public async Task<IActionResult> TransferContainerLocation([FromBody] ContainerLocationRequest req) =>
Ok(await _service.BindOrTransferLocation(req, User.ActorName()));
[HttpDelete("container-locations/{containerId:guid}")]
public async Task<IActionResult> UnbindContainerLocation(Guid containerId, [FromQuery] string? reason)
{
await _service.UnbindLocation(containerId, User.ActorName(), reason ?? "");
return NoContent();
}
[HttpGet("container-materials")]
public Task<List<ContainerMaterial>> ContainerMaterials([FromQuery] string? q) => _service.ContainerMaterials(q);
[HttpPost("container-materials")]
public async Task<IActionResult> SaveContainerMaterial([FromBody] ContainerMaterialRequest req) =>
Ok(await _service.SaveContainerMaterial(req, User.ActorName()));
[HttpPut("container-materials/{id:guid}")]
public async Task<IActionResult> UpdateContainerMaterial(Guid id, [FromBody] ContainerMaterialRequest req) =>
Ok(await _service.SaveContainerMaterial(req with { Id = id }, User.ActorName()));
[HttpDelete("container-materials/{id:guid}")]
public async Task<IActionResult> UnloadContainerMaterial(Guid id, [FromQuery] string? reason)
{
await _service.UnloadMaterial(id, User.ActorName(), reason ?? "");
return NoContent();
}
[HttpGet("container-location-history")]
public Task<List<ContainerLocationHistory>> ContainerLocationHistory([FromQuery] Guid? containerId) =>
_service.LocationHistory(containerId);
[HttpGet("container-material-history")]
public Task<List<ContainerMaterialHistory>> 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;
}
}
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -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; } = "{}";
}
@@ -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<PlatformDbContext> options) : base(options) { }
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
public DbSet<Storage> Storages => Set<Storage>();
public DbSet<Container> Containers => Set<Container>();
public DbSet<Material> Materials => Set<Material>();
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var guid = new ValueConverter<Guid, string>(
v => v.ToString("D"),
v => Guid.Parse(v));
var nullableGuid = new ValueConverter<Guid?, string?>(
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<WarehouseArea>(modelBuilder, "wms_areas");
ConfigureEntityBase<Storage>(modelBuilder, "wms_storages");
ConfigureEntityBase<Container>(modelBuilder, "wms_containers");
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.AreaId);
modelBuilder.Entity<Container>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => new { x.ContainerId, x.MaterialId, x.BatchNo, x.SerialNo }).IsUnique();
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
StampEntities();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> 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<EntityBase>())
{
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<T>(ModelBuilder modelBuilder, string table) where T : EntityBase
{
var e = modelBuilder.Entity<T>();
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<T>(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase
{
var e = modelBuilder.Entity<T>();
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);
}
}
@@ -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<PlatformDbContext>((sp, options) =>
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
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<WmsReferenceValidator>();
services.AddScoped<WmsService>();
return services;
}
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
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();
}
}
+120
View File
@@ -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<string> 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<string> 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<string> All = new(StringComparer.OrdinalIgnoreCase) { Loaded, Unloaded, Adjusted, Frozen };
}
+57
View File
@@ -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);
}
}
+510
View File
@@ -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<List<WarehouseArea>> Areas(string? q = null) =>
FilterByKeyword(_db.WarehouseAreas.AsNoTracking().OrderBy(x => x.SortOrder).ThenBy(x => x.Code), q).ToListAsync();
public Task<List<Storage>> Storages(string? q = null) =>
FilterByKeyword(_db.Storages.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<Container>> Containers(string? q = null) =>
FilterByKeyword(_db.Containers.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<Material>> Materials(string? q = null) =>
FilterByKeyword(_db.Materials.AsNoTracking().OrderBy(x => x.Code), q).ToListAsync();
public Task<List<ContainerLocation>> 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<List<ContainerMaterial>> ContainerMaterials(string? q = null) =>
FilterByKeyword(_db.ContainerMaterials.AsNoTracking().OrderBy(x => x.ContainerId), q).ToListAsync();
public async Task<WarehouseArea> 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<Storage> 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<Container> 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<Material> 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<T>(Guid id, long? version, string actor) where T : EntityBase
{
var set = _db.Set<T>();
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<ContainerLocation> 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<ContainerMaterial> 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<List<ContainerLocationHistory>> 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<List<ContainerMaterialHistory>> 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<T> FindEditable<T>(DbSet<T> 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<T>(IQueryable<T> query, System.Linq.Expressions.Expression<Func<T, bool>> 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<T> FilterByKeyword<T>(IQueryable<T> query, string? q)
{
if (string.IsNullOrWhiteSpace(q)) return query;
var s = q.Trim();
return typeof(T).Name switch
{
nameof(WarehouseArea) => (IQueryable<T>)((IQueryable<WarehouseArea>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)),
nameof(Storage) => (IQueryable<T>)((IQueryable<Storage>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.SiteId.Contains(s)),
nameof(Container) => (IQueryable<T>)((IQueryable<Container>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s)),
nameof(Material) => (IQueryable<T>)((IQueryable<Material>)query).Where(x => x.Code.Contains(s) || x.Name.Contains(s) || x.Spec.Contains(s)),
nameof(ContainerLocation) => (IQueryable<T>)((IQueryable<ContainerLocation>)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";
}
+1
View File
@@ -0,0 +1 @@
Gl4ghnjUoi2/dEh1Uv4DE5qjuqefIqSpVwg5/ZbIlvdq1g93+vFuwX30N+NRSdzU
+61
View File
@@ -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
}
]
}
}
+21
View File
@@ -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
}
]
}
}
+20
View File
@@ -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"
}
}
+64
View File
@@ -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"
}
]
}
}
}
+43
View File
@@ -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
}
}
}
+32
View File
@@ -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
}
]
}
}
+41
View File
@@ -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
}
]
}
}
+36
View File
@@ -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": {}
}
}
}
+26
View File
@@ -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
}
]
}
}
+49
View File
@@ -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
}
}
}
+20
View File
@@ -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"
]
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"section": "task",
"version": 1,
"updatedAt": "2026-06-08T09:43:37.4893595+00:00",
"payload": {
"mode": "leastLoad",
"loadBalance": true,
"maxQueuePerCar": 3
}
}
+33
View File
@@ -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"
}
]
}
}
+19
View File
@@ -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
}
}
}
+18
View File
@@ -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"
]
}
]
}
}
Binary file not shown.
+94
View File
@@ -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="
}
]
}
@@ -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<string, unknown>) {
return { params }
}
export async function listAreas(keyword?: string) {
const { data } = await http.get<WarehouseArea[]>('/wms/areas', q({ q: keyword }))
return data
}
export async function saveArea(payload: MasterDataPayload) {
const { data } = payload.id
? await http.put<WarehouseArea>(`/wms/areas/${payload.id}`, payload)
: await http.post<WarehouseArea>('/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<Storage[]>('/wms/storages', q({ q: keyword }))
return data
}
export async function saveStorage(payload: StoragePayload) {
const { data } = payload.id
? await http.put<Storage>(`/wms/storages/${payload.id}`, payload)
: await http.post<Storage>('/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<Container[]>('/wms/containers', q({ q: keyword }))
return data
}
export async function saveContainer(payload: MasterDataPayload) {
const { data } = payload.id
? await http.put<Container>(`/wms/containers/${payload.id}`, payload)
: await http.post<Container>('/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<Material[]>('/wms/materials', q({ q: keyword }))
return data
}
export async function saveMaterial(payload: MaterialPayload) {
const { data } = payload.id
? await http.put<Material>(`/wms/materials/${payload.id}`, payload)
: await http.post<Material>('/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<ContainerLocation[]>('/wms/container-locations', q(params))
return data
}
export async function saveContainerLocation(payload: ContainerLocationPayload) {
const { data } = await http.post<ContainerLocation>('/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<ContainerMaterial[]>('/wms/container-materials', q({ q: keyword }))
return data
}
export async function saveContainerMaterial(payload: ContainerMaterialPayload) {
const { data } = payload.id
? await http.put<ContainerMaterial>(`/wms/container-materials/${payload.id}`, payload)
: await http.post<ContainerMaterial>('/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<ContainerLocationHistory[]>('/wms/container-location-history', q({ containerId }))
return data
}
export async function listContainerMaterialHistory(containerId?: string, materialId?: string) {
const { data } = await http.get<ContainerMaterialHistory[]>('/wms/container-material-history', q({ containerId, materialId }))
return data
}
@@ -0,0 +1,186 @@
export interface EntityBase {
id: string
createdAt: string
updatedAt: string
isDeleted: boolean
deletedAt?: string
deletedBy: string
version: number
isLock: boolean
createdBy: string
updatedBy: string
remark: string
extend: string
}
export interface WarehouseArea extends EntityBase {
code: string
name: string
type: string
enabled: boolean
sortOrder: number
}
export interface Storage extends EntityBase {
areaId: string
code: string
name: string
storageType: string
siteId: string
capacity: number
enabled: boolean
}
export interface Container extends EntityBase {
code: string
name: string
containerType: string
status: string
enabled: boolean
}
export interface Material extends EntityBase {
code: string
name: string
spec: string
unit: string
category: string
enabled: boolean
}
export type ContainerLocationType = 'Storage' | 'Car'
export type ContainerLocationStatus = 'Active' | 'Locked' | 'Exception'
export type ContainerMaterialStatus = 'Loaded' | 'Unloaded' | 'Adjusted' | 'Frozen'
export interface ContainerLocation extends EntityBase {
containerId: string
locationType: ContainerLocationType
locationId: string
locationCode: string
locationName: string
status: ContainerLocationStatus
enteredAt: string
}
export interface ContainerMaterial extends EntityBase {
containerId: string
materialId: string
quantity: number
batchNo: string
serialNo: string
status: ContainerMaterialStatus
loadedAt: string
unloadedAt?: string
}
export interface ContainerLocationHistory {
id: string
relationId?: string
eventType: string
beforeJson: string
afterJson: string
containerId?: string
operator: string
operatedAt: string
source: string
reason: string
remark: string
extend: string
fromLocationType: string
fromLocationId: string
toLocationType: string
toLocationId: string
}
export interface ContainerMaterialHistory {
id: string
relationId?: string
eventType: string
beforeJson: string
afterJson: string
containerId?: string
operator: string
operatedAt: string
source: string
reason: string
remark: string
extend: string
materialId?: string
quantityDelta: number
}
export interface MasterDataPayload {
id?: string
version?: number
code: string
name: string
type: string
status: string
enabled: boolean
sortOrder: number
isLock: boolean
remark: string
extend: string
}
export interface StoragePayload {
id?: string
version?: number
areaId: string
code: string
name: string
storageType: string
siteId: string
capacity: number
enabled: boolean
isLock: boolean
remark: string
extend: string
}
export interface MaterialPayload {
id?: string
version?: number
code: string
name: string
spec: string
unit: string
category: string
enabled: boolean
isLock: boolean
remark: string
extend: string
}
export interface ContainerLocationPayload {
id?: string
version?: number
containerId: string
locationType: ContainerLocationType
locationId: string
status: ContainerLocationStatus
enteredAt?: string
source: string
reason: string
isLock: boolean
remark: string
extend: string
}
export interface ContainerMaterialPayload {
id?: string
version?: number
containerId: string
materialId: string
quantity: number
batchNo: string
serialNo: string
status: ContainerMaterialStatus
loadedAt?: string
unloadedAt?: string
source: string
reason: string
isLock: boolean
remark: string
extend: string
}
@@ -0,0 +1,338 @@
<template>
<div class="warehouse-page">
<el-card shadow="never" class="warehouse-card">
<template #header>
<div class="page-header">
<div>
<h2>仓储管理</h2>
<p>库区库位容器物料主数据以及容器位置和容器物料关系</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</template>
<el-tabs v-model="activeGroup">
<el-tab-pane label="基础资料" name="master">
<el-tabs v-model="activeMaster" lazy>
<el-tab-pane label="库区" name="areas">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索编码/名称" clearable /><el-button type="primary" @click="openArea()">新增库区</el-button></div>
<el-table :data="filteredAreas" border size="small">
<el-table-column prop="code" label="编码" width="140" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="type" label="类型" width="120" />
<el-table-column prop="enabled" label="启用" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column prop="isLock" label="锁定" width="90"><template #default="{ row }"><el-tag :type="row.isLock ? 'warning' : 'info'">{{ row.isLock ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" :disabled="row.isLock" @click="openArea(row)">编辑</el-button><el-button size="small" type="danger" :disabled="row.isLock" @click="removeArea(row)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="库位" name="storages">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索编码/名称/站点" clearable /><el-button type="primary" @click="openStorage()">新增库位</el-button></div>
<el-table :data="filteredStorages" border size="small">
<el-table-column prop="code" label="编码" width="140" />
<el-table-column prop="name" label="名称" />
<el-table-column label="库区" width="140"><template #default="{ row }">{{ areaName(row.areaId) }}</template></el-table-column>
<el-table-column prop="storageType" label="类型" width="120" />
<el-table-column prop="siteId" label="站点" width="100" />
<el-table-column prop="capacity" label="容量" width="90" />
<el-table-column prop="enabled" label="启用" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" :disabled="row.isLock" @click="openStorage(row)">编辑</el-button><el-button size="small" type="danger" :disabled="row.isLock" @click="removeStorage(row)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="容器" name="containers">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索编码/名称" clearable /><el-button type="primary" @click="openContainer()">新增容器</el-button></div>
<el-table :data="filteredContainers" border size="small">
<el-table-column prop="code" label="编码" width="140" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="containerType" label="类型" width="120" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="当前位置"><template #default="{ row }">{{ containerLocationLabel(row.id) }}</template></el-table-column>
<el-table-column prop="enabled" label="启用" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" :disabled="row.isLock" @click="openContainer(row)">编辑</el-button><el-button size="small" type="danger" :disabled="row.isLock" @click="removeContainer(row)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="物料" name="materials">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索编码/名称/规格" clearable /><el-button type="primary" @click="openMaterial()">新增物料</el-button></div>
<el-table :data="filteredMaterials" border size="small">
<el-table-column prop="code" label="编码" width="140" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="spec" label="规格" />
<el-table-column prop="unit" label="单位" width="90" />
<el-table-column prop="category" label="类别" width="120" />
<el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" :disabled="row.isLock" @click="openMaterial(row)">编辑</el-button><el-button size="small" type="danger" :disabled="row.isLock" @click="removeMaterial(row)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="关系与历史" name="relations" lazy>
<el-tabs v-model="activeRelation" lazy>
<el-tab-pane label="容器位置" name="locations">
<div class="toolbar">
<el-select v-model="locationFilter" clearable placeholder="位置类型"><el-option label="库位" value="Storage" /><el-option label="车辆" value="Car" /></el-select>
<el-input v-model="keyword" placeholder="搜索位置编码/名称" clearable />
<el-button type="primary" @click="openLocation()">绑定/转移</el-button>
</div>
<el-table :data="filteredContainerLocations" border size="small">
<el-table-column label="容器" width="160"><template #default="{ row }">{{ containerName(row.containerId) }}</template></el-table-column>
<el-table-column prop="locationType" label="位置类型" width="100" />
<el-table-column prop="locationCode" label="位置编码" width="140" />
<el-table-column prop="locationName" label="位置名称" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="enteredAt" label="进入时间" width="180" />
<el-table-column label="操作" width="120"><template #default="{ row }"><el-button size="small" type="danger" :disabled="row.isLock" @click="removeLocation(row)">解绑</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="容器物料" name="materials">
<div class="toolbar"><el-input v-model="keyword" placeholder="搜索" clearable /><el-button type="primary" @click="openContainerMaterial()">装料/调整</el-button></div>
<el-table :data="containerMaterials" border size="small">
<el-table-column label="容器" width="160"><template #default="{ row }">{{ containerName(row.containerId) }}</template></el-table-column>
<el-table-column label="物料" width="160"><template #default="{ row }">{{ materialName(row.materialId) }}</template></el-table-column>
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="batchNo" label="批次" width="140" />
<el-table-column prop="serialNo" label="序列号" width="140" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column label="操作" width="120"><template #default="{ row }"><el-button size="small" type="danger" :disabled="row.isLock" @click="removeContainerMaterial(row)">卸料</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="历史记录" name="history">
<el-table :data="historyRows" border size="small">
<el-table-column prop="kind" label="类型" width="110" />
<el-table-column prop="eventType" label="事件" width="120" />
<el-table-column prop="operator" label="操作人" width="120" />
<el-table-column prop="operatedAt" label="时间" width="180" />
<el-table-column prop="reason" label="原因" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="96px">
<template v-if="dialogKind === 'area' || dialogKind === 'container'">
<el-form-item label="编码"><el-input v-model="masterForm.code" /></el-form-item>
<el-form-item label="名称"><el-input v-model="masterForm.name" /></el-form-item>
<el-form-item label="类型"><el-input v-model="masterForm.type" /></el-form-item>
<el-form-item v-if="dialogKind === 'container'" label="状态"><el-input v-model="masterForm.status" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="masterForm.enabled" /></el-form-item>
<el-form-item label="锁定"><el-switch v-model="masterForm.isLock" /></el-form-item>
</template>
<template v-if="dialogKind === 'storage'">
<el-form-item label="库区"><el-select v-model="storageForm.areaId"><el-option v-for="a in areas" :key="a.id" :label="a.name" :value="a.id" /></el-select></el-form-item>
<el-form-item label="编码"><el-input v-model="storageForm.code" /></el-form-item>
<el-form-item label="名称"><el-input v-model="storageForm.name" /></el-form-item>
<el-form-item label="类型"><el-input v-model="storageForm.storageType" /></el-form-item>
<el-form-item label="站点"><el-input v-model="storageForm.siteId" /></el-form-item>
<el-form-item label="容量"><el-input-number v-model="storageForm.capacity" :min="0" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="storageForm.enabled" /></el-form-item>
<el-form-item label="锁定"><el-switch v-model="storageForm.isLock" /></el-form-item>
</template>
<template v-if="dialogKind === 'material'">
<el-form-item label="编码"><el-input v-model="materialForm.code" /></el-form-item>
<el-form-item label="名称"><el-input v-model="materialForm.name" /></el-form-item>
<el-form-item label="规格"><el-input v-model="materialForm.spec" /></el-form-item>
<el-form-item label="单位"><el-input v-model="materialForm.unit" /></el-form-item>
<el-form-item label="类别"><el-input v-model="materialForm.category" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="materialForm.enabled" /></el-form-item>
<el-form-item label="锁定"><el-switch v-model="materialForm.isLock" /></el-form-item>
</template>
<template v-if="dialogKind === 'location'">
<el-form-item label="容器"><el-select v-model="locationForm.containerId"><el-option v-for="c in containers" :key="c.id" :label="c.code" :value="c.id" /></el-select></el-form-item>
<el-form-item label="位置类型"><el-select v-model="locationForm.locationType"><el-option label="库位" value="Storage" /><el-option label="车辆" value="Car" /></el-select></el-form-item>
<el-form-item v-if="locationForm.locationType === 'Storage'" label="库位"><el-select v-model="locationForm.locationId"><el-option v-for="s in storages" :key="s.id" :label="`${s.code} ${s.name}`" :value="s.id" /></el-select></el-form-item>
<el-form-item v-else label="车辆 ID"><el-input v-model="locationForm.locationId" /></el-form-item>
<el-form-item label="状态"><el-select v-model="locationForm.status"><el-option label="Active" value="Active" /><el-option label="Locked" value="Locked" /><el-option label="Exception" value="Exception" /></el-select></el-form-item>
</template>
<template v-if="dialogKind === 'containerMaterial'">
<el-form-item label="容器"><el-select v-model="containerMaterialForm.containerId"><el-option v-for="c in containers" :key="c.id" :label="c.code" :value="c.id" /></el-select></el-form-item>
<el-form-item label="物料"><el-select v-model="containerMaterialForm.materialId"><el-option v-for="m in materials" :key="m.id" :label="m.code" :value="m.id" /></el-select></el-form-item>
<el-form-item label="数量"><el-input-number v-model="containerMaterialForm.quantity" :min="0.0001" :precision="4" /></el-form-item>
<el-form-item label="批次"><el-input v-model="containerMaterialForm.batchNo" /></el-form-item>
<el-form-item label="序列号"><el-input v-model="containerMaterialForm.serialNo" /></el-form-item>
<el-form-item label="状态"><el-select v-model="containerMaterialForm.status"><el-option label="Loaded" value="Loaded" /><el-option label="Adjusted" value="Adjusted" /><el-option label="Frozen" value="Frozen" /></el-select></el-form-item>
</template>
<el-form-item label="备注"><el-input v-model="currentRemark" type="textarea" /></el-form-item>
<el-form-item label="扩展 JSON"><el-input v-model="currentExtend" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitDialog">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import * as wmsApi from '@/api/wms'
import type {
WarehouseArea, Storage, Container, Material, ContainerLocation, ContainerMaterial,
ContainerLocationHistory, ContainerMaterialHistory, MasterDataPayload, StoragePayload,
MaterialPayload, ContainerLocationPayload, ContainerMaterialPayload
} from '@/types/wms'
type DialogKind = 'area' | 'storage' | 'container' | 'material' | 'location' | 'containerMaterial'
const activeGroup = ref('master')
const activeMaster = ref('areas')
const activeRelation = ref('locations')
const keyword = ref('')
const locationFilter = ref('')
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const dialogKind = ref<DialogKind>('area')
const areas = ref<WarehouseArea[]>([])
const storages = ref<Storage[]>([])
const containers = ref<Container[]>([])
const materials = ref<Material[]>([])
const containerLocations = ref<ContainerLocation[]>([])
const containerMaterials = ref<ContainerMaterial[]>([])
const locationHistory = ref<ContainerLocationHistory[]>([])
const materialHistory = ref<ContainerMaterialHistory[]>([])
const masterForm = reactive<MasterDataPayload>(baseMaster())
const storageForm = reactive<StoragePayload>(baseStorage())
const materialForm = reactive<MaterialPayload>(baseMaterial())
const locationForm = reactive<ContainerLocationPayload>(baseLocation())
const containerMaterialForm = reactive<ContainerMaterialPayload>(baseContainerMaterial())
const dialogTitle = computed(() => ({
area: '库区',
storage: '库位',
container: '容器',
material: '物料',
location: '容器位置',
containerMaterial: '容器物料'
}[dialogKind.value]))
const currentRemark = computed({
get: () => formOf(dialogKind.value).remark,
set: (v: string) => { formOf(dialogKind.value).remark = v }
})
const currentExtend = computed({
get: () => formOf(dialogKind.value).extend,
set: (v: string) => { formOf(dialogKind.value).extend = v }
})
const filteredAreas = computed(() => filterRows(areas.value, keyword.value))
const filteredStorages = computed(() => filterRows(storages.value, keyword.value))
const filteredContainers = computed(() => filterRows(containers.value, keyword.value))
const filteredMaterials = computed(() => filterRows(materials.value, keyword.value))
const filteredContainerLocations = computed(() => containerLocations.value.filter((r) =>
(!locationFilter.value || r.locationType === locationFilter.value) &&
(!keyword.value || `${r.locationCode} ${r.locationName}`.toLowerCase().includes(keyword.value.toLowerCase()))))
const historyRows = computed(() => [
...locationHistory.value.map((x) => ({ ...x, kind: '位置' })),
...materialHistory.value.map((x) => ({ ...x, kind: '物料' }))
].sort((a, b) => String(b.operatedAt).localeCompare(String(a.operatedAt))))
async function loadAll() {
loading.value = true
try {
const [a, s, c, m, cl, cm, lh, mh] = await Promise.all([
wmsApi.listAreas(), wmsApi.listStorages(), wmsApi.listContainers(), wmsApi.listMaterials(),
wmsApi.listContainerLocations(), wmsApi.listContainerMaterials(), wmsApi.listContainerLocationHistory(), wmsApi.listContainerMaterialHistory()
])
areas.value = a; storages.value = s; containers.value = c; materials.value = m
containerLocations.value = cl; containerMaterials.value = cm; locationHistory.value = lh; materialHistory.value = mh
} finally {
loading.value = false
}
}
function openArea(row?: WarehouseArea) { dialogKind.value = 'area'; Object.assign(masterForm, baseMaster(row)); dialogVisible.value = true }
function openStorage(row?: Storage) { dialogKind.value = 'storage'; Object.assign(storageForm, baseStorage(row)); dialogVisible.value = true }
function openContainer(row?: Container) { dialogKind.value = 'container'; Object.assign(masterForm, baseMaster(row)); dialogVisible.value = true }
function openMaterial(row?: Material) { dialogKind.value = 'material'; Object.assign(materialForm, baseMaterial(row)); dialogVisible.value = true }
function openLocation(row?: ContainerLocation) { dialogKind.value = 'location'; Object.assign(locationForm, baseLocation(row)); dialogVisible.value = true }
function openContainerMaterial(row?: ContainerMaterial) { dialogKind.value = 'containerMaterial'; Object.assign(containerMaterialForm, baseContainerMaterial(row)); dialogVisible.value = true }
async function submitDialog() {
saving.value = true
try {
JSON.parse(currentExtend.value || '{}')
if (dialogKind.value === 'area') await wmsApi.saveArea(masterForm)
else if (dialogKind.value === 'container') await wmsApi.saveContainer(masterForm)
else if (dialogKind.value === 'storage') await wmsApi.saveStorage(storageForm)
else if (dialogKind.value === 'material') await wmsApi.saveMaterial(materialForm)
else if (dialogKind.value === 'location') await wmsApi.saveContainerLocation(locationForm)
else await wmsApi.saveContainerMaterial(containerMaterialForm)
ElMessage.success('已保存')
dialogVisible.value = false
await loadAll()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : String(e))
} finally {
saving.value = false
}
}
async function confirmDelete(message: string) {
await ElMessageBox.confirm(message, '确认操作', { type: 'warning' })
}
async function removeArea(row: WarehouseArea) { await confirmDelete('删除该库区?'); await wmsApi.deleteArea(row.id, row.version); await loadAll() }
async function removeStorage(row: Storage) { await confirmDelete('删除该库位?'); await wmsApi.deleteStorage(row.id, row.version); await loadAll() }
async function removeContainer(row: Container) { await confirmDelete('删除该容器?'); await wmsApi.deleteContainer(row.id, row.version); await loadAll() }
async function removeMaterial(row: Material) { await confirmDelete('删除该物料?'); await wmsApi.deleteMaterial(row.id, row.version); await loadAll() }
async function removeLocation(row: ContainerLocation) { await confirmDelete('解绑该容器位置?'); await wmsApi.deleteContainerLocation(row.containerId); await loadAll() }
async function removeContainerMaterial(row: ContainerMaterial) { await confirmDelete('卸载该容器物料?'); await wmsApi.deleteContainerMaterial(row.id); await loadAll() }
function areaName(id: string) { return areas.value.find((a) => a.id === id)?.name ?? id }
function containerName(id: string) { const c = containers.value.find((x) => x.id === id); return c ? `${c.code} ${c.name}` : id }
function materialName(id: string) { const m = materials.value.find((x) => x.id === id); return m ? `${m.code} ${m.name}` : id }
function containerLocationLabel(id: string) {
const loc = containerLocations.value.find((x) => x.containerId === id)
return loc ? `${loc.locationType}:${loc.locationCode || loc.locationId}` : '未绑定'
}
function filterRows<T extends Record<string, unknown>>(rows: T[], q: string) {
if (!q) return rows
const s = q.toLowerCase()
return rows.filter((r) => Object.values(r).some((v) => String(v ?? '').toLowerCase().includes(s)))
}
function formOf(kind: DialogKind): { remark: string; extend: string } {
if (kind === 'storage') return storageForm
if (kind === 'material') return materialForm
if (kind === 'location') return locationForm
if (kind === 'containerMaterial') return containerMaterialForm
return masterForm
}
function baseCommon(row?: { id: string; version: number; isLock: boolean; remark: string; extend: string }) {
return { id: row?.id, version: row?.version, isLock: row?.isLock ?? false, remark: row?.remark ?? '', extend: row?.extend ?? '{}' }
}
function baseMaster(row?: WarehouseArea | Container): MasterDataPayload {
return { ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', type: 'type' in (row ?? {}) ? (row as WarehouseArea).type : (row as Container | undefined)?.containerType ?? '', status: (row as Container | undefined)?.status ?? 'Idle', enabled: row?.enabled ?? true, sortOrder: (row as WarehouseArea | undefined)?.sortOrder ?? 0 }
}
function baseStorage(row?: Storage): StoragePayload {
return { ...baseCommon(row), areaId: row?.areaId ?? areas.value[0]?.id ?? '', code: row?.code ?? '', name: row?.name ?? '', storageType: row?.storageType ?? 'Storage', siteId: row?.siteId ?? '', capacity: row?.capacity ?? 0, enabled: row?.enabled ?? true }
}
function baseMaterial(row?: Material): MaterialPayload {
return { ...baseCommon(row), code: row?.code ?? '', name: row?.name ?? '', spec: row?.spec ?? '', unit: row?.unit ?? 'pcs', category: row?.category ?? '', enabled: row?.enabled ?? true }
}
function baseLocation(row?: ContainerLocation): ContainerLocationPayload {
return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', locationType: row?.locationType ?? 'Storage', locationId: row?.locationId ?? '', status: row?.status ?? 'Active', enteredAt: row?.enteredAt, source: 'Manual', reason: '' }
}
function baseContainerMaterial(row?: ContainerMaterial): ContainerMaterialPayload {
return { ...baseCommon(row), containerId: row?.containerId ?? containers.value[0]?.id ?? '', materialId: row?.materialId ?? materials.value[0]?.id ?? '', quantity: row?.quantity ?? 1, batchNo: row?.batchNo ?? '', serialNo: row?.serialNo ?? '', status: row?.status ?? 'Loaded', loadedAt: row?.loadedAt, source: 'Manual', reason: '' }
}
onMounted(loadAll)
</script>
<style scoped>
.warehouse-page { padding: 16px; height: 100%; overflow: auto; }
.warehouse-card { min-height: 100%; }
.page-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.page-header h2 { margin: 0 0 4px; font-size: 20px; color: var(--mg-text-light); }
.page-header p { margin: 0; color: var(--mg-text-muted); font-size: 13px; }
.toolbar { display: flex; gap: 10px; align-items: center; margin: 0 0 12px; }
.toolbar .el-input { max-width: 260px; }
.toolbar .el-select { width: 160px; }
</style>