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