feat: 新增IM即时通讯(浮窗)、工作流、打印模块及工作台增强
- IM: 新增浮窗聊天(ImFloatWindow)、管理页(monitor/config/service/message)、SSE推送 - 工作流: 新增待办/我的流程页面及后端服务 - 打印: 新增打印模板、出库单打印(PrintPage)、模板种子脚本 - 工作台: 增强快捷入口与工作台数据 - 修复: TagsView页签关闭、CrudPage通用表格增强 - 移除导航菜单中的即时通讯入口,改为右下角浮窗
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* openprint 外部打印页配置
|
||||
* - VITE_OPENPRINT_URL:openprint 应用地址(默认本机 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()}`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
/* 忽略音频不可用 */
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user