feat: 新增IM即时通讯(浮窗)、工作流、打印模块及工作台增强

- IM: 新增浮窗聊天(ImFloatWindow)、管理页(monitor/config/service/message)、SSE推送
- 工作流: 新增待办/我的流程页面及后端服务
- 打印: 新增打印模板、出库单打印(PrintPage)、模板种子脚本
- 工作台: 增强快捷入口与工作台数据
- 修复: TagsView页签关闭、CrudPage通用表格增强
- 移除导航菜单中的即时通讯入口,改为右下角浮窗
This commit is contained in:
2026-08-16 00:19:24 +08:00
parent 7235265749
commit 1bec470647
49 changed files with 5791 additions and 51 deletions
+168
View File
@@ -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 }
}
]
}
]
}
}
}
+96
View File
@@ -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);
});
+64
View File
@@ -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);
});
+37
View File
@@ -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);
});
+104
View File
@@ -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); });
+46
View File
@@ -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)
})
+78
View File
@@ -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);
});
+78
View File
@@ -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);
});
+123
View File
@@ -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);
});
+128
View File
@@ -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);
});
@@ -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;
/// <summary>IM 系统管理:性能/服务监测、配置界面、系统消息与公告管理</summary>
[ApiController]
[Route("api/im/admin")]
[Authorize]
public class ImAdminController : ControllerBase
{
private readonly ImAdminService _svc;
public ImAdminController(ImAdminService svc)
{
_svc = svc;
}
/// <summary>性能监测</summary>
[HttpGet("performance")]
public async Task<ActionResult> Performance()
{
return Ok(ApiResult.Ok(await _svc.PerformanceAsync()));
}
/// <summary>服务监测</summary>
[HttpGet("service")]
public async Task<ActionResult> Service()
{
return Ok(ApiResult.Ok(await _svc.ServiceStatusAsync()));
}
/// <summary>读取 IM 配置</summary>
[HttpGet("config")]
public async Task<ActionResult> Config()
{
return Ok(ApiResult.Ok(await _svc.GetConfigAsync()));
}
/// <summary>保存 IM 配置</summary>
[HttpPost("config")]
public async Task<ActionResult> SaveConfig([FromBody] List<ImConfigInput> inputs)
{
await _svc.SaveConfigAsync(inputs ?? new());
return Ok(ApiResult.Ok(true, "配置已保存"));
}
/// <summary>系统消息管理分页(msgType=-1 全部)</summary>
[HttpGet("messages")]
public async Task<ActionResult> 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)));
}
/// <summary>删除消息</summary>
[HttpDelete("messages/{id}")]
public async Task<ActionResult> DeleteMessage(long id)
{
await _svc.DeleteMessageAsync(id);
return Ok(ApiResult.Ok(true, "已删除"));
}
}
@@ -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;
/// <summary>轻量 IM:单聊 + 系统通知(SSE 长连接实时推送)</summary>
[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;
}
/// <summary>
/// SSE 长连接:订阅新消息/通知的实时推送。
/// 客户端用 fetch 流式读取(EventSource 无法携带 Authorization header),
/// 收到 message 事件后刷新会话/消息列表即可,无需再轮询。
/// </summary>
[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);
}
/// <summary>会话列表</summary>
[HttpGet("sessions")]
public async Task<ActionResult> Sessions()
{
var list = await _im.GetSessionsAsync();
return Ok(ApiResult.Ok(list));
}
/// <summary>与某人的聊天记录</summary>
[HttpGet("messages")]
public async Task<ActionResult> 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 }));
}
/// <summary>发送单聊消息</summary>
[HttpPost("send")]
public async Task<ActionResult> Send([FromBody] ImSendInput input)
{
await _im.SendAsync(input.PeerId, input.Content);
return Ok(ApiResult.Ok(true, "发送成功"));
}
/// <summary>标记与某人的会话已读</summary>
[HttpPost("read")]
public async Task<ActionResult> Read([FromBody] ImReadInput input)
{
await _im.ReadAsync(input.PeerId);
return Ok(ApiResult.Ok(true));
}
/// <summary>未读消息总数(顶栏角标)</summary>
[HttpGet("unread-count")]
public async Task<ActionResult> UnreadCount()
{
var count = await _im.GetUnreadCountAsync();
return Ok(ApiResult.Ok(count));
}
/// <summary>系统/业务通知群发(工单/工艺单变更等)</summary>
[HttpPost("notify")]
public async Task<ActionResult> Notify([FromBody] ImNotifyInput input)
{
var count = await _im.NotifyAsync(input);
return Ok(ApiResult.Ok(count, $"已发送 {count} 条通知"));
}
}
@@ -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;
/// <summary>
/// 打印数据关联字段补全映射:主表 *Id 字段 → 关联表及取名字段。
/// 出库单/入库单等打印时自动注入 {Xxx}Code / {Xxx}Name(如 ZhuangkouCode、OutOrgName)。
/// </summary>
private static readonly Dictionary<string, (string Table, string? CodeField, string? NameField)> 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)
/// <summary>枚举字段文本映射:打印时自动注入 {Prop}Text(如 OutTypeText = 领料/退货/报损)</summary>
private static readonly Dictionary<string, Dictionary<int, string>> 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 });
}
// ==================== 打印数据 ====================
/// <summary>
/// 取单据行数据(供 openprint 外部打印页 / 设计器真实数据预览使用)。
/// 返回裸 JSON 信封:{ "表名": { 字段... } },字段键与 PrintService.BuildFields 的 Path 前缀一致;
/// 并按 RefMap 自动补全 *Id 关联的 Code/Name(如 ZhuangkouCode、OutOrgName)。
/// </summary>
[HttpGet("data/{table}/{id:long}")]
public async Task<IActionResult> 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 键,后续序列化为 camelCaseopenprint 侧自动补齐别名)
var props = new Dictionary<string, object?>(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<string, object?> { [table] = props });
}
/// <summary>读取关联行指定字段值</summary>
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();
}
/// <summary>按实体类型 + 主键动态查询单行(复用 CrudService 模板)</summary>
private async Task<object?> 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);
}
/// <summary>统一错误信封:{ code, message, detail, requestId }</summary>
private IActionResult Err(int status, string message, string? detail = null)
{
@@ -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 });
}
/// <summary>庄口生产分工段进度(选茧→煮茧→缫丝→复摇→秤大丝)</summary>
[HttpGet("zhuangkou-progress")]
public async Task<ApiResult> ZhuangkouProgress()
{
var pzs = await _db.Select<Process_ProcessZhuangkou>()
.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<long, (decimal Input, decimal Good)>();
if (zkIds.Count > 0)
{
var rows = await _db.Select<ProXuan_Daily>()
.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<long, decimal>();
if (pzIds.Count > 0)
{
var rows = await _db.Select<ProQian_CocoonBoiling>()
.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<long, decimal>();
if (pzIds.Count > 0)
{
var rows = await _db.Select<ProQian_ThreadRecord>()
.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<long, (decimal Input, decimal Output)>();
if (pzIds.Count > 0)
{
var rows = await _db.Select<ProHou_Daily>()
.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<long, decimal>();
if (pzIds.Count > 0)
{
var rows = await _db.Select<ProHou_WeighBig>()
.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));
}
// 工单计划(TargetProcess0=选茧 1=前缫)
var orderMap = new Dictionary<long, List<(int TargetProcess, decimal PlanOutput)>>();
if (pzIds.Count > 0)
{
var rows = await _db.Select<ProPlan_WorkOrder>()
.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<object>();
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<object>
{
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
});
}
/// <summary>构建单个工段进度(rate = actual / base * 100,封顶 100</summary>
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 => "待下发",
@@ -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;
/// <summary>
/// 简易工作流 API:流程定义、发起、待办、审批、实例查询
/// </summary>
[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;
}
/// <summary>已发布流程定义列表(可按业务类型过滤)</summary>
[HttpGet("definitions")]
public async Task<ActionResult> GetDefinitions([FromQuery] string? bizType)
{
var list = await _workflow.GetDefinitionsAsync(bizType);
return Ok(ApiResult.Ok(list));
}
/// <summary>发起流程</summary>
[HttpPost("start")]
public async Task<ActionResult> Start(WorkflowStartInput input)
{
var instanceId = await _workflow.StartAsync(input, _currentUser.UserId);
return Ok(ApiResult.Ok(new { instanceId }, "流程发起成功"));
}
/// <summary>我的待办(分页)</summary>
[HttpGet("todos")]
public async Task<ActionResult> GetMyTodos([FromQuery] int page = 1, [FromQuery] int size = 20)
{
var (items, total) = await _workflow.GetMyTodosAsync(_currentUser.UserId, page, size);
return Ok(ApiResult.Ok(PageResult<WorkflowTodoDto>.From(page, size, total, items)));
}
/// <summary>同意</summary>
[HttpPost("approve")]
public async Task<ActionResult> Approve(WorkflowHandleInput input)
{
await _workflow.ApproveAsync(input, _currentUser.UserId);
return Ok(ApiResult.Ok(null, "已同意"));
}
/// <summary>驳回</summary>
[HttpPost("reject")]
public async Task<ActionResult> Reject(WorkflowHandleInput input)
{
await _workflow.RejectAsync(input, _currentUser.UserId);
return Ok(ApiResult.Ok(null, "已驳回"));
}
/// <summary>我发起的实例(分页)</summary>
[HttpGet("instances")]
public async Task<ActionResult> 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<WorkflowInstanceDto>.From(page, size, total, items)));
}
/// <summary>实例详情(含任务轨迹)</summary>
[HttpGet("instance/{id:long}")]
public async Task<ActionResult> GetInstanceDetail([FromRoute] long id)
{
var detail = await _workflow.GetInstanceDetailAsync(id);
if (detail == null) return Ok(ApiResult.Error("流程实例不存在"));
return Ok(ApiResult.Ok(detail));
}
}
+6
View File
@@ -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<ProductionService>();
builder.Services.AddScoped<FimsService>();
// OpenPrint 打印对接(模板 CRUD + 数据源内省)
builder.Services.AddScoped<PrintService>();
builder.Services.AddScoped<WorkflowService>();
builder.Services.AddScoped<ImService>();
builder.Services.AddSingleton<ImEventHub>();
builder.Services.AddScoped<ImAdminService>();
// 业务单号生成器(编号流水表存于主库)
builder.Services.AddSingleton<OrderNoGenerator>();
+7 -1
View File
@@ -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": {
@@ -11,4 +11,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>IM 系统管理:性能监测 / 服务监测 / 配置管理 / 消息管理</summary>
public class ImAdminService
{
private readonly IFreeSql _db;
private readonly ImService _im;
public ImAdminService(IFreeSql db, ImService im)
{
_db = db;
_im = im;
}
// ==================== 性能监测 ====================
public async Task<object> PerformanceAsync()
{
var now = DateTime.Now;
var todayStart = now.Date;
var weekStart = todayStart.AddDays(-6);
var total = await _db.Select<Common_Message>().Where(m => m.Flag == 1).CountAsync();
var today = await _db.Select<Common_Message>()
.Where(m => m.Flag == 1 && m.SendTime >= todayStart).CountAsync();
var unread = await _db.Select<Common_Message>()
.Where(m => m.Flag == 1 && m.IsRead == 0).CountAsync();
// 近 7 天消息趋势
var weekRows = await _db.Select<Common_Message>()
.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<object>();
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<Common_Message>()
.Where(m => m.Flag == 1)
.GroupBy(m => m.MsgType)
.ToListAsync(g => new { Type = g.Key, Count = g.Count() });
var typeNames = new Dictionary<int, string>
{
{ 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<Common_Message>()
.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<Common_Message>()
.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<object> 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<Common_Message>()
.Where(m => m.Flag == 1)
.OrderByDescending(m => m.SendTime)
.FirstAsync(m => m.SendTime);
var total = await _db.Select<Common_Message>().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 = "f9webMySQL",
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<ImConfigInput> 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", },
};
/// <summary>读取配置(含默认值,数据库已存的覆盖)</summary>
public async Task<List<ImConfigInput>> GetConfigAsync()
{
var rows = await _db.Select<Im_Config>().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;
}
/// <summary>保存配置(按 Key 更新或插入)</summary>
public async Task SaveConfigAsync(List<ImConfigInput> inputs)
{
if (inputs == null || inputs.Count == 0) return;
foreach (var i in inputs)
{
var exists = await _db.Select<Im_Config>().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<Im_Config>()
.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();
}
}
}
// ==================== 消息管理 ====================
/// <summary>消息管理分页(msgType=-1 全部;kw 匹配标题/内容)</summary>
public async Task<PageResult<ImAdminMessageDto>> GetMessagesAsync(string? kw, int msgType, int page, int size)
{
var q = _db.Select<Common_Message>().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<BaseSys_User>()
.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<ImAdminMessageDto>.From(page, size, total, items);
}
/// <summary>删除消息(软删)</summary>
public async Task<int> DeleteMessageAsync(long id)
{
return await _db.Update<Common_Message>()
.Set(m => m.Flag, 0)
.Where(m => m.Id == id)
.ExecuteAffrowsAsync();
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace F9MES.Application.Im;
/// <summary>发送单聊消息入参</summary>
public class ImSendInput
{
/// <summary>接收人用户ID</summary>
public long PeerId { get; set; }
/// <summary>消息内容</summary>
public string Content { get; set; } = "";
}
/// <summary>标记已读入参</summary>
public class ImReadInput
{
/// <summary>对端用户ID(标记与该用户的所有未读)</summary>
public long PeerId { get; set; }
}
/// <summary>系统/业务通知入参(群发)</summary>
public class ImNotifyInput
{
/// <summary>接收人用户ID列表(空=全部启用用户)</summary>
public List<long>? UserIds { get; set; }
/// <summary>消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警</summary>
public int MsgType { get; set; } = 0;
/// <summary>标题</summary>
public string Title { get; set; } = "";
/// <summary>内容</summary>
public string Content { get; set; } = "";
}
/// <summary>会话条目</summary>
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; }
/// <summary>是否系统通知/公告会话(PeerId=0</summary>
public bool IsSystem { get; set; }
}
/// <summary>消息条目</summary>
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; } = "";
/// <summary>标题(系统通知/公告标题)</summary>
public string? Title { get; set; }
public int MsgType { get; set; }
public DateTime SendTime { get; set; }
/// <summary>是否我发出的</summary>
public bool Mine { get; set; }
/// <summary>是否系统通知/公告消息</summary>
public bool IsSystem { get; set; }
}
/// <summary>IM 配置项(读写入参/返回)</summary>
public class ImConfigInput
{
/// <summary>配置键</summary>
public string Key { get; set; } = "";
/// <summary>配置值</summary>
public string Value { get; set; } = "";
/// <summary>配置名称</summary>
public string Name { get; set; } = "";
}
/// <summary>IM 管理消息条目</summary>
public class ImAdminMessageDto
{
public long Id { get; set; }
/// <summary>发送人ID0=系统)</summary>
public long SenderId { get; set; }
/// <summary>发送人姓名</summary>
public string SenderName { get; set; } = "";
/// <summary>接收人ID</summary>
public long ReceiverId { get; set; }
/// <summary>接收人姓名</summary>
public string ReceiverName { get; set; } = "";
/// <summary>标题</summary>
public string Title { get; set; } = "";
/// <summary>内容</summary>
public string Content { get; set; } = "";
/// <summary>消息类型:0=系统 1=业务 2=审批 3=预警 4=单聊</summary>
public int MsgType { get; set; }
/// <summary>是否已读</summary>
public int IsRead { get; set; }
/// <summary>发送时间</summary>
public DateTime SendTime { get; set; }
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace F9MES.Application.Im;
/// <summary>
/// IM SSE 长连接中枢(单例):
/// 维护 userId -> 连接集合,向在线客户端实时推送新消息/通知。
/// 客户端通过 GET /api/im/events 订阅,服务端在消息落库后推送。
/// </summary>
public class ImEventHub
{
private readonly ConcurrentDictionary<long, List<ImSseClient>> _clients = new();
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>当前已建立 SSE 长连接的用户数</summary>
public int OnlineCount => _clients.Count;
/// <summary>订阅:注册连接并保持到请求取消(客户端断开/服务端关闭)</summary>
public async Task SubscribeAsync(long userId, HttpResponse response, CancellationToken ct)
{
var client = new ImSseClient(userId, response);
var list = _clients.GetOrAdd(userId, _ => new List<ImSseClient>());
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 _);
}
}
/// <summary>向指定用户推送事件</summary>
public async Task PushToUserAsync(long userId, object data)
{
var payload = JsonSerializer.Serialize(data, JsonOpts);
await PushRawAsync(userId, payload);
}
/// <summary>向多个用户推送同一事件(按用户去重)</summary>
public async Task PushToUsersAsync(IEnumerable<long> userIds, object data)
{
var payload = JsonSerializer.Serialize(data, JsonOpts);
var done = new HashSet<long>();
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<ImSseClient> snapshot;
lock (list) snapshot = list.ToList();
foreach (var c in snapshot)
await c.WriteEventAsync("message", payload);
}
}
/// <summary>单个 SSE 连接(写入失败即视为已断开,静默忽略)</summary>
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 { /* 忽略 */ }
}
}
@@ -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;
/// <summary>轻量级 IM:单聊消息 + 系统通知</summary>
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;
}
/// <summary>记录当前用户活跃时间(SSE 订阅 / 接口调用时触发)</summary>
public void TouchActive()
{
var me = _currentUser.UserId;
if (me > 0) _cache.Set(ActiveKeyPrefix + me, DateTime.Now, 60);
}
/// <summary>当前在线用户数(SSE 长连接数为准,缓存活跃估算兜底)</summary>
public int OnlineCount() => Math.Max(_hub.OnlineCount, _cache.CountByPrefix(ActiveKeyPrefix));
/// <summary>会话列表:按对端聚合最后一条消息 + 未读数(系统通知/公告聚合为 peerId=0 会话并置顶)</summary>
public async Task<List<ImSessionDto>> GetSessionsAsync()
{
TouchActive();
var me = _currentUser.UserId;
var msgs = await _db.Select<Common_Message>()
.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<long, ImSessionDto>();
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<BaseSys_User>()
.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;
}
/// <summary>聊天记录(分页;peerId=0 时查询系统通知/公告)</summary>
public async Task<(List<ImMessageDto> Items, int Total)> GetMessagesAsync(long peerId, int page, int size)
{
var me = _currentUser.UserId;
var query = _db.Select<Common_Message>().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<BaseSys_User>().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);
}
/// <summary>发送单聊消息(落库后向接收方 SSE 实时推送)</summary>
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,
});
}
/// <summary>标记会话已读(peerId=0 时标记全部系统通知已读)</summary>
public async Task ReadAsync(long peerId)
{
var me = _currentUser.UserId;
var q = _db.Update<Common_Message>().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();
}
/// <summary>未读消息总数(顶栏角标)</summary>
public async Task<int> GetUnreadCountAsync()
{
TouchActive();
var me = _currentUser.UserId;
return (int)await _db.Select<Common_Message>()
.Where(m => m.Flag == 1 && m.UserId == me && m.IsRead == 0)
.CountAsync();
}
/// <summary>系统/业务通知群发:给指定用户(空=全部启用用户)</summary>
public async Task<int> NotifyAsync(ImNotifyInput input)
{
var me = _currentUser.UserId;
var title = string.IsNullOrEmpty(input.Title) ? "系统通知" : input.Title;
List<long> userIds;
if (input.UserIds is { Count: > 0 })
userIds = input.UserIds;
else
userIds = await _db.Select<BaseSys_User>()
.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;
}
}
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
namespace F9MES.Application.Workflow;
/// <summary>发起流程入参</summary>
public class WorkflowStartInput
{
/// <summary>流程定义ID</summary>
public long WorkflowId { get; set; }
/// <summary>关联业务类型(缺省用流程定义的类型)</summary>
public string? BizType { get; set; }
/// <summary>关联业务记录ID</summary>
public long BizId { get; set; }
/// <summary>关联业务单号</summary>
public string? BillNo { get; set; }
}
/// <summary>审批/驳回入参</summary>
public class WorkflowHandleInput
{
/// <summary>任务ID</summary>
public long TaskId { get; set; }
/// <summary>处理意见</summary>
public string? Comment { get; set; }
}
/// <summary>流程定义(含节点)</summary>
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<WorkflowNodeDto> Nodes { get; set; } = new();
}
/// <summary>流程节点</summary>
public class WorkflowNodeDto
{
public long Id { get; set; }
public string Name { get; set; } = "";
/// <summary>0=开始 1=审批 2=抄送 3=条件 4=结束</summary>
public int NodeType { get; set; }
public string? ApproverJson { get; set; }
public int Sort { get; set; }
}
/// <summary>待办项</summary>
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; }
}
/// <summary>实例列表项</summary>
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; }
/// <summary>0=进行中 1=已通过 2=已驳回 3=已撤销</summary>
public int Status { get; set; }
/// <summary>当前节点名称</summary>
public string NodeName { get; set; } = "";
public DateTime StartTime { get; set; }
public DateTime? EndTime { get; set; }
}
/// <summary>实例详情(含任务轨迹)</summary>
public class WorkflowInstanceDetailDto : WorkflowInstanceDto
{
public List<WorkflowTaskDto> Tasks { get; set; } = new();
}
/// <summary>任务轨迹项</summary>
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; }
/// <summary>0=待处理 1=已同意 2=已驳回</summary>
public int Status { get; set; }
public DateTime? HandleTime { get; set; }
public DateTime AddTime { get; set; }
}
@@ -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;
/// <summary>
/// 简易工作流服务:流程定义查询、发起、待办、同意、驳回、实例查询
/// 流程模型:Common_Workflow(定义) → Common_WorkflowNode(节点) → Common_WorkflowInstance(实例) → Common_WorkflowTask(任务)
/// 节点流转:按 Sort 升序,发起后首个审批节点生成任务;同意后推进到下一审批节点,无则流程通过;驳回则流程终止。
/// 审批人配置 ApproverJsonJSON 数组 ["1","2"](用户ID),v1 取首个用户处理。
/// </summary>
public class WorkflowService
{
private readonly IFreeSql _db;
public WorkflowService(IFreeSql db)
{
_db = db;
}
/// <summary>已发布流程定义(含节点),可按业务类型过滤</summary>
public async Task<List<WorkflowDefinitionDto>> GetDefinitionsAsync(string? bizType)
{
var flows = await _db.Select<Common_Workflow>()
.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<WorkflowDefinitionDto>();
foreach (var f in flows)
{
var nodes = await _db.Select<Common_WorkflowNode>()
.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;
}
/// <summary>发起流程:创建实例 + 首个审批任务</summary>
public async Task<long> StartAsync(WorkflowStartInput input, long userId)
{
var flow = await _db.Select<Common_Workflow>()
.Where(a => a.Id == input.WorkflowId && a.Flag > 0 && a.Status == 1)
.FirstAsync();
if (flow == null)
throw new InvalidOperationException("流程不存在或未发布");
var nodes = await _db.Select<Common_WorkflowNode>()
.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;
}
/// <summary>同意:任务完成 → 推进下一审批节点,无则实例通过</summary>
public async Task ApproveAsync(WorkflowHandleInput input, long userId)
{
var task = await _db.Select<Common_WorkflowTask>()
.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<Common_WorkflowTask>().SetSource(task).ExecuteAffrowsAsync();
var inst = await _db.Select<Common_WorkflowInstance>().Where(a => a.Id == task.InstanceId).FirstAsync();
if (inst == null) return;
var nodes = await _db.Select<Common_WorkflowNode>()
.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<Common_WorkflowInstance>().SetSource(inst).ExecuteAffrowsAsync();
}
else
{
inst.CurrentNodeId = next.Id;
inst.Updater = userId;
await _db.Update<Common_WorkflowInstance>().SetSource(inst).ExecuteAffrowsAsync();
await CreateTaskAsync(inst.Id, next, userId);
}
}
/// <summary>驳回:任务驳回 → 实例终止</summary>
public async Task RejectAsync(WorkflowHandleInput input, long userId)
{
var task = await _db.Select<Common_WorkflowTask>()
.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<Common_WorkflowTask>().SetSource(task).ExecuteAffrowsAsync();
var inst = await _db.Select<Common_WorkflowInstance>().Where(a => a.Id == task.InstanceId).FirstAsync();
if (inst == null) return;
inst.Status = 2; // 已驳回
inst.EndTime = DateTime.Now;
inst.Updater = userId;
await _db.Update<Common_WorkflowInstance>().SetSource(inst).ExecuteAffrowsAsync();
}
/// <summary>我的待办(分页)</summary>
public async Task<(List<WorkflowTodoDto> items, long total)> GetMyTodosAsync(long userId, int page, int size)
{
var query = _db.Select<Common_WorkflowTask, Common_WorkflowInstance, Common_Workflow, BaseSys_User>()
.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);
}
/// <summary>我发起的实例(分页)</summary>
public async Task<(List<WorkflowInstanceDto> items, long total)> GetMyInstancesAsync(long userId, int page, int size, int? status)
{
var query = _db.Select<Common_WorkflowInstance, Common_Workflow>()
.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<Common_WorkflowInstance>()
.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<Common_WorkflowNode>().Where(a => nodeIds.Contains(a.Id)).ToListAsync()
: new List<Common_WorkflowNode>();
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);
}
/// <summary>实例详情(含任务轨迹)</summary>
public async Task<WorkflowInstanceDetailDto?> GetInstanceDetailAsync(long id)
{
var inst = await _db.Select<Common_WorkflowInstance, Common_Workflow>()
.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<Common_WorkflowInstance>().Where(a => a.Id == id).FirstAsync();
if (instRow != null && instRow.CurrentNodeId > 0)
{
var curNode = await _db.Select<Common_WorkflowNode>().Where(a => a.Id == instRow.CurrentNodeId).FirstAsync();
inst.NodeName = curNode?.Name ?? "";
}
var tasks = await _db.Select<Common_WorkflowTask, BaseSys_User>()
.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<Common_WorkflowNode>().Where(a => nodeIds.Contains(a.Id)).ToListAsync()
: new List<Common_WorkflowNode>();
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;
}
/// <summary>创建审批任务(审批人取 ApproverJson 首个用户,缺省给操作人)</summary>
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();
}
/// <summary>解析 ApproverJson 首个审批人IDJSON 数组 ["1","2"]</summary>
private static long? ParseFirstApprover(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
var arr = JsonSerializer.Deserialize<List<long>>(json);
return arr != null && arr.Count > 0 ? arr[0] : null;
}
catch
{
return null;
}
}
/// <summary>为列表项填充节点名</summary>
private async Task FillNodeNamesAsync<T>(List<T> items, Func<T, long> nodeIdGetter, Action<T, string> setter)
{
var nodeIds = items.Select(nodeIdGetter).Where(a => a > 0).Distinct().ToList();
if (nodeIds.Count == 0) return;
var nodes = await _db.Select<Common_WorkflowNode>().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);
}
}
}
@@ -60,6 +60,14 @@ public class CacheService
foreach (var k in keys) _cache.Remove(k);
}
/// <summary>
/// 统计指定前缀的缓存条目数(如 "im:active:" 前缀统计在线用户)
/// </summary>
public int CountByPrefix(string prefix)
{
return GetKeys().Count(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
private IEnumerable<string> GetKeys()
{
// IMemoryCache 没有直接枚举方法,这里通过反射获取 key(兼容实现)
@@ -128,6 +128,11 @@ public class Common_WorkflowInstance : BaseEntity
[Column(DbType = "varchar(50)")]
public string? BillNo { get; set; }
/// <summary>关联业务记录ID(用于跳转业务详情)</summary>
[Description("关联业务记录ID")]
[Column]
public long BizId { get; set; }
/// <summary>关联业务类型</summary>
[Description("关联业务类型")]
[Column(DbType = "varchar(100)")]
@@ -284,10 +289,20 @@ public class Common_Log : BaseEntity
public DateTime OpTime { get; set; } = DateTime.Now;
}
/// <summary>消息通知</summary>
/// <summary>消息通知(含 IM 单聊)</summary>
[Table(Name = "Common_Message")]
public class Common_Message : BaseEntity
{
/// <summary>发送人ID0=系统)</summary>
[Description("发送人ID0=系统)")]
[Column]
public long SenderId { get; set; }
/// <summary>发送人姓名(冗余,0=系统时显示"系统通知"</summary>
[Description("发送人姓名")]
[Column(DbType = "varchar(50)")]
public string? SenderName { get; set; }
/// <summary>接收人ID</summary>
[Description("接收人ID")]
[Column]
@@ -303,8 +318,8 @@ public class Common_Message : BaseEntity
[Column(DbType = "text")]
public string? Content { get; set; }
/// <summary>消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警</summary>
[Description("消息类型:0=系统 1=业务 2=审批 3=预警")]
/// <summary>消息类型:0=系统通知 1=业务提醒 2=审批通知 3=预警 4=单聊消息</summary>
[Description("消息类型:0=系统 1=业务 2=审批 3=预警 4=单聊")]
[Column]
public int MsgType { get; set; } = 0;
+25
View File
@@ -0,0 +1,25 @@
using FreeSql.DataAnnotations;
using F9MES.Common.Entities;
using System.ComponentModel;
namespace F9MES.Domain.Im;
/// <summary>IM 系统配置(键值对)</summary>
[Table(Name = "Im_Config")]
public class Im_Config : BaseEntity
{
/// <summary>配置键</summary>
[Description("配置键")]
[Column(DbType = "varchar(100)")]
public string Key { get; set; } = "";
/// <summary>配置值</summary>
[Description("配置值")]
[Column(DbType = "text")]
public string Value { get; set; } = "";
/// <summary>配置名称</summary>
[Description("配置名称")]
[Column(DbType = "varchar(100)")]
public string Name { get; set; } = "";
}