OA ecology: - BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档 - ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配 - application-prod/test.yml: ecology 只读库数据源 会议邀请函/分享: - InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享 - PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页 参会人/执行意向: - BizMeetingAttendee*: 参会人模块增强 - BizExecutionIntent*: 执行意向增强 签到/劳务/导入: - BizSignServiceImpl + SignFill.vue: 签到劳务 - FieldMismatch/ImportMismatch: 导入校验 VO 公示/会议详情: - PublicityDetail.vue / MeetingDetail.vue 等前端增强 配置: - test 短信 mock + 供应商/招标接口切生产域名
363 lines
11 KiB
Vue
363 lines
11 KiB
Vue
<!--
|
||
共享通知列表组件
|
||
------------------------------------------------------------
|
||
自包含: 数据拉取 / SSE 实时刷新 / 详情弹窗 / 全部已读 全部封装在内.
|
||
任何角色页面 (workbench / home / 独立 messages 页) 只需:
|
||
<NoticeList :limit="5" /> // 嵌入式小列表 (默认有"全部已读"顶部按钮)
|
||
<NoticeList :limit="50" :show-category="true" /> // 完整页列表 (显示分类标签)
|
||
<NoticeList :limit="5" :show-header="false" /> // 嵌入式,父 section-title 自己带按钮
|
||
|
||
Props:
|
||
limit Number 拉取条数,默认 5
|
||
showCategory Boolean 是否显示 [通知/待办/系统] 分类标签,默认 false
|
||
showHeader Boolean 是否显示组件自己的"全部已读"顶部按钮,默认 true
|
||
emptyText String 空列表文案,默认 '暂无通知'
|
||
|
||
复用: 调用方不再写 notice-item / dot / SSE 订阅 / 详情 dialog,3 处变 1 处.
|
||
-->
|
||
<template>
|
||
<div class="notice-list-wrap">
|
||
<div v-if="showHeader" class="notice-list-header">
|
||
<el-link :underline="false" v-if="hasUnread" type="primary" @click="markAllRead">全部已读</el-link>
|
||
</div>
|
||
<ul class="notice-list">
|
||
<li
|
||
v-for="n in rows"
|
||
:key="n.id"
|
||
class="notice-item"
|
||
:class="{ read: n.read }"
|
||
@click="openDetail(n)"
|
||
>
|
||
<div class="dot"></div>
|
||
<div class="body">
|
||
<div class="title">
|
||
<span v-if="showCategory && n.category" class="cat" :class="catClass(n)">{{ n.category }}</span>
|
||
{{ n.title }}
|
||
</div>
|
||
<div class="text">{{ n.text }}</div>
|
||
</div>
|
||
<span class="time">{{ n.time }}</span>
|
||
</li>
|
||
<li v-if="!rows.length" class="empty">{{ emptyText }}</li>
|
||
</ul>
|
||
|
||
<!-- 分页 (pageable=true 时显示; 嵌入式小列表 pageable=false 隐藏) -->
|
||
<div v-if="pageable && total > 0" class="notice-pager">
|
||
<el-pagination
|
||
v-model:current-page="page.pageNum"
|
||
v-model:page-size="page.pageSize"
|
||
:total="total"
|
||
:page-sizes="[10, 20, 50]"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
@current-change="load"
|
||
@size-change="load"
|
||
/>
|
||
</div>
|
||
|
||
<el-dialog
|
||
v-model="detailOpen"
|
||
:title="detail.title || '消息详情'"
|
||
width="520px"
|
||
align-center
|
||
destroy-on-close
|
||
>
|
||
<div class="detail-body">
|
||
<div class="meta">
|
||
<span v-if="detail.category" class="cat" :class="catClass(detail)">{{ detail.category }}</span>
|
||
<span class="time">{{ detail.time }}</span>
|
||
</div>
|
||
<div class="text">{{ detail.content || detail.text || '-' }}</div>
|
||
<div v-if="detailLink" class="detail-link">
|
||
<img v-if="detailQrUrl" :src="detailQrUrl" class="detail-qr" alt="链接二维码" />
|
||
<div class="detail-link-row">
|
||
<el-link type="primary" :href="detailLink" target="_blank" :underline="false">打开链接</el-link>
|
||
<el-link type="primary" :underline="false" @click="copyDetailLink">复制链接</el-link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import QRCode from 'qrcode'
|
||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||
import { onGlobal } from '@/utils/sseClient'
|
||
|
||
const router = useRouter()
|
||
|
||
const props = defineProps({
|
||
limit: { type: Number, default: 5 },
|
||
showCategory: { type: Boolean, default: false },
|
||
showHeader: { type: Boolean, default: true },
|
||
emptyText: { type: String, default: '暂无通知' },
|
||
/**
|
||
* 是否启用分页
|
||
* - false (默认): 嵌入式小列表, 拉 props.limit 条, 不显示 el-pagination
|
||
* - true: 独立页, 显示 el-pagination, 按 page.pageSize 拉, total 读后端真实值
|
||
*/
|
||
pageable: { type: Boolean, default: false }
|
||
})
|
||
|
||
const rows = ref([])
|
||
const total = ref(0)
|
||
const page = reactive({ pageNum: 1, pageSize: 20 })
|
||
const detailOpen = ref(false)
|
||
const detail = ref({})
|
||
const detailLink = ref('')
|
||
const detailQrUrl = ref('')
|
||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||
|
||
let unsubscribeNewMessage = null
|
||
|
||
async function load() {
|
||
try {
|
||
// 分页模式: 传 pageNum/pageSize (后端返回真实 total); 非分页模式: 传 limit
|
||
const params = props.pageable
|
||
? { pageNum: page.pageNum, pageSize: page.pageSize }
|
||
: { limit: props.limit }
|
||
const r = await listMyMessages(params)
|
||
rows.value = r.rows || []
|
||
total.value = r.total || 0
|
||
} catch (e) {
|
||
rows.value = []
|
||
total.value = 0
|
||
}
|
||
}
|
||
|
||
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见)
|
||
* 分页模式: 重拉当前页; 非分页模式: 仍按 limit 拉 */
|
||
async function refresh() {
|
||
try {
|
||
const params = props.pageable
|
||
? { pageNum: page.pageNum, pageSize: page.pageSize }
|
||
: { limit: props.limit }
|
||
const r = await listMyMessages(params)
|
||
rows.value = r.rows || []
|
||
total.value = r.total || 0
|
||
} catch (e) { /* swallow */ }
|
||
}
|
||
|
||
/** 从消息正文里抽取第一个 http(s) 链接, 去尾随标点 */
|
||
function extractLink(content) {
|
||
if (!content) return ''
|
||
const m = String(content).match(/https?:\/\/[^\s"'<>]+/)
|
||
if (!m) return ''
|
||
return m[0].replace(/[。,、;:,.!?;:))\]]+$/, '')
|
||
}
|
||
|
||
// bizType → 路由映射: 这类通知点击后直接进入对应页面 (带 layout), 不弹 dialog 二次点击.
|
||
// 后端约定: bizId 存路由参数 (如会议邀请函 bizId = attendeeId). 后续新类型在此扩展.
|
||
const BIZ_NAV = {
|
||
meetingInvitation: (bizId) => `/doctor/invitation/${bizId}`
|
||
}
|
||
|
||
function navigateTarget(n) {
|
||
if (!n.bizType || n.bizId == null) return ''
|
||
const fn = BIZ_NAV[n.bizType]
|
||
return fn ? fn(n.bizId) : ''
|
||
}
|
||
|
||
/** 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
|
||
function markRead(n) {
|
||
if (!n.read) {
|
||
n.read = true
|
||
markMessageRead(n.id).catch(() => { n.read = false })
|
||
}
|
||
}
|
||
|
||
/** 点列表项: 特定类型直接跳页面; 其余打开详情 dialog */
|
||
function openDetail(n) {
|
||
const target = navigateTarget(n)
|
||
if (target) {
|
||
markRead(n)
|
||
router.push(target)
|
||
return
|
||
}
|
||
detail.value = n
|
||
detailOpen.value = true
|
||
const link = extractLink(n.content)
|
||
detailLink.value = link
|
||
detailQrUrl.value = ''
|
||
if (link) {
|
||
QRCode.toDataURL(link, { width: 200, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' } })
|
||
.then(url => { detailQrUrl.value = url })
|
||
.catch(() => { detailQrUrl.value = '' })
|
||
}
|
||
markRead(n)
|
||
}
|
||
|
||
/** 复制消息正文中的链接 */
|
||
async function copyDetailLink() {
|
||
if (!detailLink.value) return
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(detailLink.value)
|
||
} else {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = detailLink.value
|
||
ta.style.position = 'fixed'
|
||
ta.style.opacity = '0'
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
}
|
||
ElMessage.success('链接已复制')
|
||
} catch (e) {
|
||
ElMessage.error('复制失败, 请手动复制')
|
||
}
|
||
}
|
||
|
||
/** 全部已读: 后端 markAllRead (后端会推 silent=true SSE, 角标自动清零) + 本地乐观更新 */
|
||
async function markAllRead() {
|
||
try {
|
||
await markAllMessagesRead()
|
||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||
} catch (e) {
|
||
ElMessage.error('全部已读失败, 请重试')
|
||
}
|
||
}
|
||
|
||
/** type 字段 (后端 BizMessage.msgType): '1'通知 '2'待办 '3'系统 */
|
||
function catClass(n) {
|
||
if (n.type === '1') return 'cat-notif'
|
||
if (n.type === '2') return 'cat-todo'
|
||
if (n.type === '3') return 'cat-sys'
|
||
return ''
|
||
}
|
||
|
||
onMounted(() => {
|
||
load()
|
||
// SSE 全局总线 (AdminLayout 维护连接, 这里订阅刷新本组件列表即可)
|
||
// toast 由 AdminLayout 统一弹, 这里只负责本组件 list 实时刷新
|
||
unsubscribeNewMessage = onGlobal(() => {
|
||
refresh()
|
||
})
|
||
})
|
||
onBeforeUnmount(() => {
|
||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.notice-list-wrap { display: flex; flex-direction: column; }
|
||
.notice-list-header {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 4px 0 8px;
|
||
}
|
||
|
||
/* 列表 (跟 doctor/Home.vue 旧内联样式保持像素一致) */
|
||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||
.notice-item {
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.notice-item:last-child { border-bottom: none; }
|
||
.notice-item:hover .title { color: var(--brand-primary); }
|
||
.notice-item .dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--brand-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||
.notice-item .body { flex: 1; min-width: 0; }
|
||
.notice-item .title {
|
||
font-size: 13px;
|
||
color: #1a1a1a;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.notice-item.read .title { color: #8c8c8c; }
|
||
.notice-item .text {
|
||
font-size: 12px;
|
||
color: #595959;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
}
|
||
.notice-item .time {
|
||
font-size: 12px;
|
||
color: #bfbfbf;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 分类标签 (按后端 msgType 染色: 1=通知蓝 2=待办橙 3=系统紫) */
|
||
.cat {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
padding: 1px 6px;
|
||
border-radius: 2px;
|
||
background: #f0f0f0;
|
||
color: #595959;
|
||
flex-shrink: 0;
|
||
}
|
||
.cat-sys { background: #f9f0ff; color: #722ed1; }
|
||
.cat-todo { background: #fff7e6; color: #fa8c16; }
|
||
.cat-notif { background: #e6f7ff; color: var(--brand-primary); }
|
||
|
||
.empty {
|
||
padding: 24px 0;
|
||
text-align: center;
|
||
color: #bfbfbf;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* 分页 (独立页用, 嵌入式不显示) */
|
||
.notice-pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
/* 详情 dialog */
|
||
.detail-body .meta {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.detail-body .meta .time {
|
||
font-size: 12px;
|
||
color: #8c8c8c;
|
||
}
|
||
.detail-body .text {
|
||
font-size: 14px;
|
||
color: #1a1a1a;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
}
|
||
.detail-link {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #f0f0f0;
|
||
text-align: center;
|
||
}
|
||
.detail-link .detail-qr {
|
||
width: 200px;
|
||
height: 200px;
|
||
display: block;
|
||
margin: 0 auto 12px;
|
||
}
|
||
.detail-link-row {
|
||
display: flex;
|
||
gap: 20px;
|
||
justify-content: center;
|
||
}
|
||
</style>
|