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}`)
+132 -4
View File
@@ -115,7 +115,7 @@
</template>
</template>
</el-table-column>
<el-table-column label="操作" :width="detailRoute ? 200 : 160" align="center" fixed="right">
<el-table-column label="操作" :width="opWidth" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="detailRoute"
@@ -126,6 +126,20 @@
>详情</el-button>
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-button
v-if="printTemplate"
link
type="success"
size="small"
@click="onPrint(row)"
>打印</el-button>
<el-button
v-if="workflowCfg"
link
type="warning"
size="small"
@click="openWorkflow(row)"
>审批</el-button>
</template>
</el-table-column>
</el-table>
@@ -235,6 +249,24 @@
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- 发起审批流程对话框 -->
<el-dialog v-model="wfDialogVisible" title="发起审批流程" width="420px" destroy-on-close>
<el-form label-width="90px">
<el-form-item label="审批流程">
<el-select v-model="wfFlowId" style="width: 100%" placeholder="请选择流程">
<el-option v-for="f in wfFlows" :key="f.id" :label="f.name" :value="f.id" />
</el-select>
</el-form-item>
<el-form-item label="业务单号">
<el-input :model-value="wfBillNo" disabled />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="wfDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="wfSaving" @click="handleStartWorkflow">发起</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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()
+881
View File
@@ -0,0 +1,881 @@
<template>
<div class="im-float">
<!-- 悬浮球 -->
<div v-if="!isOpen" class="im-float-ball" @click="open">
<el-icon :size="26"><Message /></el-icon>
<span v-if="totalUnread > 0" class="im-float-dot">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
</div>
<!-- 展开浮窗 -->
<div v-else class="im-float-window">
<div class="im-float-head">
<span class="im-float-title">
即时通讯
<el-tooltip
v-if="imStatus !== 'connected'"
content="无法连接 IM 服务器,正在自动重连…"
placement="bottom"
>
<el-icon class="im-float-offline"><WarningFilled /></el-icon>
</el-tooltip>
</span>
<el-tooltip content="收起" placement="top">
<el-icon class="im-float-act" @click="close"><Fold /></el-icon>
</el-tooltip>
</div>
<div class="im-float-body">
<!-- 左侧消息 / 联系 -->
<div class="im-side">
<div class="im-side-tabs">
<div class="im-tab" :class="{ active: activeTab === 'msg' }" @click="activeTab = 'msg'">
消息
<el-badge v-if="unreadCount > 0" :value="unreadCount > 99 ? '99+' : unreadCount" :max="99" class="im-tab-badge" />
</div>
<div class="im-tab" :class="{ active: activeTab === 'contacts' }" @click="activeTab = 'contacts'">联系</div>
</div>
<template v-if="activeTab === 'msg'">
<div class="im-side-head">
<span class="im-title">消息</span>
<el-button type="primary" size="small" circle @click="openNewChat">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<el-scrollbar class="im-session-list">
<div
v-for="s in sessions"
:key="s.peerId"
class="im-session"
:class="{ active: current?.peerId === s.peerId, system: s.isSystem }"
@click="openSession(s)"
>
<el-avatar :size="36" class="im-avatar" :class="{ 'avatar-system': s.isSystem }">
{{ s.isSystem ? '系' : (s.peerName || '?').slice(0, 1) }}
</el-avatar>
<div class="im-session-main">
<div class="im-session-top">
<span class="im-session-name">{{ s.isSystem ? '系统通知' : s.peerName }}</span>
<span class="im-session-time">{{ fmtTime(s.lastTime) }}</span>
</div>
<div class="im-session-last">
<span class="im-last-text">
<span v-if="s.isSystem" class="im-last-tag">{{ msgTypeLabel(s.msgType) }}</span>{{ s.lastContent || '暂无消息' }}
</span>
<el-badge v-if="s.unread > 0" :value="s.unread > 99 ? '99+' : s.unread" class="im-unread" />
</div>
</div>
</div>
<el-empty v-if="!sessions.length" description="暂无会话,点右上角 + 或到「联系」选人发起" :image-size="60" />
</el-scrollbar>
</template>
<template v-else>
<div class="im-side-head">
<el-input v-model="contactKw" placeholder="搜索姓名/手机号" size="small" clearable />
</div>
<el-scrollbar class="im-session-list">
<template v-for="g in filteredGroups" :key="g.orgId">
<div class="im-org-head" @click="toggleGroup(g)">
<el-icon class="im-org-arrow" :class="{ open: !collapsed.has(g.orgId) }">
<ArrowRight />
</el-icon>
<span class="im-org-name">{{ g.orgName }}</span>
<span class="im-org-count">{{ g.users.length }}</span>
</div>
<template v-if="!collapsed.has(g.orgId)">
<div
v-for="u in g.users"
:key="u.id"
class="im-session"
:class="{ active: current?.peerId === u.id }"
@click="onContactClick(u)"
>
<el-avatar :size="36" class="im-avatar">{{ (u.name || '?').slice(0, 1) }}</el-avatar>
<div class="im-session-main">
<div class="im-session-top">
<span class="im-session-name">{{ u.name }}</span>
</div>
<div class="im-session-last">
<span class="im-last-text">{{ u.phone || '—' }}</span>
</div>
</div>
</div>
</template>
</template>
<el-empty v-if="!filteredGroups.length" description="暂无用户" :image-size="60" />
</el-scrollbar>
</template>
</div>
<!-- 右侧聊天窗口 -->
<div class="im-chat">
<template v-if="current">
<div class="im-chat-head">
<span>{{ current.peerName }}</span>
<span class="im-chat-sub">{{ current.isSystem ? '系统通知 / 公告' : (current.unread ? `${current.unread} 条未读` : '已读') }}</span>
<div class="im-chat-head-right">
<el-tooltip :content="soundOn ? '新消息提示音:开' : '新消息提示音:关'" placement="bottom">
<el-icon class="im-sound-btn" @click="toggleSound">
<Bell v-if="soundOn" />
<MuteNotification v-else />
</el-icon>
</el-tooltip>
<el-tooltip content="IM 个人设置" placement="bottom">
<el-icon class="im-sound-btn" @click="settingsVisible = true"><Setting /></el-icon>
</el-tooltip>
</div>
</div>
<el-scrollbar ref="chatScrollRef" class="im-msg-list">
<div v-for="m in messages" :key="m.id" class="im-msg" :class="{ mine: m.mine, system: m.isSystem }">
<el-avatar :size="32" class="im-msg-avatar" :class="{ 'avatar-system': m.isSystem }">
{{ m.isSystem ? '系' : (m.mine ? '我' : (m.senderName || '?')).slice(0, 1) }}
</el-avatar>
<div class="im-bubble-wrap">
<div v-if="m.isSystem && m.title" class="im-msg-title">
<span class="im-msg-tag" :class="'tag-' + m.msgType">{{ msgTypeLabel(m.msgType) }}</span>
<b class="im-msg-title-text">{{ m.title }}</b>
</div>
<div class="im-bubble">{{ m.content }}</div>
<div class="im-msg-time">{{ fmtFull(m.sendTime) }}</div>
</div>
</div>
</el-scrollbar>
<template v-if="current.isSystem">
<div class="im-system-tip">系统通知 / 公告仅可查看</div>
</template>
<template v-else>
<div class="im-input-area">
<el-input
v-model="draft"
type="textarea"
:rows="2"
resize="none"
:placeholder="enterSend ? '输入消息,Enter 发送,Shift+Enter 换行' : '输入消息,Enter 换行,Ctrl+Enter 发送'"
@keydown="onDraftKeydown"
/>
<div class="im-input-actions">
<el-button type="primary" size="small" @click="sendMsg">发送</el-button>
</div>
</div>
</template>
</template>
<el-empty v-else description="选择左侧会话开始聊天" />
</div>
</div>
<!-- 发起新会话 -->
<el-dialog v-model="newChatVisible" title="发起聊天" width="420px" destroy-on-close append-to-body>
<el-select
v-model="newPeerId"
filterable
placeholder="搜索用户姓名/手机号"
style="width: 100%"
@change="onPeerSelected"
>
<el-option v-for="u in contacts" :key="u.id" :label="`${u.name}${u.phone}`" :value="u.id" />
</el-select>
</el-dialog>
<!-- IM 个人设置 -->
<el-dialog v-model="settingsVisible" title="IM 个人设置" width="420px" append-to-body>
<el-form label-width="130px" class="settings-form">
<el-form-item label="新消息提示音">
<el-switch v-model="soundOn" @change="persistSound" />
<span class="settings-tip">收到他人新消息时播放提示音</span>
</el-form-item>
<el-form-item label="回车发送消息">
<el-switch v-model="enterSend" @change="persistEnterSend" />
<span class="settings-tip">{{ enterSend ? 'Enter 发送,Shift+Enter 换行' : 'Enter 换行,Ctrl+Enter 发送' }}</span>
</el-form-item>
</el-form>
</el-dialog>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowRight, Bell, MuteNotification, Setting, WarningFilled } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { imSessions, imMessages, imSend, imRead, imUnreadCount } from '@/api/im'
import { subscribeImEvents, subscribeImStatus } from '@/api/im-sse'
import { getImSound, setImSound, playImSound, getEnterSend, setEnterSend } from '@/utils/im-settings'
const userStore = useUserStore()
// 个人偏好:新消息提示音(本地存储,配置入口在对话页右上角)
const soundOn = ref(getImSound())
function toggleSound() {
soundOn.value = !soundOn.value
setImSound(soundOn.value)
}
function persistSound() {
setImSound(soundOn.value)
}
// 个人偏好:回车发送(本地存储,配置入口:右上角 IM 个人设置)
const enterSend = ref(getEnterSend())
function persistEnterSend() {
setEnterSend(enterSend.value)
}
// IM 个人设置弹窗
const settingsVisible = ref(false)
// SSE 连接状态:无法连接服务器时标题旁显示醒目图标
const imStatus = ref('disconnected')
const isOpen = ref(false)
const totalUnread = ref(0)
const sessions = ref([])
const current = ref(null)
const messages = ref([])
const draft = ref('')
const chatScrollRef = ref()
const newChatVisible = ref(false)
const newPeerId = ref(null)
const activeTab = ref('msg')
const contacts = ref([])
const contactGroups = ref([])
const contactKw = ref('')
const collapsed = ref(new Set())
const unreadCount = ref(0)
const filteredGroups = computed(() => {
const kw = contactKw.value.trim().toLowerCase()
return contactGroups.value
.map((g) => ({
...g,
users: kw
? g.users.filter(
(u) =>
(u.name || '').toLowerCase().includes(kw) ||
(u.phone || '').toLowerCase().includes(kw)
)
: g.users,
}))
.filter((g) => g.users.length > 0)
})
function toggleGroup(g) {
const s = new Set(collapsed.value)
if (s.has(g.orgId)) s.delete(g.orgId)
else s.add(g.orgId)
collapsed.value = s
}
// 消息类型标签:0=公告/系统通知 1=业务提醒 2=审批通知 3=预警 4=单聊
const MSG_TYPE_LABEL = { 0: '公告', 1: '业务提醒', 2: '审批通知', 3: '预警', 4: '单聊' }
const msgTypeLabel = (t) => MSG_TYPE_LABEL[t] ?? '通知'
let unsubImEvents = null
let unsubImStatus = null
const fmtTime = (t) => {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const fmtFull = (t) => {
if (!t) return ''
const d = new Date(t)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadSessions() {
try {
const res = await imSessions()
sessions.value = res.data || []
if (current.value) {
const s = sessions.value.find((x) => x.peerId === current.value.peerId)
if (s) current.value.unread = s.unread
}
} catch (e) {
/* 静默 */
}
}
async function openSession(s) {
current.value = s
await loadMessages(true)
imRead(s.peerId).then(loadSessions).catch(() => {})
}
async function loadMessages(initial = false) {
if (!current.value) return
try {
const res = await imMessages(current.value.peerId, { page: 1, size: 100 })
messages.value = (res.data?.items || []).slice().reverse()
await nextTick()
scrollBottom()
} catch (e) {
/* 静默 */
}
}
async function sendMsg() {
const content = draft.value.trim()
if (!content) return
if (!current.value) {
ElMessage.warning('请先选择会话')
return
}
if (current.value.isSystem) {
ElMessage.warning('系统通知 / 公告仅可查看,无需回复')
return
}
await imSend({ peerId: current.value.peerId, content })
draft.value = ''
await loadMessages(true)
loadSessions()
}
/** 输入框按键:Ctrl/Cmd+Enter 始终发送;普通 Enter 依个人设置(回车发送),Shift+Enter 换行 */
function onDraftKeydown(e) {
if (e.key !== 'Enter' || e.shiftKey) return
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
sendMsg()
return
}
if (enterSend.value) {
e.preventDefault()
sendMsg()
}
}
async function loadContacts() {
try {
const { crudAll } = await import('@/api')
const selfId = userStore.userInfo?.id ?? userStore.userInfo?.Id
const [orgRes, uoRes, userRes] = await Promise.all([
crudAll('BaseSys_Org'),
crudAll('BaseSys_UserOrg'),
crudAll('BaseSys_User'),
])
const unwrap = (r) => r.data?.data || r.data?.items || r.data || []
const orgs = unwrap(orgRes)
const binds = unwrap(uoRes)
const users = unwrap(userRes)
const orgMap = new Map(orgs.map((o) => [o.id, o]))
const orgNameOf = (orgId) => {
const seen = new Set()
let cur = orgMap.get(orgId)
while (cur && !seen.has(cur.id)) {
seen.add(cur.id)
cur = orgMap.get(cur.parentId)
}
return orgMap.get(orgId)?.name || '未分组'
}
const userOrg = new Map()
for (const b of binds) {
if (!userOrg.has(b.userId)) userOrg.set(b.userId, orgNameOf(b.orgId))
}
const peers = users.filter((u) => u.id !== selfId && u.status === 1 && u.flag !== 0)
contacts.value = peers.map((u) => ({ ...u, orgName: userOrg.get(u.id) || '未分组' }))
const orgOrder = new Map(orgs.map((o, i) => [o.id, i]))
const groups = new Map()
for (const u of contacts.value) {
const name = u.orgName
if (!groups.has(name)) groups.set(name, { orgName: name, users: [] })
groups.get(name).users.push(u)
}
contactGroups.value = [...groups.values()].sort((a, b) => {
if (a.orgName === '未分组') return 1
if (b.orgName === '未分组') return -1
const ao = orgs.find((o) => o.name === a.orgName)
const bo = orgs.find((o) => o.name === b.orgName)
return (ao ? orgOrder.get(ao.id) : 999) - (bo ? orgOrder.get(bo.id) : 999)
})
} catch (e) {
/* 拦截器已提示 */
}
}
async function openNewChat() {
newPeerId.value = null
if (!contacts.value.length) await loadContacts()
newChatVisible.value = true
}
function onContactClick(u) {
const existing = sessions.value.find((s) => s.peerId === u.id)
if (existing) {
openSession(existing)
} else {
openSession({ peerId: u.id, peerName: u.name || '', unread: 0 })
}
activeTab.value = 'msg'
}
function onPeerSelected(id) {
if (!id) return
const existing = sessions.value.find((s) => s.peerId === id)
if (existing) {
openSession(existing)
} else {
openSession({ peerId: id, peerName: '', unread: 0 })
}
newChatVisible.value = false
}
function scrollBottom() {
chatScrollRef.value?.setScrollTop(999999)
}
// SSE 长连接推送:收到新消息/通知时增量刷新
function handleImEvent(msg) {
if (!msg) return
loadSessions()
refreshUnread()
const cur = current.value
const isCur = cur && (msg.senderId === cur.peerId || msg.receiverId === cur.peerId)
if (isCur) {
loadMessages()
}
// 他人发来、且不在当前打开的会话 → 播放提示音(个人开关在右上角)
const me = Number(userStore.userInfo?.id ?? userStore.userInfo?.Id)
if (me > 0 && Number(msg.senderId) !== me && !isCur) {
playImSound()
}
}
async function refreshUnread() {
try {
const res = await imUnreadCount()
unreadCount.value = res.data || 0
totalUnread.value = unreadCount.value
} catch (e) {
/* 静默 */
}
}
function open() {
isOpen.value = true
loadMessages()
}
function close() {
isOpen.value = false
}
defineExpose({ open, close })
onMounted(() => {
loadSessions()
loadContacts()
refreshUnread()
unsubImEvents = subscribeImEvents(handleImEvent)
unsubImStatus = subscribeImStatus((s) => (imStatus.value = s))
})
onUnmounted(() => {
unsubImEvents?.()
unsubImStatus?.()
})
</script>
<style scoped>
.im-float {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 3000;
font-size: 14px;
}
.im-float-ball {
position: relative;
width: 54px;
height: 54px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
transition: transform 0.15s;
}
.im-float-ball:hover {
transform: scale(1.06);
}
.im-float-dot {
position: absolute;
top: -2px;
right: -2px;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: #f56c6c;
color: #fff;
font-size: 12px;
line-height: 20px;
text-align: center;
border: 2px solid #fff;
}
.im-float-window {
width: 780px;
height: 540px;
max-height: calc(100vh - 120px);
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
}
.im-float-head {
height: 46px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
cursor: move;
user-select: none;
}
.im-float-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* 无法连接服务器:醒目红色警示图标(闪烁) */
.im-float-offline {
font-size: 15px;
color: #f56c6c;
cursor: help;
animation: im-float-offline-blink 1s ease-in-out infinite;
}
@keyframes im-float-offline-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.im-float-act {
font-size: 18px;
color: #909399;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.im-float-act:hover {
color: var(--el-color-primary);
background: #f5f7fa;
}
.im-float-body {
flex: 1;
min-height: 0;
display: flex;
}
/* ===== 左侧面板 ===== */
.im-side {
width: 260px;
flex-shrink: 0;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.im-side-tabs {
display: flex;
height: 44px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.im-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 13px;
color: #909399;
cursor: pointer;
position: relative;
transition: color 0.15s;
}
.im-tab:hover {
color: #606266;
}
.im-tab.active {
color: var(--el-color-primary);
font-weight: 600;
}
.im-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 3px;
border-radius: 2px;
background: var(--el-color-primary);
}
.im-tab-badge {
display: inline-flex;
}
.im-side-head {
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 16px;
border-bottom: 1px solid #f0f0f0;
}
.im-title {
font-size: 15px;
font-weight: 600;
}
.im-session-list {
flex: 1;
}
.im-org-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #fafafa;
cursor: pointer;
user-select: none;
font-size: 13px;
}
.im-org-head:hover {
background: #f0f2f5;
}
.im-org-arrow {
font-size: 12px;
color: #909399;
transition: transform 0.15s;
}
.im-org-arrow.open {
transform: rotate(90deg);
}
.im-org-name {
flex: 1;
color: #606266;
font-weight: 600;
}
.im-org-count {
color: #c0c4cc;
font-size: 12px;
}
.im-session {
display: flex;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
}
.im-session:hover {
background: #f5f7fa;
}
.im-session.active {
background: #ecf5ff;
}
.im-avatar {
flex-shrink: 0;
background: var(--el-color-primary);
color: #fff;
}
.im-session-main {
flex: 1;
min-width: 0;
}
.im-session-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.im-session-name {
font-size: 14px;
color: #303133;
}
.im-session-time {
font-size: 12px;
color: #c0c4cc;
}
.im-session-last {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 3px;
}
.im-last-text {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.im-unread {
flex-shrink: 0;
}
/* ===== 右侧聊天 ===== */
.im-chat {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.im-chat-head {
height: 46px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 15px;
font-weight: 600;
}
.im-chat-sub {
font-size: 12px;
font-weight: 400;
color: #909399;
}
.im-chat-head-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.im-sound-btn {
font-size: 18px;
color: #909399;
cursor: pointer;
transition: color 0.15s;
}
.im-sound-btn:hover {
color: var(--el-color-primary);
}
/* ===== IM 个人设置弹窗 ===== */
.settings-form .settings-tip {
margin-left: 12px;
font-size: 12px;
color: #909399;
}
.im-msg-list {
flex: 1;
padding: 14px;
background: #f7f8fa;
}
.im-msg {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.im-msg.mine {
flex-direction: row-reverse;
}
.im-msg-avatar {
flex-shrink: 0;
background: #909399;
color: #fff;
}
.im-msg.mine .im-msg-avatar {
background: var(--el-color-primary);
}
.im-bubble-wrap {
max-width: 60%;
}
.im-bubble {
padding: 9px 13px;
background: #fff;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.im-msg.mine .im-bubble {
background: var(--el-color-primary);
color: #fff;
}
.im-msg-time {
font-size: 11px;
color: #c0c4cc;
margin-top: 4px;
}
.im-msg.mine .im-msg-time {
text-align: right;
}
.im-input-area {
border-top: 1px solid #f0f0f0;
padding: 8px 12px;
flex-shrink: 0;
}
.im-input-actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
/* ===== 系统通知 / 公告 ===== */
.avatar-system {
background: #e6a23c !important;
}
.im-last-tag {
color: #e6a23c;
margin-right: 5px;
font-weight: 600;
}
.im-msg-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.im-msg-tag {
flex-shrink: 0;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: #ecf5ff;
color: var(--el-color-primary);
}
.im-msg-tag.tag-1 {
background: #fdf6ec;
color: #e6a23c;
}
.im-msg-tag.tag-2 {
background: #fef0f0;
color: #f56c6c;
}
.im-msg-tag.tag-3 {
background: #f4f4f5;
color: #909399;
}
.im-msg-title-text {
font-size: 13px;
color: #303133;
}
.im-system-tip {
border-top: 1px solid #f0f0f0;
padding: 14px;
text-align: center;
color: #909399;
font-size: 13px;
}
</style>
+9 -2
View File
@@ -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)
})
</script>
<style scoped>
@@ -156,6 +160,9 @@ onBeforeUnmount(() => document.removeEventListener('click', closeMenu))
flex-shrink: 0;
}
.tag-title {
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1;
}
.tag-close {
+22
View File
@@ -0,0 +1,22 @@
/**
* openprint 外部打印页配置
* - VITE_OPENPRINT_URLopenprint 应用地址(默认本机 5227)
* - VITE_PRINT_API_BASE:后端 API 根地址(openprint 打印页直连取模板/数据,默认本机 5136)
* 生产环境建议用环境变量指向实际地址,如 http://192.168.1.10:5136
*/
export const OPENPRINT_URL =
import.meta.env.VITE_OPENPRINT_URL || 'http://localhost:5227'
export const PRINT_API_BASE =
import.meta.env.VITE_PRINT_API_BASE || 'http://localhost:5136'
/** 由查询参数构造 openprint 打印页 URL */
export function buildPrintUrl({ template, table, row, token, api = PRINT_API_BASE, data }) {
const q = new URLSearchParams({ print: '1', template })
if (table) q.set('table', table)
if (row) q.set('row', row)
if (token) q.set('token', token)
if (api) q.set('api', api)
if (data) q.set('data', data)
return `${OPENPRINT_URL}/?${q.toString()}`
}
+29 -2
View File
@@ -124,8 +124,8 @@ export function resolveTable(component) {
return `${mod}_${pascal}`
}
/** 特殊页面:不经过通用 CRUD(如工作台、看板) */
export const SPECIAL_PAGES = new Set(['WorkBench/index'])
/** 特殊页面:不经过通用 CRUD(如工作台、看板、工作流、IM */
export const SPECIAL_PAGES = new Set(['WorkBench/index', 'WorkFlow/todo', 'WorkFlow/my', 'Im/monitor', 'Im/service', 'Im/config', 'Im/message'])
/**
* 详情页映射:component → 详情页路由(:id 占位替换)
@@ -139,3 +139,30 @@ export const DETAIL_MAP = {
export function resolveDetail(component) {
return component ? DETAIL_MAP[component] || null : null
}
/**
* 打印模板映射:component → 模板名称(对应 RepCenter_ReportDesign.Name
* 配置后,通用 CRUD 列表操作列会显示"打印"按钮
*/
export const PRINT_MAP = {
'RawMaterial/outstock': { templateName: '三等分原料出库单', title: '原料出库单' }
}
/** 根据 component 解析打印模板配置(无则返回 null) */
export function resolvePrint(component) {
return component ? PRINT_MAP[component] || null : null
}
/**
* 工作流映射:component → 业务类型(BizType
* 配置后,通用 CRUD 列表操作列会显示"审批"按钮(发起审批流程)
*/
export const WORKFLOW_MAP = {
'RawMaterial/outstock': 'RawMaterial_OutStock'
}
/** 根据 component 解析工作流配置(无则返回 null) */
export function resolveWorkflow(component) {
if (!component || !WORKFLOW_MAP[component]) return null
return { bizType: WORKFLOW_MAP[component] }
}
+202 -2
View File
@@ -6,6 +6,43 @@
<span class="logo-icon">🧵</span>
<span v-show="!collapse" class="logo-text">F9 智慧缫丝 MES</span>
</div>
<!-- 菜单搜索 -->
<div v-show="!collapse" class="menu-search">
<el-input
v-model="searchKey"
size="small"
placeholder="搜索菜单"
clearable
class="search-input"
@input="handleSearchInput"
@focus="handleSearchInput"
@blur="handleSearchBlur"
@keydown.enter="goFirstResult"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<transition name="el-zoom-in-top">
<div v-if="searchVisible && searchResults.length" class="search-panel">
<div
v-for="item in searchResults"
:key="item.path"
class="search-item"
@mousedown.prevent="goSearch(item)"
>
<el-icon class="search-item-icon"><component :is="item.icon || 'Document'" /></el-icon>
<div class="search-item-text">
<div class="search-item-title">{{ item.title }}</div>
<div class="search-item-group" v-if="item.group">{{ item.group }}</div>
</div>
</div>
</div>
</transition>
<div v-if="searchVisible && searchKey && !searchResults.length" class="search-panel search-empty">
未找到相关菜单
</div>
</div>
<el-scrollbar class="menu-scroll">
<el-menu
:default-active="activeMenu"
@@ -58,6 +95,12 @@
</el-breadcrumb-item>
</el-breadcrumb>
<div class="flex-1"></div>
<!-- 消息中心唤起右下角浮窗 -->
<div class="msg-entry flex-center" @click="openImFloat">
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
<el-icon :size="19"><Message /></el-icon>
</el-badge>
</div>
<!-- 天气 -->
<div class="weather flex-center" v-if="weather">
<el-icon><Sunny /></el-icon>
@@ -109,25 +152,81 @@
<el-button type="primary" @click="submitPwd">确定</el-button>
</template>
</el-dialog>
<!-- 右下角 IM 浮窗 -->
<ImFloatWindow ref="imFloatRef" />
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { changePassword, getWeather, mapWeather } from '@/api'
import { imUnreadCount } from '@/api/im'
import { subscribeImEvents } from '@/api/im-sse'
import TagsView from '@/components/TagsView.vue'
import ImFloatWindow from '@/components/ImFloatWindow.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const imFloatRef = ref()
const collapse = ref(false)
const pwdVisible = ref(false)
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' })
const pwdFormRef = ref()
const weather = ref(null)
const unreadCount = ref(0)
let unsubImEvents = null
// 菜单搜索
const searchKey = ref('')
const searchVisible = ref(false)
const searchResults = ref([])
// 扁平化所有可跳转菜单项(一级 + 二级)
const flatMenus = computed(() => {
const list = []
for (const m of userStore.menus) {
if (m.children && m.children.length) {
for (const c of m.children) {
if (c.path) list.push({ title: c.title, path: c.path, icon: c.icon || m.icon, group: m.title })
}
} else if (m.path) {
list.push({ title: m.title, path: m.path, icon: m.icon, group: '' })
}
}
return list
})
function handleSearchInput() {
searchVisible.value = true
const kw = searchKey.value.trim().toLowerCase()
if (!kw) {
searchResults.value = flatMenus.value.slice(0, 10)
return
}
searchResults.value = flatMenus.value
.filter((i) => (i.title + (i.group || '')).toLowerCase().includes(kw))
.slice(0, 12)
}
function handleSearchBlur() {
// 延迟关闭,保证下拉项点击(mousedown)先执行
setTimeout(() => (searchVisible.value = false), 150)
}
function goSearch(item) {
searchVisible.value = false
searchKey.value = ''
if (item.path) router.push(item.path)
}
function goFirstResult() {
if (searchResults.value.length) goSearch(searchResults.value[0])
}
const menus = computed(() => userStore.menus)
const activeMenu = computed(() => route.path)
@@ -157,6 +256,9 @@ const breadcrumbs = computed(() => {
const toggleCollapse = () => (collapse.value = !collapse.value)
/** 唤起右下角 IM 浮窗 */
const openImFloat = () => imFloatRef.value?.open()
const handleCommand = (cmd) => {
if (cmd === 'logout') {
ElMessageBox.confirm('确定退出登录吗?', '提示', { type: 'warning' })
@@ -211,7 +313,23 @@ async function loadWeather() {
}
}
onMounted(loadWeather)
// 未读消息数(顶栏角标)
async function loadUnread() {
try {
const res = await imUnreadCount()
unreadCount.value = res.data || 0
} catch (e) {
/* 静默 */
}
}
onMounted(() => {
loadWeather()
loadUnread()
// SSE 推送:收到新消息即刷新未读角标(替代 30s 轮询)
unsubImEvents = subscribeImEvents(() => loadUnread())
})
onUnmounted(() => unsubImEvents?.())
</script>
<style scoped>
@@ -245,6 +363,79 @@ onMounted(loadWeather)
.logo-text {
letter-spacing: 1px;
}
.menu-search {
position: relative;
padding: 10px 12px 2px;
}
.search-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.08);
box-shadow: none;
border-radius: 6px;
}
.search-input :deep(.el-input__wrapper.is-focus) {
background: rgba(255, 255, 255, 0.14);
}
.search-input :deep(.el-input__inner) {
color: #fff;
}
.search-input :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.45);
}
.search-input :deep(.el-input__prefix) {
color: rgba(255, 255, 255, 0.5);
}
.search-input :deep(.el-input__clear) {
color: rgba(255, 255, 255, 0.5);
}
.search-panel {
position: absolute;
top: calc(100% - 2px);
left: 10px;
right: 10px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
padding: 6px;
z-index: 2000;
max-height: 320px;
overflow-y: auto;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
.search-item:hover {
background: #f0f2f5;
}
.search-item-icon {
color: var(--el-color-primary);
flex-shrink: 0;
}
.search-item-text {
flex: 1;
min-width: 0;
}
.search-item-title {
font-size: 13px;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-item-group {
font-size: 11px;
color: #909399;
}
.search-empty {
text-align: center;
color: #909399;
font-size: 12px;
padding: 12px;
}
.menu-scroll {
flex: 1;
}
@@ -271,6 +462,15 @@ onMounted(loadWeather)
.breadcrumb {
margin-left: 4px;
}
.msg-entry {
cursor: pointer;
padding: 0 10px;
height: 100%;
color: #606266;
}
.msg-entry:hover {
color: var(--el-color-primary);
}
.weather {
gap: 4px;
font-size: 13px;
+40 -2
View File
@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useTabsStore } from '@/stores/tabs'
import { resolveTable, resolveDetail, SPECIAL_PAGES } from '@/config/table-map'
import { resolveTable, resolveDetail, resolvePrint, resolveWorkflow, SPECIAL_PAGES } from '@/config/table-map'
// 静态路由(与权限无关)
export const constantRoutes = [
@@ -34,6 +34,42 @@ export const constantRoutes = [
name: 'ProcessZhuangkouDetail',
component: () => import('@/views/process/zhuangkou-detail.vue'),
meta: { title: '工艺庄口详情', hidden: true }
},
{
path: '/workflow/todo',
name: 'WorkFlowTodo',
component: () => import('@/views/workflow/todo.vue'),
meta: { title: '待办中心' }
},
{
path: '/workflow/my',
name: 'WorkFlowMy',
component: () => import('@/views/workflow/my.vue'),
meta: { title: '我发起的' }
},
{
path: '/im/monitor',
name: 'ImMonitor',
component: () => import('@/views/im/monitor.vue'),
meta: { title: 'IM性能监测' }
},
{
path: '/im/service',
name: 'ImService',
component: () => import('@/views/im/service.vue'),
meta: { title: 'IM服务监测' }
},
{
path: '/im/config',
name: 'ImConfig',
component: () => import('@/views/im/config.vue'),
meta: { title: 'IM配置界面' }
},
{
path: '/im/message',
name: 'ImMessage',
component: () => import('@/views/im/message.vue'),
meta: { title: '消息与公告管理' }
}
]
},
@@ -75,7 +111,9 @@ export function registerDynamicRoutes(menus) {
title: p.title || p.name,
tableName: table,
component: p.component,
detailRoute: resolveDetail(p.component)
detailRoute: resolveDetail(p.component),
printTemplate: resolvePrint(p.component),
workflow: resolveWorkflow(p.component)
}
})
}
+56
View File
@@ -0,0 +1,56 @@
// IM 个人设置工具(localStorage 存储,仅本机生效,不上传服务端)
// 个人偏好入口:IM 对话页面右上角「设置」图标
const SOUND_KEY = 'im_sound'
const ENTER_SEND_KEY = 'im_enter_send'
/** 读取提示音开关(默认开启) */
export function getImSound() {
return localStorage.getItem(SOUND_KEY) !== '0'
}
/** 设置提示音开关 */
export function setImSound(on) {
localStorage.setItem(SOUND_KEY, on ? '1' : '0')
}
/** 读取回车发送开关(默认开启:Enter 发送;关闭后 Enter 换行、Ctrl+Enter 发送) */
export function getEnterSend() {
return localStorage.getItem(ENTER_SEND_KEY) !== '0'
}
/** 设置回车发送开关 */
export function setEnterSend(on) {
localStorage.setItem(ENTER_SEND_KEY, on ? '1' : '0')
}
// Web Audio 合成短提示音,无需引入音频文件
let ctx = null
/** 播放提示音(已开启时才发声) */
export function playImSound() {
if (!getImSound()) return
try {
ctx = ctx || new (window.AudioContext || window.webkitAudioContext)()
if (ctx.state === 'suspended') ctx.resume()
const t = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.type = 'sine'
// 两声短音:880Hz → 660Hz
osc.frequency.setValueAtTime(880, t)
osc.frequency.setValueAtTime(660, t + 0.12)
gain.gain.setValueAtTime(0.001, t)
gain.gain.exponentialRampToValueAtTime(0.15, t + 0.02)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1)
gain.gain.setValueAtTime(0.001, t + 0.14)
gain.gain.exponentialRampToValueAtTime(0.15, t + 0.16)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.26)
osc.start(t)
osc.stop(t + 0.28)
} catch (e) {
/* 忽略音频不可用 */
}
}
+218
View File
@@ -0,0 +1,218 @@
<template>
<div class="im-config">
<el-card shadow="never" class="config-card">
<template #header>
<div class="card-title">
<span>IM 服务端配置</span>
<el-button size="small" type="primary" :icon="Check" :loading="saving" @click="save">保存配置</el-button>
</div>
</template>
<div class="config-sub">
本页面为后台 / 服务端的公共配置保存后对所有用户生效个人偏好如新消息提示音请在对话页面右上角设置
</div>
<el-form :model="form" label-width="150px" class="config-form">
<el-form-item v-for="item in form.items" :key="item.key" :label="item.name">
<div v-if="item.key === 'pollInterval'" class="poll-field">
<el-input-number
v-model="item.valueNum"
:min="3"
:max="60"
:step="1"
/>
<span class="poll-tip">SSE 长连接已启用此配置仅兼容保留</span>
</div>
<el-input-number
v-else-if="item.key === 'retainDays'"
v-model="item.valueNum"
:min="1"
:max="365"
:step="1"
/>
<el-input-number
v-else-if="item.key === 'maxBody'"
v-model="item.valueNum"
:min="50"
:max="5000"
:step="50"
/>
<el-input v-else v-model="item.valueText" />
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="ops-card">
<template #header>
<div class="card-title">
<span>运维备忘 · 技术实现与必要服务</span>
</div>
</template>
<div class="ops-section">
<div class="ops-title">技术实现</div>
<ul class="ops-list">
<li>实时推送<b>SSE 长连接</b><code>GET /api/im/events</code>JWT 认证20 秒心跳保活断线指数退避自动重连不再依赖轮询</li>
<li>消息存储MySQL <code>Common_Message</code> 消息落库后由服务端经 SSE 即时推送提醒</li>
<li>在线状态服务端按 userId 维护连接集合单例连接枢纽在线数实时统计</li>
<li>未读 / 会话列表基于消息表实时统计无额外中间件</li>
</ul>
</div>
<div class="ops-section">
<div class="ops-title">必要服务IM 正常运行依赖</div>
<table class="ops-table">
<thead>
<tr>
<th>服务</th>
<th>说明</th>
<th>启动方式 / 地址</th>
</tr>
</thead>
<tbody>
<tr>
<td>后端 API</td>
<td>提供全部 IM 接口与 SSE 推送</td>
<td>F9MES.Api · http://localhost:5136</td>
</tr>
<tr>
<td>MySQL 数据库</td>
<td>用户 / 消息Common_Message存储</td>
<td> f9web</td>
</tr>
<tr>
<td>前端 Web</td>
<td>IM 页面浮动窗SSE 订阅</td>
<td>vite dev :5173或构建后由 Nginx 托管</td>
</tr>
<tr>
<td>登录认证</td>
<td>JWT 令牌未登录无法访问 IM SSE</td>
<td>由后端 API 签发</td>
</tr>
</tbody>
</table>
<div class="ops-note">提示以上均为系统内置服务IM 无需额外启动 Redis / MQ 等中间件</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
import { imAdminConfig, imAdminSaveConfig } from '@/api/im'
const form = reactive({ items: [] })
const saving = ref(false)
function normalize(list) {
return (list || [])
.filter((it) => it.key !== 'sound') // 个人配置(提示音)已移至对话页面右上角
.map((it) => ({
key: it.key,
name: it.name,
valueText: it.value,
valueNum: Number(it.value) || 0
}))
}
async function load() {
const res = await imAdminConfig()
form.items = normalize(res?.data)
}
async function save() {
saving.value = true
try {
const payload = form.items.map((it) => ({
key: it.key,
name: it.name,
value: ['pollInterval', 'retainDays', 'maxBody'].includes(it.key)
? String(it.valueNum)
: it.valueText
}))
await imAdminSaveConfig(payload)
ElMessage.success('配置已保存')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.config-card {
max-width: 640px;
}
.ops-card {
max-width: 860px;
margin-top: 16px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-sub {
color: #909399;
font-size: 13px;
padding: 2px 0 6px;
}
.config-form {
padding-top: 6px;
}
.poll-field {
display: flex;
align-items: center;
gap: 10px;
}
.poll-tip {
font-size: 12px;
color: #909399;
}
.ops-section {
margin-bottom: 16px;
}
.ops-section:last-child {
margin-bottom: 0;
}
.ops-title {
font-weight: 600;
margin-bottom: 8px;
}
.ops-list {
margin: 0;
padding-left: 20px;
line-height: 1.9;
color: #606266;
}
.ops-list code {
background: #f4f4f5;
border-radius: 3px;
padding: 1px 5px;
font-size: 12px;
}
.ops-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.ops-table th,
.ops-table td {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
.ops-table th {
background: #f5f7fa;
font-weight: 600;
}
.ops-note {
margin-top: 8px;
font-size: 12px;
color: #909399;
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="im-message">
<el-card shadow="never">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-select v-model="query.msgType" placeholder="消息类型" style="width: 140px" clearable @change="handleSearch">
<el-option v-for="t in msgTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-input
v-model="query.kw"
placeholder="搜索标题/内容"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
</div>
<el-button type="primary" :icon="Promotion" @click="openNotify">群发公告</el-button>
</div>
<!-- 列表 -->
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="msgTagType(row.msgType)" effect="light">{{ msgTypeName(row.msgType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
<el-table-column prop="senderName" label="发送人" width="100" align="center" />
<el-table-column prop="receiverName" label="接收人" width="110" align="center" />
<el-table-column label="已读" width="70" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isRead ? 'info' : 'danger'" effect="plain">{{ row.isRead ? '已读' : '未读' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" width="170" align="center" />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" link @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
class="pager"
layout="total, sizes, prev, pager, next"
:total="total"
:page-sizes="[10, 20, 50]"
v-model:current-page="query.page"
v-model:page-size="query.size"
@current-change="load"
@size-change="handleSearch"
/>
</el-card>
<!-- 群发公告对话框 -->
<el-dialog v-model="notifyVisible" title="群发公告" width="520px" :close-on-click-modal="false">
<el-form :model="notifyForm" label-width="80px">
<el-form-item label="类型">
<el-select v-model="notifyForm.msgType" style="width: 100%">
<el-option v-for="t in msgTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="notifyForm.title" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="notifyForm.content" type="textarea" :rows="4" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="接收范围">
<el-radio-group v-model="notifyForm.scope">
<el-radio value="all">全部用户</el-radio>
<el-radio value="online">仅在线用户</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="notifyVisible = false">取消</el-button>
<el-button type="primary" :loading="notifying" @click="doNotify">发送</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Promotion } from '@element-plus/icons-vue'
import { imAdminMessages, imAdminDeleteMessage, imNotify } from '@/api/im'
const msgTypeOptions = [
{ value: 0, label: '系统通知' },
{ value: 1, label: '业务提醒' },
{ value: 2, label: '审批通知' },
{ value: 3, label: '预警' },
{ value: 4, label: '单聊消息' }
]
const msgTypeName = (t) => msgTypeOptions.find((o) => o.value === t)?.label || `类型${t}`
const msgTagType = (t) => ({ 0: 'primary', 1: 'success', 2: 'warning', 3: 'danger', 4: 'info' }[t] || 'info')
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ msgType: undefined, kw: '', page: 1, size: 20 })
const notifyVisible = ref(false)
const notifying = ref(false)
const notifyForm = reactive({ msgType: 0, title: '', content: '', scope: 'all' })
async function load() {
loading.value = true
try {
const res = await imAdminMessages({
msgType: query.msgType ?? -1,
kw: query.kw || undefined,
page: query.page,
size: query.size
})
const d = res?.data || {}
rows.value = d.items || []
total.value = d.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
load()
}
function remove(row) {
ElMessageBox.confirm(`确定删除消息「${row.title || row.content}」吗?`, '提示', { type: 'warning' })
.then(async () => {
await imAdminDeleteMessage(row.id)
ElMessage.success('已删除')
load()
})
.catch(() => {})
}
function openNotify() {
notifyForm.msgType = 0
notifyForm.title = ''
notifyForm.content = ''
notifyForm.scope = 'all'
notifyVisible.value = true
}
async function doNotify() {
if (!notifyForm.title.trim() || !notifyForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
notifying.value = true
try {
await imNotify({
msgType: notifyForm.msgType,
title: notifyForm.title.trim(),
content: notifyForm.content.trim(),
userIds: notifyForm.scope === 'online' ? [] : undefined
})
ElMessage.success('公告已群发')
notifyVisible.value = false
load()
} finally {
notifying.value = false
}
}
onMounted(load)
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
}
.pager {
margin-top: 14px;
justify-content: flex-end;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="im-monitor">
<!-- 统计卡片 -->
<el-row :gutter="14" class="stat-row">
<el-col v-for="card in statCards" :key="card.key" :xs="12" :sm="12" :md="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<div class="stat-icon" :style="{ background: card.bg, color: card.color }">
<el-icon :size="22"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区 -->
<el-row :gutter="14" class="chart-row">
<el-col :xs="24" :md="16">
<el-card shadow="never">
<template #header>
<div class="card-title">
<span> 7 天消息量趋势</span>
<span class="update-at">更新于 {{ updatedAt }}</span>
</div>
</template>
<div ref="trendRef" class="chart trend-chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="8">
<el-card shadow="never">
<template #header>
<div class="card-title">消息类型分布</div>
</template>
<div ref="pieRef" class="chart pie-chart"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { Message, ChatDotRound, Bell, User, ChatLineRound, Document } from '@element-plus/icons-vue'
import { imAdminPerformance } from '@/api/im'
const data = ref({ total: 0, today: 0, unread: 0, online: 0, sessions: 0, avgLen: 0, trend: [], typeDist: [], updatedAt: '' })
const updatedAt = computed(() => data.value.updatedAt || '')
const trendRef = ref(null)
const pieRef = ref(null)
let trendChart = null
let pieChart = null
let timer = null
const statCards = computed(() => [
{ key: 'total', title: '消息总量', value: data.value.total, icon: Message, bg: '#ecf5ff', color: '#409eff' },
{ key: 'today', title: '今日消息', value: data.value.today, icon: ChatDotRound, bg: '#f0f9eb', color: '#67c23a' },
{ key: 'unread', title: '未读消息', value: data.value.unread, icon: Bell, bg: '#fdf6ec', color: '#e6a23c' },
{ key: 'online', title: '在线用户', value: data.value.online, icon: User, bg: '#f4f4f5', color: '#909399' },
{ key: 'sessions', title: '会话数', value: data.value.sessions, icon: ChatLineRound, bg: '#f3eefc', color: '#9c27b0' },
{ key: 'avgLen', title: '平均消息长度(B)', value: data.value.avgLen, icon: Document, bg: '#fef0f0', color: '#f56c6c' }
])
function renderCharts() {
if (!trendRef.value || !pieRef.value) return
const dates = (data.value.trend || []).map((t) => t.date)
const counts = (data.value.trend || []).map((t) => t.count)
trendChart = echarts.init(trendRef.value)
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 30, bottom: 30 },
xAxis: { type: 'category', data: dates, boundaryGap: false },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '消息量',
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
itemStyle: { color: '#409eff' },
data: counts
}
]
})
pieChart = echarts.init(pieRef.value)
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, icon: 'circle', itemWidth: 10, itemHeight: 10 },
series: [
{
name: '消息类型',
type: 'pie',
radius: ['40%', '65%'],
center: ['50%', '45%'],
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}\n{d}%' },
data: (data.value.typeDist || []).map((t) => ({ name: t.name, value: t.count }))
}
]
})
}
function resizeCharts() {
trendChart?.resize()
pieChart?.resize()
}
async function load() {
const res = await imAdminPerformance()
data.value = res?.data || data.value
await nextTick()
renderCharts()
}
function startTimer() {
timer = setInterval(load, 10000)
}
onMounted(() => {
load()
startTimer()
window.addEventListener('resize', resizeCharts)
})
onBeforeUnmount(() => {
clearInterval(timer)
window.removeEventListener('resize', resizeCharts)
trendChart?.dispose()
pieChart?.dispose()
})
</script>
<style scoped>
.im-monitor .stat-row {
margin-bottom: 14px;
}
.im-monitor .stat-card {
margin-bottom: 0;
}
.stat-item {
display: flex;
align-items: center;
gap: 12px;
}
.stat-icon {
width: 46px;
height: 46px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.stat-title {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.chart-row {
margin-bottom: 0;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.update-at {
font-size: 12px;
color: #909399;
font-weight: 400;
}
.chart {
width: 100%;
}
.trend-chart {
height: 320px;
}
.pie-chart {
height: 320px;
}
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="im-service">
<!-- 服务状态总览 -->
<el-row :gutter="14" class="status-row">
<el-col :xs="12" :md="6">
<el-card shadow="hover" class="status-card">
<div class="status-item">
<div class="status-dot" :class="svc.apiStatus === '正常' ? 'ok' : 'err'"></div>
<div class="status-info">
<div class="status-name">API 服务</div>
<div class="status-desc">{{ svc.apiStatus }} · {{ svc.apiDetail }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :md="6">
<el-card shadow="hover" class="status-card">
<div class="status-item">
<div class="status-dot" :class="svc.dbStatus === '正常' ? 'ok' : 'err'"></div>
<div class="status-info">
<div class="status-name">数据库</div>
<div class="status-desc">{{ svc.dbStatus }} · {{ svc.dbName }}{{ svc.dbDetail }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :md="6">
<el-card shadow="hover" class="status-card">
<div class="status-item">
<div class="status-dot ok"></div>
<div class="status-info">
<div class="status-name">消息通道</div>
<div class="status-desc">正常 · 最近消息 {{ svc.lastMsgTime || '暂无' }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :md="6">
<el-card shadow="hover" class="status-card">
<div class="status-item">
<div class="status-dot ok"></div>
<div class="status-info">
<div class="status-name">在线用户</div>
<div class="status-desc">当前 {{ svc.online || 0 }} 人在线</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 详细指标 -->
<el-card shadow="never">
<template #header>
<div class="card-title">
<span>服务运行详情</span>
<el-button size="small" type="primary" plain :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="服务器时间">{{ svc.serverTime }}</el-descriptions-item>
<el-descriptions-item label="服务启动时间">{{ svc.startTime }}</el-descriptions-item>
<el-descriptions-item label="已运行时长">{{ svc.uptime }}</el-descriptions-item>
<el-descriptions-item label="数据库表数量">{{ svc.tableCount ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="消息总数">{{ svc.totalMessages ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="在线用户(60秒活跃估算)">{{ svc.online ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="最近消息时间" :span="2">{{ svc.lastMsgTime || '暂无消息' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { imAdminService } from '@/api/im'
const svc = ref({})
const loading = ref(false)
let timer = null
async function load() {
loading.value = true
try {
const res = await imAdminService()
svc.value = res?.data || {}
} finally {
loading.value = false
}
}
onMounted(() => {
load()
timer = setInterval(load, 15000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>
<style scoped>
.im-service .status-row {
margin-bottom: 14px;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.ok {
background: #67c23a;
box-shadow: 0 0 0 4px rgba(103, 194, 58, 0.15);
}
.status-dot.err {
background: #f56c6c;
box-shadow: 0 0 0 4px rgba(245, 108, 108, 0.15);
}
.status-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.status-desc {
font-size: 12px;
color: #909399;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+161 -1
View File
@@ -37,6 +37,72 @@
</el-col>
</el-row>
<!-- 庄口生产分工段进度 -->
<el-card shadow="never">
<template #header>
<div class="card-title">
<span>庄口生产分工段进度</span>
<span class="zk-total"> {{ zkProgress.total || 0 }} 个庄口</span>
<span class="zk-filter">
<el-select v-model="zkStatusFilter" size="small" style="width: 110px" placeholder="全部状态">
<el-option label="全部状态" :value="-1" />
<el-option label="生产中" :value="1" />
<el-option label="未投产" :value="0" />
<el-option label="已暂停" :value="2" />
<el-option label="已完成" :value="3" />
</el-select>
</span>
</div>
</template>
<el-table v-if="filteredZk.length" :data="filteredZk" class="zk-table" style="width: 100%">
<el-table-column label="庄口" min-width="150" fixed>
<template #default="{ row }">
<div class="zk-name" :title="row.name">{{ row.name }}</div>
<div class="zk-cell-code">{{ row.code }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }">
<el-tag size="small" :type="zkTagType(row.status)" effect="light">{{ row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column label="总体进度" width="180">
<template #default="{ row }">
<div class="zk-progress-row">
<el-progress
:percentage="Math.min(100, Number(row.progress || 0))"
:stroke-width="8"
:color="overallColor(row.progress)"
:format="(p) => (Number(row.progress) > 0 ? Number(row.progress).toFixed(1) + '%' : '')"
/>
</div>
</template>
</el-table-column>
<el-table-column v-for="col in zkColumns" :key="col.key" :label="col.name" min-width="175">
<template #default="{ row }">
<div class="zk-stage-cell">
<div class="zk-stage-head">
<span class="zk-stage-flow" :title="stageOf(row, col.key)?.flow">{{ stageOf(row, col.key)?.flow }}</span>
<span class="zk-stage-val">{{ stageVal(row, col.key) }}</span>
</div>
<el-progress
:percentage="Math.min(100, Number(stageOf(row, col.key)?.rate || 0))"
:stroke-width="8"
:color="stageColor(col.key)"
:format="(p) => (Number(stageOf(row, col.key)?.rate) > 0 ? Number(stageOf(row, col.key).rate).toFixed(1) + '%' : '')"
/>
</div>
</template>
</el-table-column>
<el-table-column label="计划产丝(kg)" width="110" align="right">
<template #default="{ row }">
<span class="zk-plan">{{ row.planOutput ? fmtNum(row.planOutput) : '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无庄口生产数据" :image-size="60" />
</el-card>
<!-- 待办 / 预警 -->
<el-row :gutter="14">
<el-col :xs="24" :md="12">
@@ -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;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<div class="wf-page">
<el-card shadow="never" class="head-card">
<div class="flex-between">
<span class="page-title">我发起的</span>
<div style="display: flex; gap: 10px">
<el-select v-model="query.status" placeholder="状态筛选" clearable style="width: 130px" @change="loadData">
<el-option label="进行中" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<el-button type="primary" @click="loadData">
<el-icon style="margin-right: 4px"><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</el-card>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="rows" border stripe style="width: 100%">
<el-table-column type="index" label="#" width="52" align="center" />
<el-table-column prop="workflowName" label="流程名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="billNo" label="业务单号" min-width="140" show-overflow-tooltip />
<el-table-column prop="nodeName" label="当前节点" min-width="120">
<template #default="{ row }">{{ row.nodeName || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="发起时间" width="170">
<template #default="{ row }">{{ formatTime(row.startTime) }}</template>
</el-table-column>
<el-table-column prop="endTime" label="结束时间" width="170">
<template #default="{ row }">{{ formatTime(row.endTime) || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !rows.length" description="暂无流程记录" />
<div v-if="total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="loadData"
@current-change="loadData"
/>
</div>
</el-card>
<!-- 流程详情 -->
<el-drawer v-model="detailVisible" title="流程详情" size="480px">
<template v-if="detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="流程名称">{{ detail.workflowName }}</el-descriptions-item>
<el-descriptions-item label="业务单号">{{ detail.billNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="当前节点">{{ detail.nodeName || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detail.status)" size="small">{{ statusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发起时间">{{ formatTime(detail.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ formatTime(detail.endTime) || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 class="timeline-title">流转轨迹</h4>
<el-timeline>
<el-timeline-item
v-for="t in detail.tasks || []"
:key="t.id"
:timestamp="formatTime(t.handleTime || t.addTime)"
:type="taskTag(t.status)"
>
<div class="task-item">
<b>{{ t.nodeName }}</b>
<el-tag :type="taskTag(t.status)" size="small" style="margin-left: 6px">{{ taskText(t.status) }}</el-tag>
<div class="task-user">{{ t.userName }}</div>
<div v-if="t.comment" class="task-comment">{{ t.comment }}</div>
</div>
</el-timeline-item>
</el-timeline>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { workflowInstances, workflowInstanceDetail } from '@/api/workflow'
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({ page: 1, size: 20, status: null })
const detailVisible = ref(false)
const detail = ref(null)
const formatTime = (v) => (v ? String(v).replace('T', ' ').slice(0, 19) : '')
const statusText = (s) => ({ 0: '进行中', 1: '已通过', 2: '已驳回', 3: '已撤销' })[s] ?? '未知'
const statusTag = (s) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] ?? 'info'
const taskText = (s) => ({ 0: '待处理', 1: '已同意', 2: '已驳回' })[s] ?? '未知'
const taskTag = (s) => ({ 0: 'primary', 1: 'success', 2: 'danger' })[s] ?? 'info'
async function loadData() {
loading.value = true
try {
const params = { page: query.page, size: query.size }
if (query.status !== null && query.status !== undefined && query.status !== '') {
params.status = query.status
}
const res = await workflowInstances(params)
rows.value = res.data?.items || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
async function handleDetail(row) {
const res = await workflowInstanceDetail(row.id)
if (res.code === 0) {
detail.value = res.data
detailVisible.value = true
}
}
onMounted(loadData)
</script>
<style scoped>
.wf-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.head-card,
.table-card {
border: none;
}
.head-card :deep(.el-card__body) {
padding: 14px 16px;
}
.table-card :deep(.el-card__body) {
padding: 8px 12px 12px;
}
.page-title {
font-size: 16px;
font-weight: 600;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.timeline-title {
margin: 18px 0 12px;
}
.task-user {
color: #909399;
font-size: 12px;
margin-top: 2px;
}
.task-comment {
color: #606266;
font-size: 12px;
margin-top: 4px;
padding: 4px 8px;
background: #f5f7fa;
border-radius: 4px;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="wf-page">
<el-card shadow="never" class="head-card">
<div class="flex-between">
<span class="page-title">待办中心</span>
<el-button type="primary" @click="loadData">
<el-icon style="margin-right: 4px"><Refresh /></el-icon>刷新
</el-button>
</div>
</el-card>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="rows" border stripe style="width: 100%">
<el-table-column type="index" label="#" width="52" align="center" />
<el-table-column prop="workflowName" label="流程名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="billNo" label="业务单号" min-width="140" show-overflow-tooltip />
<el-table-column prop="nodeName" label="当前节点" min-width="120" />
<el-table-column prop="startUserName" label="发起人" width="110" />
<el-table-column prop="startTime" label="发起时间" width="170">
<template #default="{ row }">{{ formatTime(row.startTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="handleApprove(row)">同意</el-button>
<el-button link type="danger" size="small" @click="handleReject(row)">驳回</el-button>
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !rows.length" description="暂无待办事项" />
<div v-if="total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="loadData"
@current-change="loadData"
/>
</div>
</el-card>
<!-- 流程详情 -->
<el-drawer v-model="detailVisible" title="流程详情" size="480px">
<template v-if="detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="流程名称">{{ detail.workflowName }}</el-descriptions-item>
<el-descriptions-item label="业务单号">{{ detail.billNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="当前节点">{{ detail.nodeName || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detail.status)" size="small">{{ statusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发起时间">{{ formatTime(detail.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ formatTime(detail.endTime) || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 class="timeline-title">流转轨迹</h4>
<el-timeline>
<el-timeline-item
v-for="t in detail.tasks || []"
:key="t.id"
:timestamp="formatTime(t.handleTime || t.addTime)"
:type="taskTag(t.status)"
>
<div class="task-item">
<b>{{ t.nodeName }}</b>
<el-tag :type="taskTag(t.status)" size="small" style="margin-left: 6px">{{ taskText(t.status) }}</el-tag>
<div class="task-user">{{ t.userName }}</div>
<div v-if="t.comment" class="task-comment">{{ t.comment }}</div>
</div>
</el-timeline-item>
</el-timeline>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { workflowTodos, workflowApprove, workflowReject, workflowInstanceDetail } from '@/api/workflow'
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({ page: 1, size: 20 })
const detailVisible = ref(false)
const detail = ref(null)
const formatTime = (v) => (v ? String(v).replace('T', ' ').slice(0, 19) : '')
const statusText = (s) => ({ 0: '进行中', 1: '已通过', 2: '已驳回', 3: '已撤销' })[s] ?? '未知'
const statusTag = (s) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] ?? 'info'
const taskText = (s) => ({ 0: '待处理', 1: '已同意', 2: '已驳回' })[s] ?? '未知'
const taskTag = (s) => ({ 0: 'primary', 1: 'success', 2: 'danger' })[s] ?? 'info'
async function loadData() {
loading.value = true
try {
const res = await workflowTodos({ page: query.page, size: query.size })
rows.value = res.data?.items || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
async function handleApprove(row) {
try {
const { value } = await ElMessageBox.prompt('请输入审批意见(可留空)', '同意', {
confirmButtonText: '同意',
cancelButtonText: '取消',
inputPlaceholder: '审批意见',
type: 'success',
inputValue: ''
})
await workflowApprove({ taskId: row.taskId, comment: value || '' })
ElMessage.success('已同意')
loadData()
} catch (e) {
/* 取消或失败由拦截器提示 */
}
}
async function handleReject(row) {
try {
const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '驳回',
cancelButtonText: '取消',
inputPlaceholder: '驳回原因(必填)',
type: 'error',
inputValidator: (v) => (v && v.trim() ? true : '驳回原因不能为空')
})
await workflowReject({ taskId: row.taskId, comment: value.trim() })
ElMessage.success('已驳回')
loadData()
} catch (e) {
/* 取消或失败 */
}
}
async function handleDetail(row) {
const res = await workflowInstanceDetail(row.instanceId)
if (res.code === 0) {
detail.value = res.data
detailVisible.value = true
}
}
onMounted(loadData)
</script>
<style scoped>
.wf-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.head-card,
.table-card {
border: none;
}
.head-card :deep(.el-card__body) {
padding: 14px 16px;
}
.table-card :deep(.el-card__body) {
padding: 8px 12px 12px;
}
.page-title {
font-size: 16px;
font-weight: 600;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.timeline-title {
margin: 18px 0 12px;
}
.task-user {
color: #909399;
font-size: 12px;
margin-top: 2px;
}
.task-comment {
color: #606266;
font-size: 12px;
margin-top: 4px;
padding: 4px 8px;
background: #f5f7fa;
border-radius: 4px;
}
</style>