引入WMS仓储主数据与关系管理全流程能力
后端实现基于EF Core的库区/库位/容器/物料/关系/历史等模型、服务与RESTful接口,支持多数据库Provider。前端新增类型、API与聚合页面,支持主数据及容器位置/物料关系的增删改查、绑定/解绑、装料/卸料、历史追溯。完善权限、菜单与文档,平台具备完整WMS能力。
This commit is contained in:
@@ -0,0 +1,705 @@
|
|||||||
|
# 迷毂 2.0 项目模块与关系说明
|
||||||
|
|
||||||
|
> 本文基于当前仓库代码、README 与架构文档整理。结论以本仓库实际实现为主;`ARCHITECTURE.md` 中包含较多中长期蓝图,若与代码存在差异,本文会单独标注。
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
|
||||||
|
|
||||||
|
- 平台后端:`MiGu.Server`,ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。
|
||||||
|
- 平台前端:`frontends/apps/simple-platform-vue`,Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`。
|
||||||
|
|
||||||
|
调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。
|
||||||
|
|
||||||
|
## 2. 技术栈总览
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- 运行时:`.NET 8` / ASP.NET Core Web API。
|
||||||
|
- API 文档:Swagger,仅开发环境启用。
|
||||||
|
- 鉴权:JWT Bearer + httpOnly Cookie 双轨。
|
||||||
|
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
|
||||||
|
- 代理:YARP Reverse Proxy。
|
||||||
|
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
|
||||||
|
- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- 框架:Vue 3.5、Vite 5、TypeScript。
|
||||||
|
- 状态:Pinia。
|
||||||
|
- UI:Element Plus、`@element-plus/icons-vue`。
|
||||||
|
- 图表/编排:ECharts、Vue Flow。
|
||||||
|
- HTTP:Axios,统一通过 `/api` baseURL 调用平台后端。
|
||||||
|
- 开发 Mock:`VITE_USE_MOCK=true` 时部分 API 使用前端 mock 数据。
|
||||||
|
|
||||||
|
### 外部/相邻系统
|
||||||
|
|
||||||
|
- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
|
||||||
|
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
|
||||||
|
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
|
||||||
|
|
||||||
|
## 3. 顶层目录职责
|
||||||
|
|
||||||
|
```text
|
||||||
|
MIGU2.0/
|
||||||
|
├── MiGu.Server/ 平台后端工程
|
||||||
|
├── MiGu.Server.sln 后端解决方案
|
||||||
|
├── frontends/ 前端 pnpm workspace
|
||||||
|
│ └── apps/simple-platform-vue/ 单 SPA:管理员端 + 运营端
|
||||||
|
├── Doc/ 代码审查、问题清单与本文档
|
||||||
|
├── ARCHITECTURE.md 总体架构蓝图,含大量规划内容
|
||||||
|
├── PLATFORM_V3_CHANGES.md v3 平台化实际改动记录
|
||||||
|
├── README.md 仓库定位与启动说明
|
||||||
|
└── build-platform-frontend.bat 前端构建并同步到 MiGu.Server/wwwroot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 当前实现的总体运行关系
|
||||||
|
|
||||||
|
当前代码中的主入口是 `MiGu.Server`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User["浏览器用户"] --> Vue["simple-platform-vue<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`
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Gl4ghnjUoi2/dEh1Uv4DE5qjuqefIqSpVwg5/ZbIlvdq1g93+vFuwX30N+NRSdzU
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"section": "task",
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-06-08T09:43:37.4893595+00:00",
|
||||||
|
"payload": {
|
||||||
|
"mode": "leastLoad",
|
||||||
|
"loadBalance": true,
|
||||||
|
"maxQueuePerCar": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
@@ -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>
|
||||||
Reference in New Issue
Block a user