diff --git a/AI开发日志/2026-08-15.md b/AI开发日志/2026-08-15.md index 8240959..79624e4 100644 --- a/AI开发日志/2026-08-15.md +++ b/AI开发日志/2026-08-15.md @@ -11,9 +11,10 @@ | 序号 | 任务 | 状态 | 说明 | |---|---|---|---| -| 1 | 通用打印接入主前端 | 🔄 **进行中(方案已定,代码未写)** | 三步:后端取行数据接口 → openprint 外部打印模式 → web 打印按钮 | -| 2 | 简易工作流(采购/领料审批闭环) | ⏳ 待做 | 4 张表已有,缺服务+API+前端审批 UI | +| 1 | 通用打印接入主前端 | ✅ **v1 完成(三等分原料出库单可打印)** | 后端取数 + openprint `/print` 外部页 + web 打印按钮 + 三等分模板入库;联调收尾:已造测试数据(庄口 id=3 / 出库单 id=2),打印数据端点返回完整 | +| 2 | 简易工作流(采购/领料审批闭环) | ✅ **v1 完成(原料出库审批闭环)** | WorkflowService + API(7 端点)+ 待办中心/我发起的页面 + 原料出库单「审批」按钮;流程定义/菜单已种子入库 | | 3 | 专业业务逻辑(自动定级/能耗分摊/成本盈亏/断料预警) | ⏳ 待做 | 依赖任务 1/2 部分能力 | +| 4 | 轻量 IM(内部互发 + 系统通知) | ✅ **v1 完成** | ImService + API(6 端点)+ IM 聊天页 + 顶栏消息铃铛 + 演示通知种子 | 已完成的里程碑: - ✅ 动态 CRUD 体系(后端 EntityCatalog + CrudService + DataController,前端 CrudPage + table-map 配置驱动) @@ -59,22 +60,31 @@ ### 3.1 目标 表单通用打印:主前端任意 CRUD 列表 → 选中一行 → 选打印模板 → 打开 openprint 用**该行真实数据**打印(针式/A4 均支持,设计器已具备)。 -### 3.2 已锁定的三步方案(代码未写,按序实施) +### 3.2 已实施的三步方案(2026-08-15 落地 ✅,v1 三等分原料出库单) -**Step A — 后端:取行数据接口** -- 位置:`PrintApiController.cs` 新增(或新 controller),路由建议 `GET api/print/data/{table}/{id}`。 -- 实现:基于 `EntityCatalog` 动态取实体 Type → FreeSql 按 `id` 查询单行 → 输出结构匹配绑定 Path:`{ "": { "": value, ... } }`(如 `{ "rawMaterial_Instock": { "orderNo": "...", ... } }` 或 PascalCase 表名,需与 PrintService.BuildFields 生成的 Path 前缀一致,建议直接复用 BuildFields 的 TableId)。 -- 返回裸 JSON(同 PrintApiController 风格,走 openprint http-client 的 json() 直接拿对象)。 +**Step A — 后端:取行数据接口(已完成)** +- 位置:`PrintApiController.cs`,路由 `GET api/print/data/{table}/{id}`。 +- 实现:`EntityCatalog` 动态取实体 Type → 反射 `CrudService.GetByIdAsync`(复用 DataController 模式,`ActivatorUtilities.CreateInstance`)→ 输出 `{ "": { camelCase 字段 } }` 裸 JSON(信封键=表名,与 BuildFields 的 Path 前缀一致)。 +- 增强:① 关联字段补全 `RefMap`(`*Id` → `{Xxx}Code`/`{Xxx}Name`,如 ZhuangkouCode、OutOrgName);② 枚举文本注入 `EnumMaps`(如 OutType → OutTypeText 领料/退货/报损)。 +- 注意:openprint 侧会为行对象自动补 PascalCase 别名(`withPascalAliases`),因此模板绑定 `RawMaterial_OutStock.BillNo` 或 `billNo` 均可取到值。 -**Step B — openprint:外部打印模式** -- 解析 URL 参数:`?print=1&template=&table=&row=&token=`。 -- 启动流程(main.ts 或 App.vue):检测 `print=1` 时 → 用 URL 的 token(或 localStorage)替换 HttpClient 的 token → 加载模板(designer store `loadTemplate`)→ 调 `api/print/data/{table}/{row}` 拉真实数据 → 注入到 dataSource store 供 `doPrint` 使用(如新增 `externalData` 状态,`PrintDialog.doPrint()` 优先用它替代 `buildPreviewData`)→ 自动打开打印弹窗。 -- 注意:`designer.ts` 有 `loadTemplate({id,name,data})`/`buildTemplate()`/`setRepository()`/`restoreLastTemplate()`;`dataSource.ts` 的 `activeFields` 决定字段列表;只读模式下应隐藏设计工具栏(TopToolbar 需加 `readonly`/`external` 模式判断)。 -- token:主前端 web 存于 localStorage `f9mes_token`,openprint 与 web 同源部署时可直接读;跨端口时用 URL 传 token 更稳(注意 URL 长度与泄露,内网 MES 可接受)。 +**Step B — openprint:外部打印模式(已完成)** +- 新增 `openprint/src/print/PrintPage.vue`(独立轻量打印页,不进入设计器),`main.ts` 检测 `?print=1` 时改挂载该页。 +- URL 参数:`?print=1&template=<模板id>&table=<表名>&row=<行id>&token=`;可选 `api`(API 根地址,缺省 `VITE_OPENPRINT_API_BASE`);可选 `data`(base64url 直传数据,绕过数据请求)。 +- 流程:解析参数 → `createHttpRepository({baseUrl, token})` → 拉数据(`fetch /api/print/data/{table}/{row}` 带 Bearer,或解码 `data`)→ `withPascalAliases` 归一化 → `createHeadless({repository}).buildRequest(tplId, data)` → `headless.print()`(浏览器打印对话框,可用三等分纸型)。 +- 页面状态机:加载中/打印中/完成/失败 +「重新打印」「关闭」按钮;缺参/HTTP 错误均给出中文提示。 -**Step C — web:CrudPage 打印按钮** -- `CrudPage.vue` 工具栏加「打印」按钮(选中一行时可用)→ 弹窗列出该表可用模板(调 `api/print/templates` 按 `tableId` 过滤)→ 确认后 `window.open` openprint 打印 URL:`http://localhost:5227/?print=1&template={id}&table={tableName}&row={rowId}&token={token}`。 -- 需要后端提供"按表过滤模板"的能力(`PrintApiController.templates` 若支持 tableId 查询则直接用,否则加参数)。 +**Step C — web:CrudPage 打印按钮(已完成)** +- 新增 `web/src/config/print.js`:`OPENPRINT_URL`(env `VITE_OPENPRINT_URL`,默认 `http://localhost:5227`)、`PRINT_API_BASE`(默认 `http://localhost:5136`)、`buildPrintUrl()`。 +- `web/src/config/table-map.js`:`PRINT_MAP`(component → `{templateName, title}`)+ `resolvePrint()`;当前配置 `RawMaterial/outstock` → 模板名「三等分原料出库单」。 +- `web/src/router/index.js`:路由 meta 注入 `printTemplate: resolvePrint(component)`。 +- `CrudPage.vue`:操作列按 `printTemplate` 条件渲染「打印」按钮 → `GET /print/templates` 按模板名找到 id → `window.open(buildPrintUrl({template, table, row, token}))`。操作列宽度 `opWidth` 随按钮动态计算。 +- 模板匹配用**名称**而非硬编码 id(数据库重建也不失效);多模板扩展时改 `PRINT_MAP` 即可。 + +**三等分模板(已完成)** +- `server/scripts/print-template-outstock.json`:210×99mm(A4 三等分)竖版 portrait、边距 8mm;标题/单号/庄口/领料车间/出库重量(kg)/出库日期/出库类型/领用人+仓管员签章区。 +- `server/scripts/seed-print-template.mjs`:登录→查重→POST `/api/print/templates` 创建(node 执行,避免 PowerShell 中文编码坑)。 +- 已入库:模板 id=2(Name=三等分原料出库单,PaperSize=三等分)。表 `RawMaterial_OutStock` 当前无数据,录入后可验证完整打印。 ### 3.3 关键文件清单 - 后端:`server/src/F9MES.Api/Controllers/PrintApiController.cs`、`server/src/F9MES.Application/Print/PrintService.cs`、`server/src/F9MES.Api/Common/EntityCatalog.cs`、`server/src/F9MES.Api/Controllers/DataController.cs` @@ -83,13 +93,35 @@ --- -## 四、任务 2:简易工作流(待做) +## 四、任务 2:简易工作流(v1 完成 ✅,原料出库审批闭环) -- 现状:4 张表(Common_Workflow/Node/Instance/Task)已有,`WorkBenchController` 已统计 `Common_WorkflowTask`(Status==0 && UserId==当前用户)为待办。 -- 要做: - 1. 后端:工作流服务(流程定义 CRUD、节点配置、发起实例、提交/审批/驳回/撤回流转)、API(建议 `api/workflow/...`)。 - 2. 前端:审批中心页面(我的待办、我发起的、流程配置);在采购单、领料申请等表单接审批。 - 3. 关联:任务 1 打印对审批单据同样适用。 +### 4.1 已实施(2026-08-15 落地) +- **后端**: + - `Common_WorkflowInstance` 实体新增 `BizId`(关联业务记录 ID,跳转业务详情用)。 + - 新增 `server/src/F9MES.Application/Workflow/WorkflowService.cs` + `WorkflowDtos.cs`、`server/src/F9MES.Api/Controllers/WorkflowController.cs`(DI 注册于 Program.cs)。 + - API(`api/workflow/*`,均需 JWT): + - `GET definitions?bizType=` 已发布流程定义(含节点) + - `POST start` 发起流程(创建实例 + 首个审批任务,审批人取节点 ApproverJson 数组首个用户,缺省当前操作人) + - `GET todos?page=&size=` 我的待办 + - `POST approve` 同意(推进下一审批节点,无则实例通过 Status=1) + - `POST reject` 驳回(实例终止 Status=2) + - `GET instances?page=&size=&status=` 我发起的实例 + - `GET instance/{id}` 实例详情(含任务轨迹) +- **前端**: + - `web/src/api/workflow.js`(7 个方法封装)。 + - `web/src/views/workflow/todo.vue` 待办中心(同意/驳回带意见、详情抽屉轨迹时间线);`my.vue` 我发起的(状态筛选、详情轨迹)。 + - `table-map.js`:`SPECIAL_PAGES` 加 `WorkFlow/todo`、`WorkFlow/my`;新增 `WORKFLOW_MAP`(component → bizType,当前 `RawMaterial/outstock` → `RawMaterial_OutStock`)+ `resolveWorkflow()`。 + - `router/index.js`:静态路由 `/workflow/todo`、`/workflow/my`;动态路由 meta 注入 `workflow: resolveWorkflow(component)`。 + - `CrudPage.vue`:操作列按 `workflowCfg` 条件渲染「审批」按钮 → 弹窗选流程定义 → 发起(opWidth 动态加宽)。 +- **种子**:`server/scripts/seed-workflow.mjs` 创建「原料出库审批」流程(Code=RM_OUT_APPROVE,节点:发起→车间主管审批[ApproverJson=["1"]]→结束)+ 侧边栏菜单(工作流目录→待办中心/我发起的)。 +- **验证**:编译通过(0 错误);端到端 API 闭环通过——发起(instanceId 正常回填)→ 待办(任务归属当前用户)→ 同意(实例 Status=1)→ 驳回(Status=2);菜单树已含待办中心/我发起的;web 全部模块编译 200。 + +### 4.2 关键坑 +- **FreeSql 自增回填**:`Insert(entity).ExecuteAffrowsAsync()` **不回填主键**(返回 0),必须用 `ExecuteIdentityAsync()` 并手动赋值(CrudService.AddAsync 已是此写法)。 + +### 4.3 扩展方式 +- 新表单接审批:`WORKFLOW_MAP` 加一行 + 后端种子加流程定义(复用 `seed-workflow.mjs` 模式);审批人可在节点 `ApproverJson` 配置用户ID数组。 +- v1 简化:单审批人串行流转;同一节点多审批人(会签/或签)、条件分支、撤回、流程配置 UI 留待后续。 --- @@ -111,6 +143,8 @@ 4. **openprint 是独立应用**:改动它用 `npm run dev`(端口 5227)独立调试;主前端 web 是 vite dev(5173)。 5. 数据库连接(项目说明):MySQL `116.198.221.105` 库 `f9web` 用户 `f9web`;阿里云 OSS(bbit-f9-web);天气用 Open-Meteo。 6. 项目要求:类/文件命名带模块前缀、各模块类放各自文件夹、公共类放 Common;界面 MES 风格、高可配置、表单必须支持打印、表单带简易工作流。 +7. **PowerShell 中文编码坑**:PS 5.1 按 GBK 读 UTF-8 无 BOM 文件(脚本/文件名乱码)、`Invoke-RestMethod` 发中文 body 易 400(GBK 发送 vs UTF-8 解析)。对策:① 涉及中文的脚本用 **node(.mjs)** 写(原生 UTF-8);② 文件/命令行尽量用 ASCII 名。 +8. **启动服务**:后端中文路径下 `Start-Process dotnet` 传中文路径会损坏 → 用 `cmd /c "cd /d <路径> && dotnet run ..."` 或先取 8.3 短路径;API 冷启动要等 `SyncStructure` 全表同步(114 张远程表,约 1-2 分钟)后才监听端口。 --- @@ -125,11 +159,43 @@ ### 7.2 openprint 全链路调研(已完成,结论见第三节) 已通读:PrintApiController / PrintService / EntityCatalog / DataController / http-repo / http-datasource / http-client / backend.ts / main.ts / App.vue / TopToolbar / PrintDialog / designer store / dataSource store / preview-data / expression / data-binder / CrudPage / table-map / request.js / user store / 两端 vite 配置。结论已沉淀到第三节。 +### 7.3 通用打印接入 v1(已完成 ✅,三等分原料出库单) +- 后端:`GET api/print/data/{table}/{id}`(关联名称 + 枚举文本补全,裸 JSON 信封),CORS 增加 `localhost:5227`。 +- openprint:`/print` 外部打印页(PrintPage.vue + main.ts 挂载逻辑),headless 渲染 + 浏览器打印。 +- web:打印按钮(CrudPage + PRINT_MAP + router meta + print.js 配置)。 +- 模板:210×99mm 三等分原料出库单已入库(id=2);种子脚本 `server/scripts/seed-print-template.mjs`(node)。 +- 验证:API/模板/数据端点 404 行为/两端模块编译均通过。 +- **联调收尾**:已造测试数据——庄口 id=3(YLZ20260815-001 四川凉山-2026春茧)、出库单 id=2(CK-20260815-001,125.5kg 领料/已出库);`GET api/print/data/RawMaterial_OutStock/2` 返回完整(含 ZhuangkouName、OutTypeText=领料);打印页 URL 已验证(选三等分纸型即可打印)。脚本 `server/scripts/seed-test-outstock.mjs`。 + +### 7.4 简易工作流 v1(已完成 ✅,原料出库审批闭环) +- 后端:WorkflowService + WorkflowController(7 端点,`api/workflow/*`),实体加 BizId;DI 注册。 +- 前端:workflow.js / todo.vue / my.vue / WORKFLOW_MAP / 静态路由 / CrudPage「审批」按钮。 +- 种子:`server/scripts/seed-workflow.mjs`(原料出库审批流程 + 工作流菜单)。 +- 验证:编译 0 错误;API 闭环(发起→待办→同意 Status=1;驳回 Status=2);菜单树含待办中心/我发起的;web 模块编译 200。 +- 坑:FreeSql `ExecuteAffrowsAsync` 不回填自增主键,须用 `ExecuteIdentityAsync`。 + +### 7.5 轻量 IM v1(已完成 ✅,内部互发 + 系统通知) +- **数据**:复用 `Common_Message` 表,扩展 `SenderId`/`SenderName`(发送人,0=系统),`MsgType` 增加 `4=单聊消息`(原 0=系统 1=业务 2=审批 3=预警)。 +- **后端**:`server/src/F9MES.Application/Im/ImService.cs` + `ImDtos.cs`、`server/src/F9MES.Api/Controllers/ImController.cs`(DI 注册于 Program.cs)。 + - API(`api/im/*`,均需 JWT):`GET sessions` 会话列表(按对端聚合最后消息+未读数)、`GET messages?peerId=&page=&size=` 聊天记录(双方)、`POST send` 发送单聊({peerId, content})、`POST read` 标记已读({peerId})、`GET unread-count` 未读总数(顶栏角标)、`POST notify` 系统/业务通知群发({userIds?, msgType, title, content},空 userIds=全部启用用户)。 +- **前端**: + - `web/src/api/im.js`(6 方法)。 + - `web/src/views/im/index.vue` 聊天中心(左侧会话列表:头像/最后消息/时间/未读角标,右上 + 发起新会话选用户;右侧消息气泡+Enter 发送;5s 轮询)。 + - `layout/index.vue` 顶栏新增消息铃铛(未读角标,30s 轮询,点击跳 `/im`)。 + - `table-map.js`:`SPECIAL_PAGES` 加 `Im/index`;`router/index.js` 加静态路由 `/im`。 +- **种子**:`server/scripts/seed-im.mjs` 创建「协作」目录 →「即时通讯」菜单 + 3 条演示通知(工单变更/工艺单变更/审批通知)。 +- **验证**:编译 0 错误;API 闭环通过——管理员↔车间主管李工(测试用户 id=2)互发、未读计数、已读清零、会话聚合、聊天记录含 senderName;菜单树含「即时通讯/待办中心/我发起的」;web 模块编译 200;前端 lint 0 错误。 +- **坑(重要)**:`InitDataService.InitMenusAsync` 有开发期修复——**只要存在 `MenuType==1 && ParentId==0`(顶层页面节点)就物理删除全部菜单重建**。种子脚本新增菜单**必须用目录节点(MenuType=0, ParentId=0)+ 页面子节点(MenuType=1, ParentId=目录id)**结构;否则会连带删掉工作流等全部自定义菜单(本次已踩坑并修复)。 + --- ## 八、下一步行动(新对话从这里开始) 1. 先读本文件 + `功能清单` + `项目说明`(若工作区未加载)。 -2. 按 **3.2 的 Step A → B → C** 依次实施任务 1(打印接入),每步完成后用 `dotnet build -o build_tmp` / `npm run build` 验证,openprint 与 web 可分别 `npm run dev` 联调。 -3. 完成后再推进任务 2(简易工作流)。 -4. 任何大改动前更新本文件「最后更新」日期与任务状态。 +2. **人工验证**: + - IM:登录 web → 顶栏消息铃铛(未读角标)→ 侧边栏「协作 → 即时通讯」聊天(管理员 ↔ 车间主管李工 13900000001/123456);演示通知已发到管理员账号。 + - 工作流:待办中心/我发起的列表 + 原料出库单「审批」按钮(测试数据出库单 id=2,流程 RM_OUT_APPROVE)。 +3. **多表单扩展**:a) 打印:`PRINT_MAP` 加 component → `{templateName}` + 设计模板;b) 审批:`WORKFLOW_MAP` 加 component → bizType + 种子脚本加流程定义;c) 通知:业务代码调 `POST api/im/notify`(工单/工艺单变更处已留接口)。 +4. 后续推进任务 3(专业业务逻辑:断料预警 → 自动定级 → 能耗分摊 → 成本盈亏)。 +5. 任何大改动前更新本文件「最后更新」日期与任务状态。 +6. **新增菜单铁律**:种子脚本建菜单必须「目录节点(MenuType=0)+ 页面子节点(MenuType=1)」结构,禁止顶层页面节点(MenuType=1, ParentId=0),否则 `InitDataService` 孤儿修复会物理删除全部菜单重建。 diff --git a/openprint/src/main.ts b/openprint/src/main.ts index d778fa7..c3ec809 100644 --- a/openprint/src/main.ts +++ b/openprint/src/main.ts @@ -6,24 +6,31 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' +import PrintPage from './print/PrintPage.vue' import { getBackendConfig } from './config/backend' import { createHttpRepository } from './repository/http-repo' import { createDataSourceHttp } from './repository/http-datasource' import { useDesignerStore } from './design/stores/designer' import { useDataSourceStore } from './design/stores/dataSource' -const app = createApp(App) +// 外部打印模式:MES 等宿主应用以 ?print=1&template=&table=&row=&token= 打开,仅挂载打印页 +const isPrintMode = new URLSearchParams(window.location.search).get('print') === '1' + +const app = createApp(isPrintMode ? PrintPage : App) const pinia = createPinia() app.use(pinia) // 后端对接:仅当配置了 VITE_OPENPRINT_API_BASE 才切云端仓库; // 未配置时 designer 用 localStorage、dataSource 用内置 Mock(主任铁律:无后端全链路可用)。 -const backend = getBackendConfig() -if (backend) { - const designerStore = useDesignerStore(pinia) - const dataSourceStore = useDataSourceStore(pinia) - designerStore.setRepository(createHttpRepository(backend.options), 'cloud') - dataSourceStore.setRepository(createDataSourceHttp(backend.options)) +// 打印模式由 PrintPage 自行按 URL 参数构建云端仓库,此处跳过。 +if (!isPrintMode) { + const backend = getBackendConfig() + if (backend) { + const designerStore = useDesignerStore(pinia) + const dataSourceStore = useDataSourceStore(pinia) + designerStore.setRepository(createHttpRepository(backend.options), 'cloud') + dataSourceStore.setRepository(createDataSourceHttp(backend.options)) + } } app.mount('#app') diff --git a/openprint/src/print/PrintPage.vue b/openprint/src/print/PrintPage.vue new file mode 100644 index 0000000..1e228f9 --- /dev/null +++ b/openprint/src/print/PrintPage.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/server/scripts/print-template-outstock.json b/server/scripts/print-template-outstock.json new file mode 100644 index 0000000..914266f --- /dev/null +++ b/server/scripts/print-template-outstock.json @@ -0,0 +1,168 @@ +{ + "name": "三等分原料出库单", + "content": { + "version": "1.0", + "document": { + "type": "report", + "page": { + "width": 210, + "height": 99, + "unit": "mm", + "orientation": "portrait", + "margin": { "top": 8, "bottom": 8, "left": 8, "right": 8 } + }, + "sections": [ + { + "type": "body", + "components": [ + { + "id": "txt-title", + "type": "text", + "left": 0, "top": 0, "width": 194, "height": 10, + "value": "原料出库单", + "style": { "fontSize": 15, "fontWeight": "bold", "textAlign": "center" } + }, + { + "id": "line-top", + "type": "line", + "left": 0, "top": 12, "width": 194, "height": 0.5, + "stroke": "#333333" + }, + { + "id": "lbl-billno", + "type": "text", + "left": 4, "top": 17, "width": 26, "height": 6, + "value": "单号:", + "style": { "fontSize": 10 } + }, + { + "id": "val-billno", + "type": "text", + "left": 34, "top": 17, "width": 60, "height": 6, + "binding": "RawMaterial_OutStock.BillNo", + "style": { "fontSize": 10 } + }, + { + "id": "lbl-org", + "type": "text", + "left": 100, "top": 17, "width": 42, "height": 6, + "value": "领料车间:", + "style": { "fontSize": 10 } + }, + { + "id": "val-org", + "type": "text", + "left": 144, "top": 17, "width": 50, "height": 6, + "binding": "RawMaterial_OutStock.OutOrgName", + "style": { "fontSize": 10 } + }, + { + "id": "lbl-zk", + "type": "text", + "left": 4, "top": 26, "width": 26, "height": 6, + "value": "庄口:", + "style": { "fontSize": 10 } + }, + { + "id": "val-zk", + "type": "text", + "left": 34, "top": 26, "width": 60, "height": 6, + "binding": "RawMaterial_OutStock.ZhuangkouCode", + "style": { "fontSize": 10 } + }, + { + "id": "lbl-weight", + "type": "text", + "left": 100, "top": 26, "width": 42, "height": 6, + "value": "出库重量:", + "style": { "fontSize": 10 } + }, + { + "id": "val-weight", + "type": "text", + "left": 144, "top": 26, "width": 34, "height": 6, + "binding": "RawMaterial_OutStock.OutWeight", + "format": { "kind": "decimal", "digits": 2 }, + "style": { "fontSize": 10 } + }, + { + "id": "unit-weight", + "type": "text", + "left": 180, "top": 26, "width": 14, "height": 6, + "value": "kg", + "style": { "fontSize": 10 } + }, + { + "id": "lbl-date", + "type": "text", + "left": 4, "top": 35, "width": 26, "height": 6, + "value": "出库日期:", + "style": { "fontSize": 10 } + }, + { + "id": "val-date", + "type": "text", + "left": 34, "top": 35, "width": 60, "height": 6, + "binding": "RawMaterial_OutStock.OutDate", + "format": { "kind": "date", "pattern": "YYYY-MM-DD" }, + "style": { "fontSize": 10 } + }, + { + "id": "lbl-type", + "type": "text", + "left": 100, "top": 35, "width": 42, "height": 6, + "value": "出库类型:", + "style": { "fontSize": 10 } + }, + { + "id": "val-type", + "type": "text", + "left": 144, "top": 35, "width": 50, "height": 6, + "binding": "RawMaterial_OutStock.OutTypeText", + "style": { "fontSize": 10 } + }, + { + "id": "line-mid", + "type": "line", + "left": 0, "top": 44, "width": 194, "height": 0.5, + "stroke": "#333333" + }, + { + "id": "lbl-receiver", + "type": "text", + "left": 4, "top": 50, "width": 60, "height": 6, + "value": "领用人签字:", + "style": { "fontSize": 10 } + }, + { + "id": "line-receiver", + "type": "line", + "left": 4, "top": 60, "width": 80, "height": 0.5, + "stroke": "#333333" + }, + { + "id": "lbl-keeper", + "type": "text", + "left": 104, "top": 50, "width": 60, "height": 6, + "value": "仓管员签字:", + "style": { "fontSize": 10 } + }, + { + "id": "line-keeper", + "type": "line", + "left": 104, "top": 60, "width": 80, "height": 0.5, + "stroke": "#333333" + }, + { + "id": "lbl-note", + "type": "text", + "left": 4, "top": 70, "width": 186, "height": 6, + "value": "备注:", + "style": { "fontSize": 10 } + } + ] + } + ] + } + } +} diff --git a/server/scripts/seed-im-admin.mjs b/server/scripts/seed-im-admin.mjs new file mode 100644 index 0000000..b0cb76f --- /dev/null +++ b/server/scripts/seed-im-admin.mjs @@ -0,0 +1,96 @@ +/** + * IM 管理菜单种子:协作(目录)下追加 4 个管理页面 + * - 性能监测(/im/monitor) + * - 服务监测(/im/service) + * - 配置界面(/im/config) + * - 消息与公告管理(/im/message) + * ⚠️ 顶层必须用目录节点(MenuType=0),页面节点(MenuType=1)必须挂目录下。 + * 用法:node server/scripts/seed-im-admin.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: h, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + if (!res.ok || (json && json.code !== 0)) { + throw new Error(`HTTP ${res.status} ${url}: ${text.slice(0, 300)}`); + } + return json; +} + +const PAGES = [ + { name: '性能监测', routePath: '/im/monitor', component: 'Im/monitor', icon: 'Monitor', sort: 20 }, + { name: '服务监测', routePath: '/im/service', component: 'Im/service', icon: 'Connection', sort: 30 }, + { name: '配置界面', routePath: '/im/config', component: 'Im/config', icon: 'Setting', sort: 40 }, + { name: '消息与公告管理', routePath: '/im/message', component: 'Im/message', icon: 'Bell', sort: 50 }, +]; + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', { + phone: PHONE, + password: PASSWORD, + }); + const token = login.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + const menus = await api(h, 'GET', '/api/data/BaseSys_Menu/all'); + const items = menus.data?.data || []; + let dir = items.find((m) => m.name === '协作' && m.menuType === 0); + if (!dir) { + const r = await api(h, 'POST', '/api/data/BaseSys_Menu/add', { + name: '协作', + menuType: 0, + routePath: '', + component: '', + icon: 'ChatDotRound', + isVisible: 1, + isEnable: 1, + sort: 998, + parentId: 0, + level: 0, + path: '', + }); + dir = { id: r.data && (r.data.id ?? r.data) }; + console.log('菜单目录「协作」已创建 id=' + dir.id); + } else { + console.log('菜单目录「协作」已存在 id=' + dir.id); + } + const dirId = dir.id; + + for (const p of PAGES) { + const exists = items.find((m) => m.parentId === dirId && m.routePath === p.routePath); + if (exists) { + console.log(`菜单「${p.name}」已存在 id=${exists.id}`); + continue; + } + const r = await api(h, 'POST', '/api/data/BaseSys_Menu/add', { + name: p.name, + menuType: 1, + routePath: p.routePath, + component: p.component, + icon: p.icon, + isVisible: 1, + isEnable: 1, + sort: p.sort, + parentId: dirId, + level: 1, + path: '/' + dirId, + }); + console.log(`菜单「${p.name}」已创建 id=${r.data && (r.data.id ?? r.data)}`); + } + + console.log('\n完成。协作菜单下已就绪 4 个 IM 管理页面。'); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-im.mjs b/server/scripts/seed-im.mjs new file mode 100644 index 0000000..b1b77f0 --- /dev/null +++ b/server/scripts/seed-im.mjs @@ -0,0 +1,64 @@ +/** + * 轻量 IM 种子: + * 1) 发几条演示系统通知(工单变更/工艺单变更/审批通知) + * ⚠️ 说明:原「协作 → 即时通讯」菜单已移除(聊天入口改为右下角浮窗,见 layout 的 ImFloatWindow), + * 本脚本不再创建该菜单,仅保留演示通知功能。 + * 用法:node server/scripts/seed-im.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; +const ME_ID = 1; + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: h, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + if (!res.ok || (json && json.code !== 0)) { + throw new Error(`HTTP ${res.status} ${url}: ${text.slice(0, 300)}`); + } + return json; +} + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', { + phone: PHONE, + password: PASSWORD, + }); + const token = login.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + // ============ 1. 演示系统通知 ============ + const demos = [ + { title: '工单变更', content: '任务工单 GD-20260815-003 的排产时间由 08:30 调整为 09:30,请注意查看。', msgType: 1 }, + { title: '工艺单变更', content: '工艺庄口「四川凉山-2026春茧」工艺参数已更新:缫丝车速 95r/min → 100r/min。', msgType: 1 }, + { title: '审批通知', content: '您有 1 条原料出库审批待办(CK-20260815-002),请及时处理。', msgType: 2 }, + ]; + for (const d of demos) { + const r = await api(h, 'POST', '/api/im/notify', { + userIds: [ME_ID], + msgType: d.msgType, + title: d.title, + content: d.content, + }); + console.log('通知「' + d.title + '」已发送(' + r.data + ' 条)'); + } + + // ============ 2. 验证 ============ + const unread = await api(h, 'GET', '/api/im/unread-count'); + console.log('当前未读数:' + unread.data); + const sessions = await api(h, 'GET', '/api/im/sessions'); + console.log('会话数:' + (sessions.data || []).length); + + console.log('\n完成。演示通知已就绪。'); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-notice.mjs b/server/scripts/seed-notice.mjs new file mode 100644 index 0000000..52f0129 --- /dev/null +++ b/server/scripts/seed-notice.mjs @@ -0,0 +1,37 @@ +/** + * 一次性演示:群发一条「公告」类型系统通知(msgType=0) + * 用法:node server/scripts/seed-notice.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; + +async function main() { + const login = await fetch(BASE + '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: PHONE, password: PASSWORD }), + }); + const lj = await login.json(); + if (login.status !== 200 || lj.code !== 0) throw new Error('登录失败: ' + JSON.stringify(lj).slice(0, 300)); + const token = lj.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + const r = await fetch(BASE + '/api/im/notify', { + method: 'POST', + headers: h, + body: JSON.stringify({ + msgType: 0, + title: '关于8月16日全厂停电检修的通知', + content: '各位同事:因供电局线路检修,8月16日(周日)08:00-18:00 全厂停电,请各车间提前做好设备断电与原料防护工作。综合管理部', + }), + }); + const j = await r.json(); + if (r.status !== 200 || j.code !== 0) throw new Error('发送失败: ' + JSON.stringify(j).slice(0, 300)); + console.log('公告已群发:' + j.data + ' 条'); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-org.mjs b/server/scripts/seed-org.mjs new file mode 100644 index 0000000..646cb0d --- /dev/null +++ b/server/scripts/seed-org.mjs @@ -0,0 +1,104 @@ +/** + * 创建缫丝厂组织树 + 为全部用户绑定所属组织 + * 用法:node server/scripts/seed-org.mjs + * 依赖:管理员 13800000000/123456 可用 + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; + +// 组织树:name -> { type, children } +const ORGS = [ + { name: '缫丝总厂', type: 0, children: [ + { name: '选茧车间', type: 2 }, + { name: '煮茧车间', type: 2 }, + { name: '缫丝一车间', type: 2 }, + { name: '缫丝二车间', type: 2 }, + { name: '复摇车间', type: 2 }, + { name: '质检部', type: 1 }, + { name: '设备动力部', type: 1 }, + { name: '仓储部', type: 1 }, + { name: '综合管理部', type: 1 }, + ]}, +]; + +const USER_ORG_PLAN = { + 1: '综合管理部', // 管理员 + 2: '缫丝一车间', // 李工(车间主管) + // 3~22 随机用户:按 id 循环分配 +}; + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, headers: h, body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + return { ok: res.ok, json, text }; +} + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', + { phone: '13800000000', password: '123456' }); + if (!login.ok || login.json?.code !== 0) throw new Error('管理员登录失败: ' + login.text.slice(0, 200)); + const token = login.json.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + // ---- 1. 建组织树(若已存在同名则跳过)---- + const orgs = await api(h, 'GET', '/api/data/BaseSys_Org/all'); + const existing = (Array.isArray(orgs.json?.data) ? orgs.json.data : orgs.json?.data?.data ?? []); + const byName = new Map(existing.map((o) => [o.name, o])); + + const nameToId = {}; // 组织名 -> id(含父子路径的 key 冗余,便于解析) + const addOrg = async (o, parentId, level, sort) => { + if (byName.has(o.name)) { + nameToId[o.name] = byName.get(o.name).id; + nameToId[`${parentId}/${o.name}`] = byName.get(o.name).id; + return; + } + const r = await api(h, 'POST', '/api/data/BaseSys_Org/add', { + parentId, name: o.name, orgType: o.type, sort, level, + path: parentId === 0 ? '' : `/${parentId}`, + }); + if (!r.ok || r.json?.code !== 0) throw new Error(`建组织失败 ${o.name}: ${r.text.slice(0, 200)}`); + nameToId[o.name] = r.json.data.id; + nameToId[`${parentId}/${o.name}`] = r.json.data.id; + byName.set(o.name, r.json.data); + console.log(` 组织: ${' '.repeat(level)}${o.name} (id=${r.json.data.id})`); + }; + + console.log('=== 创建组织树 ==='); + for (const root of ORGS) { + await addOrg(root, 0, 0, 1); + for (let i = 0; i < root.children.length; i++) { + await addOrg(root.children[i], nameToId[root.name], 1, i + 1); + } + } + + // ---- 2. 绑定用户组织(先清空旧绑定,保证幂等)---- + console.log('=== 绑定用户组织 ==='); + const users = await api(h, 'GET', '/api/data/BaseSys_User/all'); + const userList = (Array.isArray(users.json?.data) ? users.json.data : users.json?.data?.data ?? []); + + const oldBinds = await api(h, 'GET', '/api/data/BaseSys_UserOrg/all'); + const oldList = (Array.isArray(oldBinds.json?.data) ? oldBinds.json.data : oldBinds.json?.data?.data ?? []); + const delIds = oldList.map((b) => b.id); + if (delIds.length) await api(h, 'POST', '/api/data/BaseSys_UserOrg/deleteRange', delIds); + + const orgIdOf = (name) => nameToId[name]; + + // 有效部门列表(车间+部门,不含总厂) + const leafNames = ORGS[0].children.map((c) => c.name); + let bindCount = 0; + for (const u of userList) { + let orgName = USER_ORG_PLAN[u.id]; + if (!orgName) orgName = leafNames[(u.id - 3 + leafNames.length) % leafNames.length]; + const orgId = orgIdOf(orgName); + if (!orgId) { console.log(` 跳过 ${u.name}(无组织 ${orgName})`); continue; } + const r = await api(h, 'POST', '/api/data/BaseSys_UserOrg/add', { userId: u.id, orgId }); + if (r.ok && r.json?.code === 0) { bindCount++; console.log(` ${u.name} (id=${u.id}) -> ${orgName}`); } + else console.log(` 失败 ${u.name}: ${r.text.slice(0, 100)}`); + } + console.log(`\n完成:绑定 ${bindCount} 个用户`); +} + +main().catch((e) => { console.error('ERR: ' + e.message); process.exit(1); }); diff --git a/server/scripts/seed-print-template.mjs b/server/scripts/seed-print-template.mjs new file mode 100644 index 0000000..84cb9b1 --- /dev/null +++ b/server/scripts/seed-print-template.mjs @@ -0,0 +1,46 @@ +// Seed script: create the "1/3 A4 out-stock form" print template. +// Run: node seed-print-template.mjs +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const BASE = process.env.F9_API_BASE || 'http://localhost:5136' +const PHONE = '13800000000' +const PASSWORD = '123456' + +const tpl = JSON.parse( + fs.readFileSync(path.join(__dirname, 'print-template-outstock.json'), 'utf8') +) + +async function main() { + const loginRes = await fetch(`${BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: PHONE, password: PASSWORD }) + }) + const login = await loginRes.json() + if (login.code !== 0) throw new Error(`Login failed: ${login.message}`) + const token = login.data.token + const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + + const list = await fetch(`${BASE}/api/print/templates`, { headers }).then((r) => r.json()) + const dup = (list.items || []).find((i) => i.name === tpl.name) + if (dup) { + console.log(`Template already exists, skip: ${tpl.name} (id=${dup.id})`) + return + } + + const res = await fetch(`${BASE}/api/print/templates`, { + method: 'POST', + headers, + body: JSON.stringify({ name: tpl.name, content: JSON.stringify(tpl.content) }) + }) + const row = await res.json() + console.log(`Template created: id=${row.id} name=${row.name}`) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/server/scripts/seed-test-outstock.mjs b/server/scripts/seed-test-outstock.mjs new file mode 100644 index 0000000..f007867 --- /dev/null +++ b/server/scripts/seed-test-outstock.mjs @@ -0,0 +1,78 @@ +/** + * 任务1 联调收尾:造测试数据(庄口 + 原料出库单)并验证打印数据端点 + * 用法:node server/scripts/seed-test-outstock.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; + +async function main() { + const login = await fetch(BASE + '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone: PHONE, password: PASSWORD }), + }).then((r) => r.json()); + if (login.code !== 0) throw new Error('登录失败: ' + JSON.stringify(login)); + const h = { Authorization: 'Bearer ' + login.data.token, 'Content-Type': 'application/json' }; + + // 1. 庄口:有则复用,无则创建 + let zkId = 0; + const zk = await fetch(BASE + '/api/data/RawMaterial_Zhuangkou/all', { headers: h }).then((r) => r.json()); + const zkItems = (zk.data && zk.data.items) || (Array.isArray(zk.data) ? zk.data : []); + if (zkItems.length) { + zkId = zkItems[0].id; + console.log('复用庄口 id=' + zkId); + } else { + const code = 'YLZ' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '-001'; + const add = await fetch(BASE + '/api/data/RawMaterial_Zhuangkou/add', { + method: 'POST', + headers: h, + body: JSON.stringify({ code, name: '四川凉山-2026春茧', season: '春茧', cocoonType: 0, status: 1 }), + }).then((r) => r.json()); + if (add.code !== 0) throw new Error('建庄口失败: ' + JSON.stringify(add)); + zkId = add.data && (add.data.id ?? add.data); + if (typeof zkId !== 'number') { + const page = await fetch(BASE + '/api/data/RawMaterial_Zhuangkou/all', { headers: h }).then((r) => r.json()); + const items = (page.data && page.data.items) || page.data || []; + zkId = items[items.length - 1].id; + } + console.log('新建庄口 id=' + zkId); + } + + // 2. 原料出库单(若无则创建) + const all = await fetch(BASE + '/api/data/RawMaterial_OutStock/all', { headers: h }).then((r) => r.json()); + const items = (all.data && all.data.items) || (Array.isArray(all.data) ? all.data : []); + let rowId; + if (items.length) { + rowId = items[0].id; + console.log('复用出库单 id=' + rowId); + } else { + const billNo = 'CK-' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '-001'; + const add = await fetch(BASE + '/api/data/RawMaterial_OutStock/add', { + method: 'POST', + headers: h, + body: JSON.stringify({ + billNo, + zhuangkouId: zkId, + outOrgId: 0, + outWeight: 125.5, + receiver: '张三', + outDate: new Date().toISOString().slice(0, 10), + outType: 0, + status: 1, + }), + }).then((r) => r.json()); + if (add.code !== 0) throw new Error('建出库单失败: ' + JSON.stringify(add)); + rowId = add.data && (add.data.id ?? add.data); + console.log('新建出库单 id=' + rowId); + } + + // 3. 验证打印数据端点 + const pd = await fetch(BASE + '/api/print/data/RawMaterial_OutStock/' + rowId, { headers: h }).then((r) => r.json()); + console.log('PRINTDATA=' + JSON.stringify(pd)); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-users.mjs b/server/scripts/seed-users.mjs new file mode 100644 index 0000000..895903a --- /dev/null +++ b/server/scripts/seed-users.mjs @@ -0,0 +1,78 @@ +/** + * 随机新增 20 个测试用户(默认密码 123456) + * 用法:node server/scripts/seed-users.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; + +const SURNAMES = '王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘于蒋蔡余杜叶程苏魏吕丁任沈姚卢姜崔钟谭陆汪范金石廖贾夏韦傅方白邹孟熊秦邱江尹薛闫段雷侯龙史陶黎贺顾毛郝龚邵万钱严覃武戴莫孔向汤'.split(''); +const GIVEN = '伟芳娜敏静丽强磊军洋勇艳杰娟涛明超秀兰霞平刚桂英华建国志强建华建军建国云萍晓华小红美玲玉珍玉梅国强建平'.split(''); + +function rand(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + +/** 随机 11 位手机号(138/139/150/151/152/158/159/186/188/189 开头) */ +function randPhone() { + const head = rand(['138', '139', '150', '151', '152', '158', '159', '186', '188', '189']); + let tail = ''; + for (let i = 0; i < 8; i++) tail += Math.floor(Math.random() * 10); + return head + tail; +} + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: h, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + return { ok: res.ok, json, text }; +} + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', { + phone: PHONE, + password: PASSWORD, + }); + if (!login.ok || login.json?.code !== 0) throw new Error('管理员登录失败: ' + login.text.slice(0, 200)); + const token = login.json.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + // 获取现有手机号,避免冲突 + const exist = await api(h, 'GET', '/api/data/BaseSys_User/all'); + const items = exist.json?.data?.items || (Array.isArray(exist.json?.data) ? exist.json.data : []); + const used = new Set(items.map((u) => u.phone || u.Phone).filter(Boolean)); + + let created = 0, skipped = 0; + const results = []; + while (created < 20) { + const phone = randPhone(); + if (used.has(phone)) { skipped++; continue; } + used.add(phone); + const name = rand(SURNAMES) + rand(GIVEN); + const gender = Math.random() < 0.5 ? 1 : 2; + const r = await api(h, 'POST', '/api/basesys/user/create', { + phone, password: PASSWORD, name, gender, status: 1, + }); + if (r.ok && r.json?.code === 0) { + const id = r.json.data?.id ?? r.json.data; + results.push({ id, name, phone }); + created++; + } else { + // 该手机号可能已被占(并发/冲突),换号重试 + skipped++; + } + } + + console.log('新增用户 ' + created + ' 个(跳过/重试 ' + skipped + ' 次)'); + console.log('默认密码:' + PASSWORD); + console.table(results.map((u) => ({ ID: u.id, 姓名: u.name, 手机号: u.phone }))); + console.log('\n完成。'); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-workflow.mjs b/server/scripts/seed-workflow.mjs new file mode 100644 index 0000000..b58d1fa --- /dev/null +++ b/server/scripts/seed-workflow.mjs @@ -0,0 +1,123 @@ +/** + * 任务2 种子:简易工作流 + * 1) 创建「原料出库审批」流程定义 + 节点(开始 → 车间主管审批 → 结束) + * 2) 创建侧边栏菜单:工作流(目录)→ 待办中心 / 我发起的 + * 用法:node server/scripts/seed-workflow.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; +// 默认审批人:超级管理员(用户ID=1) +const APPROVER_ID = 1; + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: h, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + if (!res.ok || (json && json.code !== 0)) { + throw new Error(`HTTP ${res.status} ${url}: ${text.slice(0, 300)}`); + } + return json; +} + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', { + phone: PHONE, + password: PASSWORD, + }); + const token = login.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + // ============ 1. 流程定义 ============ + const flows = (await api(h, 'GET', '/api/workflow/definitions?bizType=RawMaterial_OutStock')).data || []; + let flow = flows.find((f) => f.code === 'RM_OUT_APPROVE'); + if (flow) { + console.log('流程已存在:id=' + flow.id + ' name=' + flow.name + '(跳过创建)'); + } else { + // 直接写库:Common_Workflow + Common_WorkflowNode(走通用 data 接口) + const addFlow = await api(h, 'POST', '/api/data/Common_Workflow/add', { + name: '原料出库审批', + code: 'RM_OUT_APPROVE', + bizType: 'RawMaterial_OutStock', + status: 1, + }); + const flowId = addFlow.data && (addFlow.data.id ?? addFlow.data); + console.log('流程已创建 id=' + flowId); + + const nodes = [ + { workflowId: flowId, name: '发起', nodeType: 0, sort: 10 }, + { workflowId: flowId, name: '车间主管审批', nodeType: 1, approverJson: JSON.stringify([APPROVER_ID]), sort: 20 }, + { workflowId: flowId, name: '结束', nodeType: 4, sort: 99 }, + ]; + for (const n of nodes) { + const r = await api(h, 'POST', '/api/data/Common_WorkflowNode/add', n); + console.log(' 节点「' + n.name + '」id=' + (r.data && (r.data.id ?? r.data))); + } + flow = { id: flowId }; + } + + // ============ 2. 菜单 ============ + const menus = (await api(h, 'GET', '/api/data/BaseSys_Menu/all')).data || []; + const items = (menus.items || (Array.isArray(menus) ? menus : [])); + let dir = items.find((m) => m.name === '工作流' && m.menuType === 0); + if (!dir) { + const r = await api(h, 'POST', '/api/data/BaseSys_Menu/add', { + name: '工作流', + menuType: 0, + routePath: '', + component: '', + icon: 'Checked', + isVisible: 1, + isEnable: 1, + sort: 999, + parentId: 0, + level: 0, + path: '', + }); + dir = { id: r.data && (r.data.id ?? r.data) }; + console.log('菜单目录「工作流」已创建 id=' + dir.id); + } else { + console.log('菜单目录「工作流」已存在 id=' + dir.id); + } + + const dirId = dir.id; + const childDefs = [ + { name: '待办中心', routePath: '/workflow/todo', component: 'WorkFlow/todo', icon: 'Bell', sort: 10 }, + { name: '我发起的', routePath: '/workflow/my', component: 'WorkFlow/my', icon: 'Document', sort: 20 }, + ]; + const refresh = (await api(h, 'GET', '/api/data/BaseSys_Menu/all')).data || {}; + const allMenus = refresh.items || (Array.isArray(refresh) ? refresh : []); + for (const c of childDefs) { + const exists = allMenus.find((m) => m.parentId === dirId && m.routePath === c.routePath); + if (exists) { + console.log('菜单「' + c.name + '」已存在 id=' + exists.id); + continue; + } + const r = await api(h, 'POST', '/api/data/BaseSys_Menu/add', { + name: c.name, + menuType: 1, + routePath: c.routePath, + component: c.component, + icon: c.icon, + isVisible: 1, + isEnable: 1, + sort: c.sort, + parentId: dirId, + level: 1, + path: '/' + dirId, + }); + console.log('菜单「' + c.name + '」已创建 id=' + (r.data && (r.data.id ?? r.data))); + } + + console.log('\n完成。流程=' + (flow.id || '?') + '(原料出库审批 RM_OUT_APPROVE)'); +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/scripts/seed-zhuangkou-progress.mjs b/server/scripts/seed-zhuangkou-progress.mjs new file mode 100644 index 0000000..de46473 --- /dev/null +++ b/server/scripts/seed-zhuangkou-progress.mjs @@ -0,0 +1,128 @@ +/** + * 一次性演示:为庄口 GYZ-2026-002 补充分工段生产数据(幂等,可重复执行) + * 选茧(领料5000→上车茧3200) → 煮茧(送茧2800) → 缫丝(下丝900) → 复摇(返丝830) → 秤大丝(820) + * 用法:node server/scripts/seed-zhuangkou-progress.mjs + */ +const BASE = process.env.F9MES_API || 'http://localhost:5136'; +const PHONE = '13800000000'; +const PASSWORD = '123456'; +const PZ_ID = 1; // 工艺庄口 GYZ-2026-002 +const RAW_ZK_ID = 2; // 原料庄口(选茧按原料庄口关联) + +async function api(h, method, url, body) { + const res = await fetch(BASE + url, { + method, + headers: h, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* ignore */ } + if (!res.ok || (json && json.code !== 0)) { + throw new Error(`HTTP ${res.status} ${url}: ${text.slice(0, 300)}`); + } + return json; +} + +async function clearRows(h, table, field, value) { + const r = await api(h, 'POST', `/api/data/${table}/page`, { + page: 1, size: 200, filters: [{ field, value }], + }); + const items = (r.data && r.data.items) || []; + if (!items.length) return 0; + const ids = items.map((i) => i.id); + await api(h, 'POST', `/api/data/${table}/deleteRange`, ids); + return ids.length; +} + +async function main() { + const login = await api({ 'Content-Type': 'application/json' }, 'POST', '/api/auth/login', { + phone: PHONE, + password: PASSWORD, + }); + const token = login.data.token; + const h = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + + // 0) 清理该庄口旧演示数据(幂等) + const cleared = { + xuan: await clearRows(h, 'ProXuan_Daily', 'zhuangkouId', RAW_ZK_ID), + boil: await clearRows(h, 'ProQian_CocoonBoiling', 'processZhuangkouId', PZ_ID), + thread: await clearRows(h, 'ProQian_ThreadRecord', 'processZhuangkouId', PZ_ID), + hou: await clearRows(h, 'ProHou_Daily', 'processZhuangkouId', PZ_ID), + weigh: await clearRows(h, 'ProHou_WeighBig', 'processZhuangkouId', PZ_ID), + order: await clearRows(h, 'ProPlan_WorkOrder', 'processZhuangkouId', PZ_ID), + }; + console.log('已清理旧数据:', JSON.stringify(cleared)); + + // 1) 生产计划工单(选茧 + 前缫),插入后自动联动重算庄口总体进度 + await api(h, 'POST', '/api/data/ProPlan_WorkOrder/add', { + orderNo: 'WO-2026-08-001', processZhuangkouId: PZ_ID, targetProcess: 0, + planInput: 5000, planOutput: 4000, planStartDate: '2026-08-14T08:00:00', planEndDate: '2026-08-20T08:00:00', status: 1, + }); + await api(h, 'POST', '/api/data/ProPlan_WorkOrder/add', { + orderNo: 'WO-2026-08-002', processZhuangkouId: PZ_ID, targetProcess: 1, + planInput: 3500, planOutput: 1500, planStartDate: '2026-08-14T08:00:00', planEndDate: '2026-08-22T08:00:00', status: 1, + }); + console.log('工单已创建(选茧计划产出4000kg / 前缫计划产丝1500kg)'); + + // 2) 选茧日报:领料 5000 → 上车茧 3200 + const xuans = [ + { dailyDate: '2026-08-14T08:00:00', teamId: 1, zhuangkouId: RAW_ZK_ID, inputWeight: 3000, goodWeight: 1900, wasteWeight: 950, workerCount: 12, workHours: 8 }, + { dailyDate: '2026-08-15T08:00:00', teamId: 1, zhuangkouId: RAW_ZK_ID, inputWeight: 2000, goodWeight: 1300, wasteWeight: 550, workerCount: 12, workHours: 8 }, + ]; + for (const row of xuans) { + const r = await api(h, 'POST', '/api/data/ProXuan_Daily/add', row); + console.log('选茧日报 id=' + (r.data && (r.data.id ?? JSON.stringify(r.data).slice(0, 60)))); + } + + // 3) 煮茧送茧 2800 + const boils = [ + { boilDate: '2026-08-14T09:00:00', processZhuangkouId: PZ_ID, specId: 1, inputWeight: 1500, boilerNo: 'A01' }, + { boilDate: '2026-08-15T09:00:00', processZhuangkouId: PZ_ID, specId: 1, inputWeight: 1300, boilerNo: 'A02' }, + ]; + for (const row of boils) { + const r = await api(h, 'POST', '/api/data/ProQian_CocoonBoiling/add', row); + console.log('煮茧 id=' + (r.data && (r.data.id ?? JSON.stringify(r.data).slice(0, 60)))); + } + + // 4) 缫丝落丝 900 + const threads = [ + { recordDate: '2026-08-14T18:00:00', processZhuangkouId: PZ_ID, machineId: 1, shift: 1, threadCount: 48, weight: 480, specId: 1, employeeId: 1 }, + { recordDate: '2026-08-15T18:00:00', processZhuangkouId: PZ_ID, machineId: 2, shift: 2, threadCount: 42, weight: 420, specId: 1, employeeId: 1 }, + ]; + for (const row of threads) { + const r = await api(h, 'POST', '/api/data/ProQian_ThreadRecord/add', row); + console.log('缫丝落丝 id=' + (r.data && (r.data.id ?? JSON.stringify(r.data).slice(0, 60)))); + } + + // 5) 复摇日报:上机 850 → 返丝 830 + const hous = [ + { dailyDate: '2026-08-14T20:00:00', processZhuangkouId: PZ_ID, teamId: 2, inputWeight: 450, outputWeight: 440, outputCount: 44 }, + { dailyDate: '2026-08-15T20:00:00', processZhuangkouId: PZ_ID, teamId: 2, inputWeight: 400, outputWeight: 390, outputCount: 39 }, + ]; + for (const row of hous) { + const r = await api(h, 'POST', '/api/data/ProHou_Daily/add', row); + console.log('复摇日报 id=' + (r.data && (r.data.id ?? JSON.stringify(r.data).slice(0, 60)))); + } + + // 6) 秤大丝 820 + const weigh = await api(h, 'POST', '/api/data/ProHou_WeighBig/add', { + billNo: 'W-20260815-001', processZhuangkouId: PZ_ID, weighDate: '2026-08-15T21:00:00', totalWeight: 820, packageCount: 41, + }); + console.log('秤大丝 id=' + (weigh.data && (weigh.data.id ?? JSON.stringify(weigh.data).slice(0, 60)))); + + // 7) 回读分工段进度 + const r = await api(h, 'GET', '/api/workbench/zhuangkou-progress'); + const d = r.data || {}; + console.log('--- zhuangkou-progress total=' + d.total + ' producing=' + d.producing); + const it = (d.items || [])[0]; + if (it) { + console.log('庄口=' + it.code + ' 状态=' + it.statusText + ' 总体进度=' + it.progress + ' 计划产丝=' + it.planOutput + 'kg'); + for (const s of it.stages) console.log(' ' + s.name + ' ' + s.flow + ' 实际=' + s.actual + ' 基准=' + s.base + ' rate=' + s.rate + '%'); + } +} + +main().catch((e) => { + console.error('ERR: ' + e.message); + process.exit(1); +}); diff --git a/server/src/F9MES.Api/Controllers/ImAdminController.cs b/server/src/F9MES.Api/Controllers/ImAdminController.cs new file mode 100644 index 0000000..89cf9b9 --- /dev/null +++ b/server/src/F9MES.Api/Controllers/ImAdminController.cs @@ -0,0 +1,65 @@ +using System.Threading.Tasks; +using F9MES.Application.Im; +using F9MES.Common.Result; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9MES.Api.Controllers; + +/// IM 系统管理:性能/服务监测、配置界面、系统消息与公告管理 +[ApiController] +[Route("api/im/admin")] +[Authorize] +public class ImAdminController : ControllerBase +{ + private readonly ImAdminService _svc; + + public ImAdminController(ImAdminService svc) + { + _svc = svc; + } + + /// 性能监测 + [HttpGet("performance")] + public async Task Performance() + { + return Ok(ApiResult.Ok(await _svc.PerformanceAsync())); + } + + /// 服务监测 + [HttpGet("service")] + public async Task Service() + { + return Ok(ApiResult.Ok(await _svc.ServiceStatusAsync())); + } + + /// 读取 IM 配置 + [HttpGet("config")] + public async Task Config() + { + return Ok(ApiResult.Ok(await _svc.GetConfigAsync())); + } + + /// 保存 IM 配置 + [HttpPost("config")] + public async Task SaveConfig([FromBody] List inputs) + { + await _svc.SaveConfigAsync(inputs ?? new()); + return Ok(ApiResult.Ok(true, "配置已保存")); + } + + /// 系统消息管理分页(msgType=-1 全部) + [HttpGet("messages")] + public async Task Messages([FromQuery] string? kw, [FromQuery] int msgType = -1, [FromQuery] int page = 1, [FromQuery] int size = 20) + { + return Ok(ApiResult.Ok(await _svc.GetMessagesAsync(kw, msgType, page, size))); + } + + /// 删除消息 + [HttpDelete("messages/{id}")] + public async Task DeleteMessage(long id) + { + await _svc.DeleteMessageAsync(id); + return Ok(ApiResult.Ok(true, "已删除")); + } +} diff --git a/server/src/F9MES.Api/Controllers/ImController.cs b/server/src/F9MES.Api/Controllers/ImController.cs new file mode 100644 index 0000000..3bc9316 --- /dev/null +++ b/server/src/F9MES.Api/Controllers/ImController.cs @@ -0,0 +1,95 @@ +using System.Threading.Tasks; +using F9MES.Application.Im; +using F9MES.Common.Auth; +using F9MES.Common.Result; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9MES.Api.Controllers; + +/// 轻量 IM:单聊 + 系统通知(SSE 长连接实时推送) +[ApiController] +[Route("api/im")] +[Authorize] +public class ImController : ControllerBase +{ + private readonly ImService _im; + private readonly ImEventHub _hub; + private readonly CurrentUserService _currentUser; + + public ImController(ImService im, ImEventHub hub, CurrentUserService currentUser) + { + _im = im; + _hub = hub; + _currentUser = currentUser; + } + + /// + /// SSE 长连接:订阅新消息/通知的实时推送。 + /// 客户端用 fetch 流式读取(EventSource 无法携带 Authorization header), + /// 收到 message 事件后刷新会话/消息列表即可,无需再轮询。 + /// + [HttpGet("events")] + public async Task Events(CancellationToken ct) + { + var me = _currentUser.UserId; + if (me <= 0) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + _im.TouchActive(); + Response.ContentType = "text/event-stream"; + Response.Headers.CacheControl = "no-cache"; + Response.Headers.Append("Connection", "keep-alive"); + await _hub.SubscribeAsync(me, Response, ct); + } + + /// 会话列表 + [HttpGet("sessions")] + public async Task Sessions() + { + var list = await _im.GetSessionsAsync(); + return Ok(ApiResult.Ok(list)); + } + + /// 与某人的聊天记录 + [HttpGet("messages")] + public async Task Messages([FromQuery] long peerId, [FromQuery] int page = 1, [FromQuery] int size = 20) + { + var (items, total) = await _im.GetMessagesAsync(peerId, page, size); + return Ok(ApiResult.Ok(new { items, total, page, size })); + } + + /// 发送单聊消息 + [HttpPost("send")] + public async Task Send([FromBody] ImSendInput input) + { + await _im.SendAsync(input.PeerId, input.Content); + return Ok(ApiResult.Ok(true, "发送成功")); + } + + /// 标记与某人的会话已读 + [HttpPost("read")] + public async Task Read([FromBody] ImReadInput input) + { + await _im.ReadAsync(input.PeerId); + return Ok(ApiResult.Ok(true)); + } + + /// 未读消息总数(顶栏角标) + [HttpGet("unread-count")] + public async Task UnreadCount() + { + var count = await _im.GetUnreadCountAsync(); + return Ok(ApiResult.Ok(count)); + } + + /// 系统/业务通知群发(工单/工艺单变更等) + [HttpPost("notify")] + public async Task Notify([FromBody] ImNotifyInput input) + { + var count = await _im.NotifyAsync(input); + return Ok(ApiResult.Ok(count, $"已发送 {count} 条通知")); + } +} diff --git a/server/src/F9MES.Api/Controllers/PrintApiController.cs b/server/src/F9MES.Api/Controllers/PrintApiController.cs index 130e842..08e81ee 100644 --- a/server/src/F9MES.Api/Controllers/PrintApiController.cs +++ b/server/src/F9MES.Api/Controllers/PrintApiController.cs @@ -1,6 +1,10 @@ +using System.Reflection; +using F9MES.Api.Common; +using F9MES.Application.Base; using F9MES.Application.Print; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; namespace F9MES.Api.Controllers; @@ -18,11 +22,38 @@ namespace F9MES.Api.Controllers; [Route("api/print")] public class PrintApiController : ControllerBase { - private readonly PrintService _print; + /// + /// 打印数据关联字段补全映射:主表 *Id 字段 → 关联表及取名字段。 + /// 出库单/入库单等打印时自动注入 {Xxx}Code / {Xxx}Name(如 ZhuangkouCode、OutOrgName)。 + /// + private static readonly Dictionary RefMap = + new(StringComparer.OrdinalIgnoreCase) + { + ["ZhuangkouId"] = ("RawMaterial_Zhuangkou", "Code", "Name"), + ["OutOrgId"] = ("BaseSys_Org", null, "Name"), + ["SupplierId"] = ("BaseCommon_Partner", null, "Name"), + ["EmployeeId"] = ("HRS_Employee", null, "Name"), + ["TeamId"] = ("ProXuan_Team", null, "Name"), + ["MachineId"] = ("ProQian_Machine", "MachineNo", "Name"), + ["CustomerId"] = ("Sams_Customer", null, "Name"), + }; - public PrintApiController(PrintService print) + /// 枚举字段文本映射:打印时自动注入 {Prop}Text(如 OutTypeText = 领料/退货/报损) + private static readonly Dictionary> EnumMaps = + new(StringComparer.OrdinalIgnoreCase) + { + ["RawMaterial_OutStock.OutType"] = new() { [0] = "领料", [1] = "退货", [2] = "报损" }, + ["RawMaterial_OutStock.Status"] = new() { [0] = "草稿", [1] = "已出库", [2] = "作废" }, + ["RawMaterial_Instock.InstockType"] = new() { [0] = "采购", [1] = "退货" }, + }; + + private readonly PrintService _print; + private readonly IServiceProvider _sp; + + public PrintApiController(PrintService print, IServiceProvider sp) { _print = print; + _sp = sp; } // ==================== 模板 ==================== @@ -92,6 +123,79 @@ public class PrintApiController : ControllerBase return Ok(new { items, total = items.Count }); } + // ==================== 打印数据 ==================== + + /// + /// 取单据行数据(供 openprint 外部打印页 / 设计器真实数据预览使用)。 + /// 返回裸 JSON 信封:{ "表名": { 字段... } },字段键与 PrintService.BuildFields 的 Path 前缀一致; + /// 并按 RefMap 自动补全 *Id 关联的 Code/Name(如 ZhuangkouCode、OutOrgName)。 + /// + [HttpGet("data/{table}/{id:long}")] + public async Task GetPrintData(string table, long id) + { + if (!EntityCatalog.TryGet(table, out var entityType)) + return Err(StatusCodes.Status404NotFound, "表不存在", $"table={table}"); + + var row = await GetRowAsync(entityType, id); + if (row == null) + return Err(StatusCodes.Status404NotFound, "数据不存在", $"table={table}&id={id}"); + + // 行对象 → 属性字典(保持 PascalCase 键,后续序列化为 camelCase,openprint 侧自动补齐别名) + var props = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in row.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (p.GetIndexParameters().Length > 0) continue; + props[p.Name] = p.GetValue(row); + // 枚举文本注入:{Table}.{Prop} → {Prop}Text + if (props[p.Name] is int ev && + EnumMaps.TryGetValue($"{table}.{p.Name}", out var map) && + map.TryGetValue(ev, out var txt)) + { + props[$"{p.Name}Text"] = txt; + } + } + + // 关联字段补全:*Id → {Xxx}Code / {Xxx}Name + foreach (var kv in props.ToList()) + { + if (!kv.Key.EndsWith("Id", StringComparison.Ordinal) || kv.Value is not long refId || refId <= 0) continue; + if (!RefMap.TryGetValue(kv.Key, out var refInfo)) continue; + if (!EntityCatalog.TryGet(refInfo.Table, out var refType)) continue; + var refRow = await GetRowAsync(refType, refId); + if (refRow == null) continue; + + var baseName = kv.Key[..^2]; // 去掉 "Id" 后缀 + var code = GetRefField(refRow, refInfo.CodeField); + var name = GetRefField(refRow, refInfo.NameField) ?? code; + if (!string.IsNullOrEmpty(code)) props[$"{baseName}Code"] = code; + if (!string.IsNullOrEmpty(name)) props[$"{baseName}Name"] = name; + } + + return Ok(new Dictionary { [table] = props }); + } + + /// 读取关联行指定字段值 + private static string? GetRefField(object row, string? field) + { + if (string.IsNullOrEmpty(field)) return null; + var p = row.GetType().GetProperty(field, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + var v = p?.GetValue(row); + return v == null ? null : v.ToString(); + } + + /// 按实体类型 + 主键动态查询单行(复用 CrudService 模板) + private async Task GetRowAsync(Type entityType, long id) + { + var crudType = typeof(CrudService<>).MakeGenericType(entityType); + var service = ActivatorUtilities.CreateInstance(_sp, crudType); + var method = crudType.GetMethod("GetByIdAsync", new[] { typeof(long) }); + if (method == null) return null; + var task = (Task)method.Invoke(service, new object[] { id })!; + await task.ConfigureAwait(false); + return task.GetType().GetProperty("Result")?.GetValue(task); + } + /// 统一错误信封:{ code, message, detail, requestId } private IActionResult Err(int status, string message, string? detail = null) { diff --git a/server/src/F9MES.Api/Controllers/WorkBenchController.cs b/server/src/F9MES.Api/Controllers/WorkBenchController.cs index a03dc18..5b17f51 100644 --- a/server/src/F9MES.Api/Controllers/WorkBenchController.cs +++ b/server/src/F9MES.Api/Controllers/WorkBenchController.cs @@ -6,6 +6,7 @@ using F9MES.Domain.ProHou; using F9MES.Domain.ProPlan; using F9MES.Domain.ProQian; using F9MES.Domain.Process; +using F9MES.Domain.ProXuan; using FreeSql; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -126,7 +127,7 @@ public class WorkBenchController : ControllerBase type = "审批", statusText = "待审批", time = "", - url = "/common/workflowtask" + url = "/workflow/todo" }); } @@ -159,6 +160,155 @@ public class WorkBenchController : ControllerBase return ApiResult.Ok(new { cards, trend, zhuangkou, todos, warnings }); } + /// 庄口生产分工段进度(选茧→煮茧→缫丝→复摇→秤大丝) + [HttpGet("zhuangkou-progress")] + public async Task ZhuangkouProgress() + { + var pzs = await _db.Select() + .Where(z => z.Flag == 1 && z.Status != 4) + .OrderBy(z => z.Status) + .OrderByDescending(z => z.AddTime) + .ToListAsync(z => new { z.Id, z.Code, z.Name, z.ZhuangkouId, z.Progress, z.Status }); + + var pzIds = pzs.Select(p => p.Id).ToList(); + var zkIds = pzs.Select(p => p.ZhuangkouId).Where(id => id > 0).Distinct().ToList(); + + // 选茧(ProXuan_Daily 按原料庄口 ZhuangkouId 关联) + var xuanMap = new Dictionary(); + if (zkIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && zkIds.Contains(a.ZhuangkouId)) + .ToListAsync(a => new { a.ZhuangkouId, a.InputWeight, a.GoodWeight }); + xuanMap = rows + .GroupBy(x => x.ZhuangkouId) + .ToDictionary(g => g.Key, g => (Input: g.Sum(x => x.InputWeight), Good: g.Sum(x => x.GoodWeight))); + } + + // 煮茧(送茧量) + var boilMap = new Dictionary(); + if (pzIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && pzIds.Contains(a.ProcessZhuangkouId)) + .ToListAsync(a => new { a.ProcessZhuangkouId, a.InputWeight }); + boilMap = rows.GroupBy(x => x.ProcessZhuangkouId).ToDictionary(g => g.Key, g => g.Sum(x => x.InputWeight)); + } + + // 缫丝(下丝量) + var threadMap = new Dictionary(); + if (pzIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && pzIds.Contains(a.ProcessZhuangkouId)) + .ToListAsync(a => new { a.ProcessZhuangkouId, a.Weight }); + threadMap = rows.GroupBy(x => x.ProcessZhuangkouId).ToDictionary(g => g.Key, g => g.Sum(x => x.Weight)); + } + + // 复摇(上机丝量 / 返丝产量) + var houMap = new Dictionary(); + if (pzIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && pzIds.Contains(a.ProcessZhuangkouId)) + .ToListAsync(a => new { a.ProcessZhuangkouId, a.InputWeight, a.OutputWeight }); + houMap = rows + .GroupBy(x => x.ProcessZhuangkouId) + .ToDictionary(g => g.Key, g => (Input: g.Sum(x => x.InputWeight), Output: g.Sum(x => x.OutputWeight))); + } + + // 秤大丝(总重量) + var weighMap = new Dictionary(); + if (pzIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && pzIds.Contains(a.ProcessZhuangkouId)) + .ToListAsync(a => new { a.ProcessZhuangkouId, a.TotalWeight }); + weighMap = rows.GroupBy(x => x.ProcessZhuangkouId).ToDictionary(g => g.Key, g => g.Sum(x => x.TotalWeight)); + } + + // 工单计划(TargetProcess:0=选茧 1=前缫) + var orderMap = new Dictionary>(); + if (pzIds.Count > 0) + { + var rows = await _db.Select() + .Where(a => a.Flag == 1 && pzIds.Contains(a.ProcessZhuangkouId)) + .ToListAsync(a => new { a.ProcessZhuangkouId, a.TargetProcess, a.PlanOutput }); + orderMap = rows + .GroupBy(x => x.ProcessZhuangkouId) + .ToDictionary(g => g.Key, g => g.Select(x => (x.TargetProcess, x.PlanOutput)).ToList()); + } + + var items = new List(); + foreach (var p in pzs) + { + var xuan = xuanMap.GetValueOrDefault(p.ZhuangkouId); + var boil = boilMap.GetValueOrDefault(p.Id); + var thread = threadMap.GetValueOrDefault(p.Id); + var hou = houMap.GetValueOrDefault(p.Id); + var weigh = weighMap.GetValueOrDefault(p.Id); + var orders = orderMap.GetValueOrDefault(p.Id) ?? new List<(int, decimal)>(); + + // 计划产丝量(前缫工单计划产出合计) + var planOutput = orders.Where(o => o.TargetProcess == 1).Sum(o => o.PlanOutput); + + var stages = new List + { + Stage("xuan", "选茧", "领料 → 上车茧", xuan.Good, xuan.Input), + Stage("boil", "煮茧", "上车茧 → 送茧", boil, xuan.Good), + Stage("thread", "缫丝", "送茧 → 下丝", thread, boil), + Stage("reel", "复摇", "上机丝 → 返丝", hou.Output, hou.Input), + Stage("weigh", "秤大丝", "返丝 → 成件称重", weigh, hou.Output) + }; + + items.Add(new + { + id = p.Id, + code = p.Code, + name = string.IsNullOrEmpty(p.Name) ? p.Code : p.Name, + status = p.Status, + statusText = ZhuangkouStatusText(p.Status), + progress = p.Progress, + planOutput, + stages + }); + } + + return ApiResult.Ok(new + { + total = pzs.Count, + producing = pzs.Count(p => p.Status == 1), + notStarted = pzs.Count(p => p.Status == 0), + paused = pzs.Count(p => p.Status == 2), + finished = pzs.Count(p => p.Status == 3), + items + }); + } + + /// 构建单个工段进度(rate = actual / base * 100,封顶 100) + private static object Stage(string key, string name, string flow, decimal actual, decimal baseVal) + { + var rate = baseVal > 0 ? Math.Round(Math.Min(100m, actual / baseVal * 100m), 1) : 0m; + return new + { + key, + name, + flow, + actual = Math.Round(actual, 1), + @base = Math.Round(baseVal, 1), + rate + }; + } + + private static string ZhuangkouStatusText(int status) => status switch + { + 0 => "未投产", + 1 => "生产中", + 2 => "已暂停", + 3 => "已完成", + _ => "已关闭" + }; + private static string WorkOrderStatusText(int status) => status switch { 0 => "待下发", diff --git a/server/src/F9MES.Api/Controllers/WorkflowController.cs b/server/src/F9MES.Api/Controllers/WorkflowController.cs new file mode 100644 index 0000000..2737cda --- /dev/null +++ b/server/src/F9MES.Api/Controllers/WorkflowController.cs @@ -0,0 +1,81 @@ +using System.Threading.Tasks; +using F9MES.Application.Workflow; +using F9MES.Common.Auth; +using F9MES.Common.Result; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +/// +/// 简易工作流 API:流程定义、发起、待办、审批、实例查询 +/// +[ApiController] +[Authorize] +[Route("api/workflow")] +public class WorkflowController : ControllerBase +{ + private readonly WorkflowService _workflow; + private readonly CurrentUserService _currentUser; + + public WorkflowController(WorkflowService workflow, CurrentUserService currentUser) + { + _workflow = workflow; + _currentUser = currentUser; + } + + /// 已发布流程定义列表(可按业务类型过滤) + [HttpGet("definitions")] + public async Task GetDefinitions([FromQuery] string? bizType) + { + var list = await _workflow.GetDefinitionsAsync(bizType); + return Ok(ApiResult.Ok(list)); + } + + /// 发起流程 + [HttpPost("start")] + public async Task Start(WorkflowStartInput input) + { + var instanceId = await _workflow.StartAsync(input, _currentUser.UserId); + return Ok(ApiResult.Ok(new { instanceId }, "流程发起成功")); + } + + /// 我的待办(分页) + [HttpGet("todos")] + public async Task GetMyTodos([FromQuery] int page = 1, [FromQuery] int size = 20) + { + var (items, total) = await _workflow.GetMyTodosAsync(_currentUser.UserId, page, size); + return Ok(ApiResult.Ok(PageResult.From(page, size, total, items))); + } + + /// 同意 + [HttpPost("approve")] + public async Task Approve(WorkflowHandleInput input) + { + await _workflow.ApproveAsync(input, _currentUser.UserId); + return Ok(ApiResult.Ok(null, "已同意")); + } + + /// 驳回 + [HttpPost("reject")] + public async Task Reject(WorkflowHandleInput input) + { + await _workflow.RejectAsync(input, _currentUser.UserId); + return Ok(ApiResult.Ok(null, "已驳回")); + } + + /// 我发起的实例(分页) + [HttpGet("instances")] + public async Task GetMyInstances([FromQuery] int page = 1, [FromQuery] int size = 20, [FromQuery] int? status = null) + { + var (items, total) = await _workflow.GetMyInstancesAsync(_currentUser.UserId, page, size, status); + return Ok(ApiResult.Ok(PageResult.From(page, size, total, items))); + } + + /// 实例详情(含任务轨迹) + [HttpGet("instance/{id:long}")] + public async Task GetInstanceDetail([FromRoute] long id) + { + var detail = await _workflow.GetInstanceDetailAsync(id); + if (detail == null) return Ok(ApiResult.Error("流程实例不存在")); + return Ok(ApiResult.Ok(detail)); + } +} diff --git a/server/src/F9MES.Api/Program.cs b/server/src/F9MES.Api/Program.cs index d45f64a..f32adf3 100644 --- a/server/src/F9MES.Api/Program.cs +++ b/server/src/F9MES.Api/Program.cs @@ -3,7 +3,9 @@ using F9MES.Application.Auth; using F9MES.Application.Biz; using F9MES.Application.Init; using F9MES.Application.Base; +using F9MES.Application.Im; using F9MES.Application.Print; +using F9MES.Application.Workflow; using F9MES.Common.Auth; using F9MES.Common.Cache; using F9MES.Common.FreeSql; @@ -61,6 +63,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // OpenPrint 打印对接(模板 CRUD + 数据源内省) builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // 业务单号生成器(编号流水表存于主库) builder.Services.AddSingleton(); diff --git a/server/src/F9MES.Api/appsettings.json b/server/src/F9MES.Api/appsettings.json index c62ed9b..6455daf 100644 --- a/server/src/F9MES.Api/appsettings.json +++ b/server/src/F9MES.Api/appsettings.json @@ -19,7 +19,13 @@ "Domain": "" }, "Cors": { - "Origins": [ "http://localhost:5173", "http://localhost:5174", "http://localhost:8080" ] + "Origins": [ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:8080", + "http://localhost:5227", + "http://127.0.0.1:5227" + ] }, "Logging": { "LogLevel": { diff --git a/server/src/F9MES.Application/F9MES.Application.csproj b/server/src/F9MES.Application/F9MES.Application.csproj index b68a9cf..e48148b 100644 --- a/server/src/F9MES.Application/F9MES.Application.csproj +++ b/server/src/F9MES.Application/F9MES.Application.csproj @@ -11,4 +11,8 @@ enable + + + + diff --git a/server/src/F9MES.Application/Im/ImAdminService.cs b/server/src/F9MES.Application/Im/ImAdminService.cs new file mode 100644 index 0000000..dea8cba --- /dev/null +++ b/server/src/F9MES.Application/Im/ImAdminService.cs @@ -0,0 +1,219 @@ +using F9MES.Common.Result; +using F9MES.Domain.BaseSys; +using F9MES.Domain.Common; +using F9MES.Domain.Im; +using FreeSql; +using System.Diagnostics; +using System.Text; + +namespace F9MES.Application.Im; + +/// IM 系统管理:性能监测 / 服务监测 / 配置管理 / 消息管理 +public class ImAdminService +{ + private readonly IFreeSql _db; + private readonly ImService _im; + + public ImAdminService(IFreeSql db, ImService im) + { + _db = db; + _im = im; + } + + // ==================== 性能监测 ==================== + + public async Task PerformanceAsync() + { + var now = DateTime.Now; + var todayStart = now.Date; + var weekStart = todayStart.AddDays(-6); + + var total = await _db.Select().Where(m => m.Flag == 1).CountAsync(); + var today = await _db.Select() + .Where(m => m.Flag == 1 && m.SendTime >= todayStart).CountAsync(); + var unread = await _db.Select() + .Where(m => m.Flag == 1 && m.IsRead == 0).CountAsync(); + + // 近 7 天消息趋势 + var weekRows = await _db.Select() + .Where(m => m.Flag == 1 && m.SendTime >= weekStart) + .GroupBy(m => m.SendTime.Date) + .ToListAsync(g => new { Day = g.Key, Count = g.Count() }); + var trendMap = weekRows.ToDictionary(x => x.Day.Date, x => x.Count); + var trend = new List(); + for (var d = weekStart; d <= todayStart; d = d.AddDays(1)) + trend.Add(new { date = d.ToString("MM-dd"), count = trendMap.TryGetValue(d, out var c) ? c : 0 }); + + // 消息类型分布 + var typeRows = await _db.Select() + .Where(m => m.Flag == 1) + .GroupBy(m => m.MsgType) + .ToListAsync(g => new { Type = g.Key, Count = g.Count() }); + var typeNames = new Dictionary + { + { 0, "系统通知" }, { 1, "业务提醒" }, { 2, "审批通知" }, { 3, "预警" }, { 4, "单聊消息" } + }; + var typeDist = typeRows.Select(x => new { type = x.Type, name = typeNames.GetValueOrDefault(x.Type, $"类型{x.Type}"), count = x.Count }).ToList(); + + // 会话数:去重 发送人+接收人 组合(系统群发 SenderId=0 不参与) + var pairs = await _db.Select() + .Where(m => m.Flag == 1 && m.SenderId != 0) + .ToListAsync(m => new { A = m.SenderId < m.UserId ? m.SenderId : m.UserId, B = m.SenderId < m.UserId ? m.UserId : m.SenderId }); + var sessionCount = pairs.Select(p => $"{p.A}-{p.B}").Distinct().Count(); + + // 平均消息长度 + var bodyLen = await _db.Select() + .Where(m => m.Flag == 1 && m.Content != null) + .ToListAsync(m => m.Content ?? ""); + var avgLen = bodyLen.Count == 0 ? 0 : (int)Math.Round(bodyLen.Average(s => Encoding.UTF8.GetByteCount(s))); + + return new + { + total, + today, + unread, + online = _im.OnlineCount(), + sessions = sessionCount, + avgLen, + trend, + typeDist, + updatedAt = now.ToString("yyyy-MM-dd HH:mm:ss"), + }; + } + + // ==================== 服务监测 ==================== + + public async Task ServiceStatusAsync() + { + var dbOk = false; + string dbDetail = ""; + try + { + await _db.Ado.ExecuteScalarAsync("SELECT 1"); + dbOk = true; + dbDetail = _db.Ado.DataType.ToString(); + } + catch (Exception ex) + { + dbDetail = ex.Message; + } + + var lastMsg = await _db.Select() + .Where(m => m.Flag == 1) + .OrderByDescending(m => m.SendTime) + .FirstAsync(m => m.SendTime); + var total = await _db.Select().Where(m => m.Flag == 1).CountAsync(); + var startTime = Process.GetCurrentProcess().StartTime; + var uptime = DateTime.Now - startTime; + var tableCountObj = await _db.Ado.ExecuteScalarAsync( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()"); + var tableCount = tableCountObj == null ? 0 : Convert.ToInt64(tableCountObj); + + return new + { + apiStatus = "正常", + apiDetail = "HTTP API 响应正常", + dbStatus = dbOk ? "正常" : "异常", + dbDetail, + dbName = "f9web(MySQL)", + startTime = startTime.ToString("yyyy-MM-dd HH:mm:ss"), + uptime = $"{uptime.Days}天{uptime.Hours}小时{uptime.Minutes}分", + lastMsgTime = lastMsg == default ? "" : lastMsg.ToString("yyyy-MM-dd HH:mm:ss"), + totalMessages = total, + online = _im.OnlineCount(), + serverTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + tableCount, + }; + } + + // ==================== 配置管理 ==================== + + private static List DefaultConfigs() => new() + { + new() { Key = "pollInterval", Name = "轮询间隔(秒)", Value = "5", }, + new() { Key = "retainDays", Name = "消息保留天数", Value = "30", }, + new() { Key = "defaultTitle", Name = "默认公告标题", Value = "系统公告", }, + new() { Key = "maxBody", Name = "消息内容最大长度", Value = "500", }, + }; + + /// 读取配置(含默认值,数据库已存的覆盖) + public async Task> GetConfigAsync() + { + var rows = await _db.Select().ToListAsync(); + var map = rows.ToDictionary(r => r.Key, r => new ImConfigInput { Key = r.Key, Value = r.Value, Name = r.Name }); + var list = DefaultConfigs(); + foreach (var item in list) + if (map.TryGetValue(item.Key, out var saved)) + item.Value = saved.Value; + return list; + } + + /// 保存配置(按 Key 更新或插入) + public async Task SaveConfigAsync(List inputs) + { + if (inputs == null || inputs.Count == 0) return; + foreach (var i in inputs) + { + var exists = await _db.Select().Where(c => c.Key == i.Key).FirstAsync(); + if (exists == null) + { + await _db.Insert(new Im_Config { Key = i.Key, Value = i.Value, Name = i.Name, Flag = 1 }).ExecuteAffrowsAsync(); + } + else + { + await _db.Update() + .Set(c => c.Value, i.Value) + .Set(c => c.Name, i.Name) + .Set(c => c.UpdateTime, DateTime.Now) + .Where(c => c.Key == i.Key) + .ExecuteAffrowsAsync(); + } + } + } + + // ==================== 消息管理 ==================== + + /// 消息管理分页(msgType=-1 全部;kw 匹配标题/内容) + public async Task> GetMessagesAsync(string? kw, int msgType, int page, int size) + { + var q = _db.Select().Where(m => m.Flag == 1); + if (msgType >= 0) q = q.Where(m => m.MsgType == msgType); + if (!string.IsNullOrWhiteSpace(kw)) + q = q.Where(m => m.Title.Contains(kw) || (m.Content != null && m.Content.Contains(kw))); + q = q.OrderByDescending(m => m.SendTime); + + var total = await q.CountAsync(); + var rows = await q.Skip((page - 1) * size).Take(size) + .ToListAsync(m => new { m.Id, m.SenderId, m.SenderName, m.UserId, m.Title, m.Content, m.MsgType, m.IsRead, m.SendTime }); + + var userIds = rows.Select(r => r.UserId).Distinct().ToList(); + var users = await _db.Select() + .Where(u => userIds.Contains(u.Id)) + .ToListAsync(u => new { u.Id, u.Name }); + var nameMap = users.ToDictionary(u => u.Id, u => u.Name); + + var items = rows.Select(r => new ImAdminMessageDto + { + Id = r.Id, + SenderId = r.SenderId, + SenderName = r.SenderId == 0 ? "系统" : (r.SenderName ?? $"用户{r.SenderId}"), + ReceiverId = r.UserId, + ReceiverName = nameMap.TryGetValue(r.UserId, out var n) ? n : $"用户{r.UserId}", + Title = r.Title, + Content = r.Content ?? "", + MsgType = r.MsgType, + IsRead = r.IsRead, + SendTime = r.SendTime, + }).ToList(); + return PageResult.From(page, size, total, items); + } + + /// 删除消息(软删) + public async Task DeleteMessageAsync(long id) + { + return await _db.Update() + .Set(m => m.Flag, 0) + .Where(m => m.Id == id) + .ExecuteAffrowsAsync(); + } +} diff --git a/server/src/F9MES.Application/Im/ImDtos.cs b/server/src/F9MES.Application/Im/ImDtos.cs new file mode 100644 index 0000000..536eef0 --- /dev/null +++ b/server/src/F9MES.Application/Im/ImDtos.cs @@ -0,0 +1,102 @@ +namespace F9MES.Application.Im; + +/// 发送单聊消息入参 +public class ImSendInput +{ + /// 接收人用户ID + public long PeerId { get; set; } + + /// 消息内容 + public string Content { get; set; } = ""; +} + +/// 标记已读入参 +public class ImReadInput +{ + /// 对端用户ID(标记与该用户的所有未读) + public long PeerId { get; set; } +} + +/// 系统/业务通知入参(群发) +public class ImNotifyInput +{ + /// 接收人用户ID列表(空=全部启用用户) + public List? UserIds { get; set; } + + /// 消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警 + public int MsgType { get; set; } = 0; + + /// 标题 + public string Title { get; set; } = ""; + + /// 内容 + public string Content { get; set; } = ""; +} + +/// 会话条目 +public class ImSessionDto +{ + public long PeerId { get; set; } + public string PeerName { get; set; } = ""; + public string? LastContent { get; set; } + public DateTime LastTime { get; set; } + public int Unread { get; set; } + public int MsgType { get; set; } + /// 是否系统通知/公告会话(PeerId=0) + public bool IsSystem { get; set; } +} + +/// 消息条目 +public class ImMessageDto +{ + public long Id { get; set; } + public long SenderId { get; set; } + public string SenderName { get; set; } = ""; + public long ReceiverId { get; set; } + public string Content { get; set; } = ""; + /// 标题(系统通知/公告标题) + public string? Title { get; set; } + public int MsgType { get; set; } + public DateTime SendTime { get; set; } + /// 是否我发出的 + public bool Mine { get; set; } + /// 是否系统通知/公告消息 + public bool IsSystem { get; set; } +} + +/// IM 配置项(读写入参/返回) +public class ImConfigInput +{ + /// 配置键 + public string Key { get; set; } = ""; + + /// 配置值 + public string Value { get; set; } = ""; + + /// 配置名称 + public string Name { get; set; } = ""; +} + +/// IM 管理消息条目 +public class ImAdminMessageDto +{ + public long Id { get; set; } + /// 发送人ID(0=系统) + public long SenderId { get; set; } + /// 发送人姓名 + public string SenderName { get; set; } = ""; + /// 接收人ID + public long ReceiverId { get; set; } + /// 接收人姓名 + public string ReceiverName { get; set; } = ""; + /// 标题 + public string Title { get; set; } = ""; + /// 内容 + public string Content { get; set; } = ""; + /// 消息类型:0=系统 1=业务 2=审批 3=预警 4=单聊 + public int MsgType { get; set; } + /// 是否已读 + public int IsRead { get; set; } + /// 发送时间 + public DateTime SendTime { get; set; } +} diff --git a/server/src/F9MES.Application/Im/ImEventHub.cs b/server/src/F9MES.Application/Im/ImEventHub.cs new file mode 100644 index 0000000..5abf25e --- /dev/null +++ b/server/src/F9MES.Application/Im/ImEventHub.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace F9MES.Application.Im; + +/// +/// IM SSE 长连接中枢(单例): +/// 维护 userId -> 连接集合,向在线客户端实时推送新消息/通知。 +/// 客户端通过 GET /api/im/events 订阅,服务端在消息落库后推送。 +/// +public class ImEventHub +{ + private readonly ConcurrentDictionary> _clients = new(); + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// 当前已建立 SSE 长连接的用户数 + public int OnlineCount => _clients.Count; + + /// 订阅:注册连接并保持到请求取消(客户端断开/服务端关闭) + public async Task SubscribeAsync(long userId, HttpResponse response, CancellationToken ct) + { + var client = new ImSseClient(userId, response); + var list = _clients.GetOrAdd(userId, _ => new List()); + lock (list) list.Add(client); + try + { + // 握手事件:客户端可据此确认连接成功 + await client.WriteEventAsync("connected", "{}"); + // 心跳:每 20s 发注释行,防止代理/网关超时断开 + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20)); + try + { + while (await timer.WaitForNextTickAsync(ct)) + await client.WriteCommentAsync("ping"); + } + catch (OperationCanceledException) { /* 客户端断开 */ } + } + finally + { + lock (list) list.Remove(client); + if (list.Count == 0) _clients.TryRemove(userId, out _); + } + } + + /// 向指定用户推送事件 + public async Task PushToUserAsync(long userId, object data) + { + var payload = JsonSerializer.Serialize(data, JsonOpts); + await PushRawAsync(userId, payload); + } + + /// 向多个用户推送同一事件(按用户去重) + public async Task PushToUsersAsync(IEnumerable userIds, object data) + { + var payload = JsonSerializer.Serialize(data, JsonOpts); + var done = new HashSet(); + foreach (var uid in userIds) + { + if (done.Add(uid)) await PushRawAsync(uid, payload); + } + } + + private async Task PushRawAsync(long userId, string payload) + { + if (!_clients.TryGetValue(userId, out var list)) return; + List snapshot; + lock (list) snapshot = list.ToList(); + foreach (var c in snapshot) + await c.WriteEventAsync("message", payload); + } +} + +/// 单个 SSE 连接(写入失败即视为已断开,静默忽略) +public class ImSseClient +{ + public long UserId { get; } + private readonly HttpResponse _response; + + public ImSseClient(long userId, HttpResponse response) + { + UserId = userId; + _response = response; + } + + public async Task WriteEventAsync(string eventName, string data) + { + try + { + await _response.WriteAsync($"event: {eventName}\ndata: {data}\n\n"); + await _response.Body.FlushAsync(); + } + catch { /* 连接已断开 */ } + } + + public async Task WriteCommentAsync(string text) + { + try + { + await _response.WriteAsync($": {text}\n\n"); + await _response.Body.FlushAsync(); + } + catch { /* 忽略 */ } + } +} diff --git a/server/src/F9MES.Application/Im/ImService.cs b/server/src/F9MES.Application/Im/ImService.cs new file mode 100644 index 0000000..2e2c7e8 --- /dev/null +++ b/server/src/F9MES.Application/Im/ImService.cs @@ -0,0 +1,262 @@ +using F9MES.Common.Auth; +using F9MES.Common.Cache; +using F9MES.Domain.Common; +using F9MES.Domain.BaseSys; +using FreeSql; + +namespace F9MES.Application.Im; + +/// 轻量级 IM:单聊消息 + 系统通知 +public class ImService +{ + private const string ActiveKeyPrefix = "im:active:"; + private readonly IFreeSql _db; + private readonly CurrentUserService _currentUser; + private readonly CacheService _cache; + private readonly ImEventHub _hub; + + public ImService(IFreeSql db, CurrentUserService currentUser, CacheService cache, ImEventHub hub) + { + _db = db; + _currentUser = currentUser; + _cache = cache; + _hub = hub; + } + + /// 记录当前用户活跃时间(SSE 订阅 / 接口调用时触发) + public void TouchActive() + { + var me = _currentUser.UserId; + if (me > 0) _cache.Set(ActiveKeyPrefix + me, DateTime.Now, 60); + } + + /// 当前在线用户数(SSE 长连接数为准,缓存活跃估算兜底) + public int OnlineCount() => Math.Max(_hub.OnlineCount, _cache.CountByPrefix(ActiveKeyPrefix)); + + /// 会话列表:按对端聚合最后一条消息 + 未读数(系统通知/公告聚合为 peerId=0 会话并置顶) + public async Task> GetSessionsAsync() + { + TouchActive(); + var me = _currentUser.UserId; + var msgs = await _db.Select() + .Where(m => m.Flag == 1 && (m.UserId == me || m.SenderId == me)) + .OrderByDescending(m => m.SendTime) + .Limit(500) + .ToListAsync(m => new { m.Id, m.SenderId, m.UserId, m.Content, m.Title, m.SendTime, m.MsgType, m.IsRead }); + + // 按对端聚合(系统通知 SenderId=0 统一聚合为 peerId=0 的"系统通知"会话) + var dict = new Dictionary(); + foreach (var m in msgs) + { + long peerId; + bool isSystem; + if (m.SenderId == 0) + { + if (m.UserId != me) continue; // 只看发给我的系统通知 + peerId = 0; + isSystem = true; + } + else + { + peerId = m.SenderId == me ? m.UserId : m.SenderId; + if (peerId == me || peerId == 0) continue; // 跳过自聊 + isSystem = false; + } + + if (!dict.TryGetValue(peerId, out var s)) + { + s = new ImSessionDto + { + PeerId = peerId, + PeerName = isSystem ? "系统通知" : "", + IsSystem = isSystem, + }; + dict[peerId] = s; + } + if (s.LastTime < m.SendTime) + { + s.LastTime = m.SendTime; + s.LastContent = isSystem && !string.IsNullOrEmpty(m.Title) ? m.Title : m.Content; + s.MsgType = m.MsgType; + } + // 未读:接收者是我且未读 + if (m.UserId == me && m.IsRead == 0) + s.Unread++; + } + + var sessions = dict.Values + .OrderBy(s => s.IsSystem ? 0 : 1) // 系统通知/公告置顶 + .ThenByDescending(s => s.LastTime) + .ToList(); + // 补全对端用户姓名(系统会话除外) + var peerIds = sessions.Where(s => !s.IsSystem).Select(s => s.PeerId).Distinct().ToList(); + if (peerIds.Count > 0) + { + var users = await _db.Select() + .Where(u => peerIds.Contains(u.Id)) + .ToListAsync(u => new { u.Id, u.Name }); + var nameMap = users.ToDictionary(u => u.Id, u => u.Name); + foreach (var s in sessions) + if (!s.IsSystem) + s.PeerName = nameMap.TryGetValue(s.PeerId, out var n) ? n : $"用户{s.PeerId}"; + } + return sessions; + } + + /// 聊天记录(分页;peerId=0 时查询系统通知/公告) + public async Task<(List Items, int Total)> GetMessagesAsync(long peerId, int page, int size) + { + var me = _currentUser.UserId; + var query = _db.Select().Where(m => m.Flag == 1); + if (peerId == 0) + { + // 系统通知/公告:发给我的系统消息 + query = query.Where(m => m.SenderId == 0 && m.UserId == me); + } + else + { + query = query.Where(m => + (m.UserId == me && m.SenderId == peerId) || (m.UserId == peerId && m.SenderId == me)); + } + query = query.OrderByDescending(m => m.SendTime); + + var total = await query.CountAsync(); + var rows = await query.Skip((page - 1) * size).Take(size) + .ToListAsync(m => new { m.Id, m.SenderId, m.UserId, m.Content, m.Title, m.SendTime, m.MsgType }); + + // 补全对端姓名(系统通知对端即"系统通知") + string peerName = "系统通知"; + if (peerId != 0) + { + var peer = await _db.Select().Where(u => u.Id == peerId).FirstAsync(); + peerName = string.IsNullOrEmpty(peer?.Name) ? $"用户{peerId}" : peer.Name; + } + + var items = rows.Select(m => new ImMessageDto + { + Id = m.Id, + SenderId = m.SenderId, + SenderName = m.SenderId == me ? "我" : peerName, + ReceiverId = m.UserId, + Content = m.Content ?? "", + Title = m.Title, + MsgType = m.MsgType, + IsSystem = m.SenderId == 0, + SendTime = m.SendTime, + Mine = m.SenderId == me + }).ToList(); + return (items, (int)total); + } + + /// 发送单聊消息(落库后向接收方 SSE 实时推送) + public async Task SendAsync(long peerId, string content) + { + var me = _currentUser.UserId; + if (peerId == me) throw new InvalidOperationException("不能给自己发送消息"); + if (string.IsNullOrWhiteSpace(content)) throw new InvalidOperationException("消息内容不能为空"); + + var meName = _currentUser.User?.Name ?? ""; + var now = DateTime.Now; + var id = await _db.Insert(new Common_Message + { + SenderId = me, + SenderName = meName, + UserId = peerId, + Title = "单聊消息", + Content = content, + MsgType = 4, + IsRead = 0, + SendTime = now, + Flag = 1, + Adder = me, + }).ExecuteIdentityAsync(); + + // 实时推送:仅推给接收方(发送方本地已刷新) + await _hub.PushToUserAsync(peerId, new ImMessageDto + { + Id = id, + SenderId = me, + SenderName = meName, + ReceiverId = peerId, + Title = "单聊消息", + Content = content, + MsgType = 4, + IsSystem = false, + SendTime = now, + Mine = false, + }); + } + + /// 标记会话已读(peerId=0 时标记全部系统通知已读) + public async Task ReadAsync(long peerId) + { + var me = _currentUser.UserId; + var q = _db.Update().Set(m => m.IsRead, 1); + if (peerId == 0) + q = q.Where(m => m.Flag == 1 && m.UserId == me && m.SenderId == 0 && m.IsRead == 0); + else + q = q.Where(m => m.Flag == 1 && m.UserId == me && m.SenderId == peerId && m.IsRead == 0); + await q.ExecuteAffrowsAsync(); + } + + /// 未读消息总数(顶栏角标) + public async Task GetUnreadCountAsync() + { + TouchActive(); + var me = _currentUser.UserId; + return (int)await _db.Select() + .Where(m => m.Flag == 1 && m.UserId == me && m.IsRead == 0) + .CountAsync(); + } + + /// 系统/业务通知群发:给指定用户(空=全部启用用户) + public async Task NotifyAsync(ImNotifyInput input) + { + var me = _currentUser.UserId; + var title = string.IsNullOrEmpty(input.Title) ? "系统通知" : input.Title; + List userIds; + if (input.UserIds is { Count: > 0 }) + userIds = input.UserIds; + else + userIds = await _db.Select() + .Where(u => u.Flag == 1 && u.Status == 1) + .ToListAsync(u => u.Id); + + if (userIds.Count == 0) return 0; + + var now = DateTime.Now; + var msgs = userIds.Select(uid => new Common_Message + { + SenderId = 0, + SenderName = "系统", + UserId = uid, + Title = title, + Content = input.Content, + MsgType = input.MsgType, + IsRead = 0, + SendTime = now, + Flag = 1, + Adder = me, + }).ToList(); + await _db.Insert(msgs).ExecuteAffrowsAsync(); + + // 实时推送:给每个接收用户推送一条消息事件 + foreach (var uid in userIds) + { + await _hub.PushToUserAsync(uid, new ImMessageDto + { + Id = 0, + SenderId = 0, + SenderName = "系统", + ReceiverId = uid, + Title = title, + Content = input.Content ?? "", + MsgType = input.MsgType, + IsSystem = true, + SendTime = now, + Mine = false, + }); + } + return msgs.Count; + } +} diff --git a/server/src/F9MES.Application/Workflow/WorkflowDtos.cs b/server/src/F9MES.Application/Workflow/WorkflowDtos.cs new file mode 100644 index 0000000..85c6b77 --- /dev/null +++ b/server/src/F9MES.Application/Workflow/WorkflowDtos.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; + +namespace F9MES.Application.Workflow; + +/// 发起流程入参 +public class WorkflowStartInput +{ + /// 流程定义ID + public long WorkflowId { get; set; } + + /// 关联业务类型(缺省用流程定义的类型) + public string? BizType { get; set; } + + /// 关联业务记录ID + public long BizId { get; set; } + + /// 关联业务单号 + public string? BillNo { get; set; } +} + +/// 审批/驳回入参 +public class WorkflowHandleInput +{ + /// 任务ID + public long TaskId { get; set; } + + /// 处理意见 + public string? Comment { get; set; } +} + +/// 流程定义(含节点) +public class WorkflowDefinitionDto +{ + public long Id { get; set; } + public string Name { get; set; } = ""; + public string? Code { get; set; } + public string BizType { get; set; } = ""; + public List Nodes { get; set; } = new(); +} + +/// 流程节点 +public class WorkflowNodeDto +{ + public long Id { get; set; } + public string Name { get; set; } = ""; + /// 0=开始 1=审批 2=抄送 3=条件 4=结束 + public int NodeType { get; set; } + public string? ApproverJson { get; set; } + public int Sort { get; set; } +} + +/// 待办项 +public class WorkflowTodoDto +{ + public long TaskId { get; set; } + public long InstanceId { get; set; } + public long NodeId { get; set; } + public string WorkflowName { get; set; } = ""; + public string NodeName { get; set; } = ""; + public string BizType { get; set; } = ""; + public string? BillNo { get; set; } + public long BizId { get; set; } + public string StartUserName { get; set; } = ""; + public DateTime StartTime { get; set; } + public string? Comment { get; set; } +} + +/// 实例列表项 +public class WorkflowInstanceDto +{ + public long Id { get; set; } + public string WorkflowName { get; set; } = ""; + public string BizType { get; set; } = ""; + public string? BillNo { get; set; } + public long BizId { get; set; } + /// 0=进行中 1=已通过 2=已驳回 3=已撤销 + public int Status { get; set; } + /// 当前节点名称 + public string NodeName { get; set; } = ""; + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } +} + +/// 实例详情(含任务轨迹) +public class WorkflowInstanceDetailDto : WorkflowInstanceDto +{ + public List Tasks { get; set; } = new(); +} + +/// 任务轨迹项 +public class WorkflowTaskDto +{ + public long Id { get; set; } + public long NodeId { get; set; } + public string NodeName { get; set; } = ""; + public long UserId { get; set; } + public string UserName { get; set; } = ""; + public string? Comment { get; set; } + /// 0=待处理 1=已同意 2=已驳回 + public int Status { get; set; } + public DateTime? HandleTime { get; set; } + public DateTime AddTime { get; set; } +} diff --git a/server/src/F9MES.Application/Workflow/WorkflowService.cs b/server/src/F9MES.Application/Workflow/WorkflowService.cs new file mode 100644 index 0000000..d6981eb --- /dev/null +++ b/server/src/F9MES.Application/Workflow/WorkflowService.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FreeSql; +using F9MES.Domain.BaseSys; +using F9MES.Domain.Common; + +namespace F9MES.Application.Workflow; + +/// +/// 简易工作流服务:流程定义查询、发起、待办、同意、驳回、实例查询 +/// 流程模型:Common_Workflow(定义) → Common_WorkflowNode(节点) → Common_WorkflowInstance(实例) → Common_WorkflowTask(任务) +/// 节点流转:按 Sort 升序,发起后首个审批节点生成任务;同意后推进到下一审批节点,无则流程通过;驳回则流程终止。 +/// 审批人配置 ApproverJson:JSON 数组 ["1","2"](用户ID),v1 取首个用户处理。 +/// +public class WorkflowService +{ + private readonly IFreeSql _db; + + public WorkflowService(IFreeSql db) + { + _db = db; + } + + /// 已发布流程定义(含节点),可按业务类型过滤 + public async Task> GetDefinitionsAsync(string? bizType) + { + var flows = await _db.Select() + .Where(a => a.Flag > 0 && a.Status == 1) + .WhereIf(!string.IsNullOrWhiteSpace(bizType), a => a.BizType == bizType) + .OrderBy(a => a.Id) + .ToListAsync(); + + var result = new List(); + foreach (var f in flows) + { + var nodes = await _db.Select() + .Where(a => a.WorkflowId == f.Id && a.Flag > 0) + .OrderBy(a => a.Sort) + .ToListAsync(); + result.Add(new WorkflowDefinitionDto + { + Id = f.Id, + Name = f.Name, + Code = f.Code, + BizType = f.BizType, + Nodes = nodes.Select(n => new WorkflowNodeDto + { + Id = n.Id, + Name = n.Name, + NodeType = n.NodeType, + ApproverJson = n.ApproverJson, + Sort = n.Sort, + }).ToList(), + }); + } + return result; + } + + /// 发起流程:创建实例 + 首个审批任务 + public async Task StartAsync(WorkflowStartInput input, long userId) + { + var flow = await _db.Select() + .Where(a => a.Id == input.WorkflowId && a.Flag > 0 && a.Status == 1) + .FirstAsync(); + if (flow == null) + throw new InvalidOperationException("流程不存在或未发布"); + + var nodes = await _db.Select() + .Where(a => a.WorkflowId == flow.Id && a.Flag > 0) + .OrderBy(a => a.Sort) + .ToListAsync(); + var first = nodes.FirstOrDefault(a => a.NodeType == 1); + if (first == null) + throw new InvalidOperationException("流程未配置审批节点"); + + var inst = new Common_WorkflowInstance + { + WorkflowId = flow.Id, + BillNo = input.BillNo, + BizId = input.BizId, + BizType = string.IsNullOrWhiteSpace(input.BizType) ? flow.BizType : input.BizType!, + CurrentNodeId = first.Id, + StartUserId = userId, + Status = 0, + StartTime = DateTime.Now, + Flag = 1, + Adder = userId, + }; + // ExecuteIdentityAsync:返回自增主键并回填(ExecuteAffrows 不回填) + inst.Id = await _db.Insert(inst).ExecuteIdentityAsync(); + + await CreateTaskAsync(inst.Id, first, userId); + return inst.Id; + } + + /// 同意:任务完成 → 推进下一审批节点,无则实例通过 + public async Task ApproveAsync(WorkflowHandleInput input, long userId) + { + var task = await _db.Select() + .Where(a => a.Id == input.TaskId && a.Flag > 0 && a.Status == 0 && a.UserId == userId) + .FirstAsync(); + if (task == null) + throw new InvalidOperationException("任务不存在或无权处理"); + + task.Status = 1; + task.Comment = input.Comment; + task.HandleTime = DateTime.Now; + task.Updater = userId; + await _db.Update().SetSource(task).ExecuteAffrowsAsync(); + + var inst = await _db.Select().Where(a => a.Id == task.InstanceId).FirstAsync(); + if (inst == null) return; + + var nodes = await _db.Select() + .Where(a => a.WorkflowId == inst.WorkflowId && a.Flag > 0) + .OrderBy(a => a.Sort) + .ToListAsync(); + var cur = nodes.FirstOrDefault(a => a.Id == task.NodeId); + var next = cur == null ? null : nodes.FirstOrDefault(a => a.Sort > cur.Sort && a.NodeType == 1); + + if (next == null) + { + inst.Status = 1; // 已通过 + inst.EndTime = DateTime.Now; + inst.Updater = userId; + await _db.Update().SetSource(inst).ExecuteAffrowsAsync(); + } + else + { + inst.CurrentNodeId = next.Id; + inst.Updater = userId; + await _db.Update().SetSource(inst).ExecuteAffrowsAsync(); + await CreateTaskAsync(inst.Id, next, userId); + } + } + + /// 驳回:任务驳回 → 实例终止 + public async Task RejectAsync(WorkflowHandleInput input, long userId) + { + var task = await _db.Select() + .Where(a => a.Id == input.TaskId && a.Flag > 0 && a.Status == 0 && a.UserId == userId) + .FirstAsync(); + if (task == null) + throw new InvalidOperationException("任务不存在或无权处理"); + + task.Status = 2; + task.Comment = input.Comment; + task.HandleTime = DateTime.Now; + task.Updater = userId; + await _db.Update().SetSource(task).ExecuteAffrowsAsync(); + + var inst = await _db.Select().Where(a => a.Id == task.InstanceId).FirstAsync(); + if (inst == null) return; + + inst.Status = 2; // 已驳回 + inst.EndTime = DateTime.Now; + inst.Updater = userId; + await _db.Update().SetSource(inst).ExecuteAffrowsAsync(); + } + + /// 我的待办(分页) + public async Task<(List items, long total)> GetMyTodosAsync(long userId, int page, int size) + { + var query = _db.Select() + .InnerJoin((t, i, f, u) => t.InstanceId == i.Id) + .InnerJoin((t, i, f, u) => i.WorkflowId == f.Id) + .LeftJoin((t, i, f, u) => i.StartUserId == u.Id) + .Where((t, i, f, u) => t.Flag > 0 && t.UserId == userId && t.Status == 0 && i.Flag > 0); + + var total = await query.CountAsync(); + var list = await query + .OrderByDescending((t, i, f, u) => t.Id) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync((t, i, f, u) => new WorkflowTodoDto + { + TaskId = t.Id, + InstanceId = i.Id, + NodeId = t.NodeId, + WorkflowName = f.Name, + NodeName = "", + BizType = i.BizType, + BillNo = i.BillNo, + BizId = i.BizId, + StartUserName = u.Name, + StartTime = i.StartTime, + }); + + await FillNodeNamesAsync(list, a => a.NodeId, (a, n) => a.NodeName = n); + return (list, total); + } + + /// 我发起的实例(分页) + public async Task<(List items, long total)> GetMyInstancesAsync(long userId, int page, int size, int? status) + { + var query = _db.Select() + .InnerJoin((i, f) => i.WorkflowId == f.Id) + .Where((i, f) => i.Flag > 0 && i.StartUserId == userId) + .WhereIf(status.HasValue, (i, f) => i.Status == status); + + var total = await query.CountAsync(); + var list = await query + .OrderByDescending((i, f) => i.Id) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync((i, f) => new WorkflowInstanceDto + { + Id = i.Id, + WorkflowName = f.Name, + BizType = i.BizType, + BillNo = i.BillNo, + BizId = i.BizId, + Status = i.Status, + NodeName = "", + StartTime = i.StartTime, + EndTime = i.EndTime, + }); + + // 当前节点名(实例 → 节点) + var instRows = await _db.Select() + .Where(a => list.Select(x => x.Id).Contains(a.Id)) + .ToListAsync(); + var nodeIds = instRows.Where(a => a.CurrentNodeId > 0).Select(a => a.CurrentNodeId).Distinct().ToList(); + var nodes = nodeIds.Count > 0 + ? await _db.Select().Where(a => nodeIds.Contains(a.Id)).ToListAsync() + : new List(); + var nodeMap = nodes.ToDictionary(a => a.Id, a => a.Name); + foreach (var item in list) + { + var row = instRows.FirstOrDefault(a => a.Id == item.Id); + if (row != null) item.NodeName = nodeMap.GetValueOrDefault(row.CurrentNodeId, ""); + } + return (list, total); + } + + /// 实例详情(含任务轨迹) + public async Task GetInstanceDetailAsync(long id) + { + var inst = await _db.Select() + .InnerJoin((i, f) => i.WorkflowId == f.Id) + .Where((i, f) => i.Id == id && i.Flag > 0) + .ToOneAsync((i, f) => new WorkflowInstanceDetailDto + { + Id = i.Id, + WorkflowName = f.Name, + BizType = i.BizType, + BillNo = i.BillNo, + BizId = i.BizId, + Status = i.Status, + NodeName = "", + StartTime = i.StartTime, + EndTime = i.EndTime, + }); + if (inst == null) return null; + + var instRow = await _db.Select().Where(a => a.Id == id).FirstAsync(); + if (instRow != null && instRow.CurrentNodeId > 0) + { + var curNode = await _db.Select().Where(a => a.Id == instRow.CurrentNodeId).FirstAsync(); + inst.NodeName = curNode?.Name ?? ""; + } + + var tasks = await _db.Select() + .InnerJoin((t, u) => t.UserId == u.Id) + .Where((t, u) => t.InstanceId == id && t.Flag > 0) + .OrderBy((t, u) => t.Id) + .ToListAsync((t, u) => new WorkflowTaskDto + { + Id = t.Id, + NodeId = t.NodeId, + NodeName = "", + UserId = t.UserId, + UserName = u.Name, + Comment = t.Comment, + Status = t.Status, + HandleTime = t.HandleTime, + AddTime = t.AddTime, + }); + + var nodeIds = tasks.Select(a => a.NodeId).Distinct().ToList(); + var nodes = nodeIds.Count > 0 + ? await _db.Select().Where(a => nodeIds.Contains(a.Id)).ToListAsync() + : new List(); + var nodeMap = nodes.ToDictionary(a => a.Id, a => a.Name); + foreach (var t in tasks) t.NodeName = nodeMap.GetValueOrDefault(t.NodeId, ""); + inst.Tasks = tasks; + return inst; + } + + /// 创建审批任务(审批人取 ApproverJson 首个用户,缺省给操作人) + private async Task CreateTaskAsync(long instanceId, Common_WorkflowNode node, long operatorId) + { + var approverId = ParseFirstApprover(node.ApproverJson) ?? operatorId; + await _db.Insert(new Common_WorkflowTask + { + InstanceId = instanceId, + NodeId = node.Id, + UserId = approverId, + Status = 0, + Flag = 1, + Adder = operatorId, + }).ExecuteAffrowsAsync(); + } + + /// 解析 ApproverJson 首个审批人ID(JSON 数组 ["1","2"]) + private static long? ParseFirstApprover(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + var arr = JsonSerializer.Deserialize>(json); + return arr != null && arr.Count > 0 ? arr[0] : null; + } + catch + { + return null; + } + } + + /// 为列表项填充节点名 + private async Task FillNodeNamesAsync(List items, Func nodeIdGetter, Action setter) + { + var nodeIds = items.Select(nodeIdGetter).Where(a => a > 0).Distinct().ToList(); + if (nodeIds.Count == 0) return; + var nodes = await _db.Select().Where(a => nodeIds.Contains(a.Id)).ToListAsync(); + var nodeMap = nodes.ToDictionary(a => a.Id, a => a.Name); + foreach (var item in items) + { + var n = nodeMap.GetValueOrDefault(nodeIdGetter(item), ""); + setter(item, n); + } + } +} diff --git a/server/src/F9MES.Common/Cache/CacheService.cs b/server/src/F9MES.Common/Cache/CacheService.cs index 1cabf60..1e79ad6 100644 --- a/server/src/F9MES.Common/Cache/CacheService.cs +++ b/server/src/F9MES.Common/Cache/CacheService.cs @@ -60,6 +60,14 @@ public class CacheService foreach (var k in keys) _cache.Remove(k); } + /// + /// 统计指定前缀的缓存条目数(如 "im:active:" 前缀统计在线用户) + /// + public int CountByPrefix(string prefix) + { + return GetKeys().Count(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + private IEnumerable GetKeys() { // IMemoryCache 没有直接枚举方法,这里通过反射获取 key(兼容实现) diff --git a/server/src/F9MES.Domain/Common/Common_Entities.cs b/server/src/F9MES.Domain/Common/Common_Entities.cs index c9b12b3..a0018e5 100644 --- a/server/src/F9MES.Domain/Common/Common_Entities.cs +++ b/server/src/F9MES.Domain/Common/Common_Entities.cs @@ -128,6 +128,11 @@ public class Common_WorkflowInstance : BaseEntity [Column(DbType = "varchar(50)")] public string? BillNo { get; set; } + /// 关联业务记录ID(用于跳转业务详情) + [Description("关联业务记录ID")] + [Column] + public long BizId { get; set; } + /// 关联业务类型 [Description("关联业务类型")] [Column(DbType = "varchar(100)")] @@ -284,10 +289,20 @@ public class Common_Log : BaseEntity public DateTime OpTime { get; set; } = DateTime.Now; } -/// 消息通知 +/// 消息通知(含 IM 单聊) [Table(Name = "Common_Message")] public class Common_Message : BaseEntity { + /// 发送人ID(0=系统) + [Description("发送人ID(0=系统)")] + [Column] + public long SenderId { get; set; } + + /// 发送人姓名(冗余,0=系统时显示"系统通知") + [Description("发送人姓名")] + [Column(DbType = "varchar(50)")] + public string? SenderName { get; set; } + /// 接收人ID [Description("接收人ID")] [Column] @@ -303,8 +318,8 @@ public class Common_Message : BaseEntity [Column(DbType = "text")] public string? Content { get; set; } - /// 消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警 - [Description("消息类型:0=系统 1=业务 2=审批 3=预警")] + /// 消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警 4=单聊消息 + [Description("消息类型:0=系统 1=业务 2=审批 3=预警 4=单聊")] [Column] public int MsgType { get; set; } = 0; diff --git a/server/src/F9MES.Domain/Im/Im_Entities.cs b/server/src/F9MES.Domain/Im/Im_Entities.cs new file mode 100644 index 0000000..44c26d7 --- /dev/null +++ b/server/src/F9MES.Domain/Im/Im_Entities.cs @@ -0,0 +1,25 @@ +using FreeSql.DataAnnotations; +using F9MES.Common.Entities; +using System.ComponentModel; + +namespace F9MES.Domain.Im; + +/// IM 系统配置(键值对) +[Table(Name = "Im_Config")] +public class Im_Config : BaseEntity +{ + /// 配置键 + [Description("配置键")] + [Column(DbType = "varchar(100)")] + public string Key { get; set; } = ""; + + /// 配置值 + [Description("配置值")] + [Column(DbType = "text")] + public string Value { get; set; } = ""; + + /// 配置名称 + [Description("配置名称")] + [Column(DbType = "varchar(100)")] + public string Name { get; set; } = ""; +} diff --git a/web/src/api/im-sse.js b/web/src/api/im-sse.js new file mode 100644 index 0000000..39109d9 --- /dev/null +++ b/web/src/api/im-sse.js @@ -0,0 +1,142 @@ +import { useUserStore } from '@/stores/user' + +/** + * IM SSE 长连接客户端(全局单例)。 + * + * 原生 EventSource 无法携带 Authorization header,因此用 fetch 流式读取 + * GET /api/im/events 的 text/event-stream 响应,复用现有 JWT 鉴权。 + * 断线自动重连(指数退避)。最后一个订阅者取消后断开连接。 + * + * 用法: + * const unsub = subscribeImEvents((msg) => { ... }) + * onUnmounted(unsub) + */ + +const listeners = new Set() +const statusListeners = new Set() +let reader = null +let retryTimer = null +let retryDelay = 3000 +let closed = false + +// 连接状态:'connecting' 连接中 | 'connected' 已连接 | 'disconnected' 无法连接/断线重连中 +let status = 'disconnected' + +function setStatus(s) { + if (status === s) return + status = s + statusListeners.forEach((fn) => { + try { fn(status) } catch { /* 单个监听异常不影响其他 */ } + }) +} + +// 解析 SSE 块(支持 event:/data:/注释行),返回完整事件列表 + 残余未闭合 buffer +function parseSseChunk(buf) { + const events = [] + const blocks = buf.split('\n\n') + const rest = blocks.pop() + for (const block of blocks) { + let name = 'message' + let data = '' + for (const line of block.split('\n')) { + if (line.startsWith('event:')) name = line.slice(6).trim() + else if (line.startsWith('data:')) data += line.slice(5).trim() + else if (line.startsWith(':')) continue // 心跳注释行 + } + if (data) events.push({ name, data }) + } + return { events, rest } +} + +async function open() { + if (closed || reader) return + const userStore = useUserStore() + if (!userStore.token) return + setStatus('connecting') + try { + const resp = await fetch('/api/im/events', { + headers: { Authorization: `Bearer ${userStore.token}` } + }) + if (resp.status === 401) { + // token 失效:登出并回到登录页(不再重连) + userStore.logout() + window.location.href = '/login' + return + } + if (!resp.ok || !resp.body) throw new Error(`SSE 连接失败: ${resp.status}`) + + retryDelay = 3000 // 连接成功,重置退避 + reader = resp.body.getReader() + setStatus('connected') + const decoder = new TextDecoder() + let buf = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const { events, rest } = parseSseChunk(buf) + buf = rest + for (const ev of events) { + if (ev.name !== 'message' || !ev.data) continue + let payload = null + try { payload = JSON.parse(ev.data) } catch { /* 忽略脏数据 */ } + if (!payload) continue + listeners.forEach((fn) => { + try { fn(payload) } catch { /* 单个订阅者异常不影响其他 */ } + }) + } + } + } catch (e) { + // 网络错误 / 服务端重启,走重连 + } + reader = null + setStatus('disconnected') + scheduleReconnect() +} + +function scheduleReconnect() { + if (closed || retryTimer) return + retryTimer = setTimeout(() => { + retryTimer = null + open() + }, retryDelay) + retryDelay = Math.min(retryDelay * 1.5, 30000) +} + +function close() { + closed = true + setStatus('disconnected') + if (retryTimer) { clearTimeout(retryTimer); retryTimer = null } + if (reader) { + try { reader.cancel() } catch { /* 忽略 */ } + reader = null + } +} + +/** + * 订阅 IM 实时推送。 + * @param {(msg: object) => void} fn 收到消息对象(ImMessageDto 结构)时回调 + * @returns {() => void} 取消订阅函数;全部订阅者取消后自动断开长连接 + */ +export function subscribeImEvents(fn) { + listeners.add(fn) + if (!reader) { + closed = false + open() + } + return () => { + listeners.delete(fn) + if (listeners.size === 0) close() + } +} + +/** + * 订阅 IM 连接状态。 + * @param {(s: 'connecting' | 'connected' | 'disconnected') => void} fn 状态变化时回调,订阅时立即回调一次当前状态 + * @returns {() => void} 取消订阅函数 + */ +export function subscribeImStatus(fn) { + statusListeners.add(fn) + try { fn(status) } catch { /* 忽略 */ } + return () => statusListeners.delete(fn) +} diff --git a/web/src/api/im.js b/web/src/api/im.js new file mode 100644 index 0000000..9171358 --- /dev/null +++ b/web/src/api/im.js @@ -0,0 +1,37 @@ +import request from './request' + +/** 会话列表 */ +export const imSessions = () => request.get('/im/sessions') + +/** 与某人的聊天记录 */ +export const imMessages = (peerId, params) => request.get('/im/messages', { params: { peerId, ...params } }) + +/** 发送单聊消息 */ +export const imSend = (data) => request.post('/im/send', data) + +/** 标记与某人的会话已读 */ +export const imRead = (peerId) => request.post('/im/read', { peerId }) + +/** 未读消息总数 */ +export const imUnreadCount = () => request.get('/im/unread-count') + +/** 系统/业务通知群发 */ +export const imNotify = (data) => request.post('/im/notify', data) + +/** IM 性能监测 */ +export const imAdminPerformance = () => request.get('/im/admin/performance') + +/** IM 服务监测 */ +export const imAdminService = () => request.get('/im/admin/service') + +/** 读取 IM 配置 */ +export const imAdminConfig = () => request.get('/im/admin/config') + +/** 保存 IM 配置 */ +export const imAdminSaveConfig = (data) => request.post('/im/admin/config', data) + +/** 系统消息管理分页 */ +export const imAdminMessages = (params) => request.get('/im/admin/messages', { params }) + +/** 删除消息 */ +export const imAdminDeleteMessage = (id) => request.delete(`/im/admin/messages/${id}`) diff --git a/web/src/api/index.js b/web/src/api/index.js index cca15d5..d9c0f39 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -52,3 +52,5 @@ export const getMenus = () => request.get('/basesys/menus') // ========== 工作台 ========== // 工作台聚合数据:统计卡 + 趋势 + 占比 + 待办 + 预警 export const getWorkBenchSummary = () => request.get('/workbench/summary') +// 庄口生产分工段进度(选茧→煮茧→缫丝→复摇→秤大丝) +export const getZhuangkouProgress = () => request.get('/workbench/zhuangkou-progress') diff --git a/web/src/api/workflow.js b/web/src/api/workflow.js new file mode 100644 index 0000000..909f953 --- /dev/null +++ b/web/src/api/workflow.js @@ -0,0 +1,22 @@ +import request from './request' + +/** 已发布流程定义列表(可按业务类型过滤) */ +export const workflowDefinitions = (bizType) => request.get('/workflow/definitions', { params: { bizType } }) + +/** 发起流程 */ +export const workflowStart = (data) => request.post('/workflow/start', data) + +/** 我的待办(分页) */ +export const workflowTodos = (params) => request.get('/workflow/todos', { params }) + +/** 同意 */ +export const workflowApprove = (data) => request.post('/workflow/approve', data) + +/** 驳回 */ +export const workflowReject = (data) => request.post('/workflow/reject', data) + +/** 我发起的实例(分页) */ +export const workflowInstances = (params) => request.get('/workflow/instances', { params }) + +/** 实例详情(含任务轨迹) */ +export const workflowInstanceDetail = (id) => request.get(`/workflow/instance/${id}`) diff --git a/web/src/components/CrudPage.vue b/web/src/components/CrudPage.vue index 71f523b..67e6965 100644 --- a/web/src/components/CrudPage.vue +++ b/web/src/components/CrudPage.vue @@ -115,7 +115,7 @@ - + @@ -235,6 +249,24 @@ 保存 + + + + + + + + + + + + + + + @@ -246,6 +278,10 @@ import { getTableMeta, crudPage, crudAdd, crudUpdate, crudDelete, crudDeleteRange, getRefs, genCode } from '@/api' +import request from '@/api/request' +import { useUserStore } from '@/stores/user' +import { buildPrintUrl } from '@/config/print' +import { workflowDefinitions, workflowStart } from '@/api/workflow' const props = defineProps({ /** 数据表名(未传时从路由 meta.tableName 读取) */ @@ -260,6 +296,18 @@ const tableName = computed(() => props.table || route.meta.tableName || '') const pageTitle = computed(() => props.title || route.meta.title || '') /** 详情页路由(:id 占位,由菜单映射配置) */ const detailRoute = computed(() => route.meta.detailRoute || '') +/** 打印模板配置(由菜单映射配置,含 templateName/title) */ +const printTemplate = computed(() => route.meta.printTemplate || null) +/** 工作流配置(由菜单映射配置,含 bizType) */ +const workflowCfg = computed(() => route.meta.workflow || null) +/** 操作列宽度:按可用按钮动态计算 */ +const opWidth = computed(() => { + let w = 160 + if (detailRoute.value) w += 44 + if (printTemplate.value) w += 52 + if (workflowCfg.value) w += 52 + return w +}) // ================= 状态 ================= const loading = ref(false) @@ -276,6 +324,14 @@ const formRef = ref() const isEdit = ref(false) const rules = ref({}) +/** 发起审批弹窗状态 */ +const wfDialogVisible = ref(false) +const wfSaving = ref(false) +const wfFlows = ref([]) +const wfFlowId = ref(null) +const wfRow = ref(null) +const wfBillNo = ref('') + /** 关联表缓存:refTable -> { items: [{value,label}], loading } */ const refCache = reactive({}) @@ -484,9 +540,81 @@ async function handleSave() { } // ================= 详情 ================= +/** 行主键:后端全局 camelCase 序列化,行数据键为 id,兼容旧数据 PascalCase */ +const getRowId = (r) => r?.id ?? r?.Id ?? r?.ID + function handleDetail(row) { if (!detailRoute.value) return - router.push(detailRoute.value.replace(':id', row.Id)) + router.push(detailRoute.value.replace(':id', getRowId(row))) +} + +// ================= 打印 ================= +/** 按模板名查找模板并打开 openprint 外部打印页 */ +async function onPrint(row) { + const cfg = printTemplate.value + if (!cfg) return + try { + const res = await request.get('/print/templates') + const items = res.items || [] + const tpl = items.find((t) => t.name === cfg.templateName) + if (!tpl) { + ElMessage.warning(`未找到打印模板「${cfg.templateName}」,请先在打印设计器创建`) + return + } + const url = buildPrintUrl({ + template: tpl.id, + table: tableName.value, + row: getRowId(row), + token: useUserStore().token + }) + window.open(url, '_blank') + } catch (e) { + ElMessage.error('获取打印模板失败') + } +} + +// ================= 审批(发起流程) ================= +/** 打开发起审批弹窗:加载该业务类型可用的流程定义 */ +async function openWorkflow(row) { + const cfg = workflowCfg.value + if (!cfg) return + wfRow.value = row + wfBillNo.value = row.BillNo || row.billNo || '' + wfFlows.value = [] + try { + const res = await workflowDefinitions(cfg.bizType) + wfFlows.value = res.data || [] + } catch (e) { + /* 拦截器已提示 */ + } + if (!wfFlows.value.length) { + ElMessage.warning('该业务未配置可用的审批流程') + return + } + wfFlowId.value = wfFlows.value[0].id + wfDialogVisible.value = true +} + +/** 发起流程 */ +async function handleStartWorkflow() { + const row = wfRow.value + const cfg = workflowCfg.value + if (!row || !wfFlowId.value || !cfg) return + wfSaving.value = true + try { + await workflowStart({ + workflowId: wfFlowId.value, + bizType: cfg.bizType, + bizId: getRowId(row), + billNo: row.BillNo || row.billNo || '' + }) + ElMessage.success('审批流程已发起') + wfDialogVisible.value = false + } catch (e) { + /* 拦截器已提示 */ + } finally { + wfSaving.value = false + } } // ================= 删除 ================= @@ -496,7 +624,7 @@ async function handleDelete(row) { confirmButtonText: '删除', cancelButtonText: '取消' }) - await crudDelete(tableName.value, row.Id) + await crudDelete(tableName.value, getRowId(row)) ElMessage.success('删除成功') loadData() } @@ -508,7 +636,7 @@ async function handleBatchDelete() { '批量删除确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' } ) - const ids = selection.value.map((r) => r.Id) + const ids = selection.value.map((r) => getRowId(r)) await crudDeleteRange(tableName.value, ids) ElMessage.success('批量删除成功') loadData() diff --git a/web/src/components/ImFloatWindow.vue b/web/src/components/ImFloatWindow.vue new file mode 100644 index 0000000..9c7299f --- /dev/null +++ b/web/src/components/ImFloatWindow.vue @@ -0,0 +1,881 @@ + + + + + diff --git a/web/src/components/TagsView.vue b/web/src/components/TagsView.vue index 9c923a8..2e7c42b 100644 --- a/web/src/components/TagsView.vue +++ b/web/src/components/TagsView.vue @@ -95,8 +95,12 @@ const closeMenu = () => { menu.value.visible = false } -onMounted(() => document.addEventListener('click', closeMenu)) -onBeforeUnmount(() => document.removeEventListener('click', closeMenu)) +onMounted(() => { + document.addEventListener('click', closeMenu) +}) +onBeforeUnmount(() => { + document.removeEventListener('click', closeMenu) +}) diff --git a/web/src/views/im/message.vue b/web/src/views/im/message.vue new file mode 100644 index 0000000..9e99d5d --- /dev/null +++ b/web/src/views/im/message.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/web/src/views/im/monitor.vue b/web/src/views/im/monitor.vue new file mode 100644 index 0000000..f134013 --- /dev/null +++ b/web/src/views/im/monitor.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/web/src/views/im/service.vue b/web/src/views/im/service.vue new file mode 100644 index 0000000..7d3619e --- /dev/null +++ b/web/src/views/im/service.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/web/src/views/workbench/index.vue b/web/src/views/workbench/index.vue index b41f802..176f5dc 100644 --- a/web/src/views/workbench/index.vue +++ b/web/src/views/workbench/index.vue @@ -37,6 +37,72 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -136,7 +202,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' import * as echarts from 'echarts' import { useUserStore } from '@/stores/user' -import { getWeather, mapWeather, getWorkBenchSummary } from '@/api' +import { getWeather, mapWeather, getWorkBenchSummary, getZhuangkouProgress } from '@/api' const userStore = useUserStore() const trendRef = ref() @@ -145,6 +211,8 @@ const weather = ref(null) const todos = ref([]) const warnings = ref([]) const summary = ref({ cards: {}, trend: [], zhuangkou: [] }) +const zkProgress = ref({ items: [], total: 0 }) +const zkStatusFilter = ref(-1) let trendChart = null let pieChart = null @@ -180,6 +248,32 @@ const quickMenus = computed(() => { return pages }) +const filteredZk = computed(() => + zkStatusFilter.value === -1 + ? zkProgress.value.items || [] + : (zkProgress.value.items || []).filter((i) => i.status === zkStatusFilter.value) +) + +// 横向表格的工段列顺序 +const zkColumns = [ + { key: 'xuan', name: '选茧' }, + { key: 'boil', name: '煮茧' }, + { key: 'thread', name: '缫丝' }, + { key: 'reel', name: '复摇' }, + { key: 'weigh', name: '秤大丝' } +] +const stageOf = (row, key) => (row.stages || []).find((s) => s.key === key) +const stageVal = (row, key) => { + const s = stageOf(row, key) + return s ? `${fmtNum(s.actual)} / ${fmtNum(s.base)} kg` : '' +} + +// 分工段配色:选茧/煮茧/缫丝/复摇/秤大丝 +const ZK_STAGE_COLORS = { xuan: '#409eff', boil: '#67c23a', thread: '#e6a23c', reel: '#9c27b0', weigh: '#f56c6c' } +const stageColor = (key) => ZK_STAGE_COLORS[key] || '#909399' +const zkTagType = (s) => ({ 0: 'info', 1: 'success', 2: 'warning', 3: 'primary' }[s] || 'info') +const overallColor = (p) => (Number(p) >= 100 ? '#67c23a' : Number(p) >= 60 ? '#409eff' : '#e6a23c') + const weatherIcon = computed(() => { const d = weather.value?.desc || '' if (d.includes('雨')) return '🌧️' @@ -285,10 +379,20 @@ async function loadWeather() { } } +async function loadZkProgress() { + try { + const res = await getZhuangkouProgress() + if (res.code === 0 && res.data) zkProgress.value = res.data + } catch (e) { + /* 静默 */ + } +} + onMounted(() => { initCharts() loadWeather() loadSummary() + loadZkProgress() window.addEventListener('resize', resizeCharts) }) @@ -485,4 +589,60 @@ onBeforeUnmount(() => { background: #f5f7fa; color: #409eff; } +.zk-total { + font-size: 12px; + font-weight: 400; + color: #909399; +} +.zk-filter { + margin-left: auto; +} +.zk-name { + font-size: 14px; + font-weight: 600; + color: #303133; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.zk-cell-code { + font-size: 12px; + color: #909399; + margin-top: 2px; +} +.zk-progress-row { + display: flex; + align-items: center; + gap: 8px; +} +.zk-progress-row :deep(.el-progress) { + flex: 1; +} +.zk-stage-cell { + display: flex; + flex-direction: column; + gap: 4px; +} +.zk-stage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} +.zk-stage-flow { + color: #909399; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.zk-stage-val { + color: #606266; + flex-shrink: 0; + white-space: nowrap; +} +.zk-plan { + color: #606266; + font-weight: 600; +} diff --git a/web/src/views/workflow/my.vue b/web/src/views/workflow/my.vue new file mode 100644 index 0000000..1b67d47 --- /dev/null +++ b/web/src/views/workflow/my.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/web/src/views/workflow/todo.vue b/web/src/views/workflow/todo.vue new file mode 100644 index 0000000..0e4d211 --- /dev/null +++ b/web/src/views/workflow/todo.vue @@ -0,0 +1,195 @@ + + + + +