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
+142
View File
@@ -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)
}
+37
View File
@@ -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}`)
+2
View File
@@ -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')
+22
View File
@@ -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}`)