Files
guoju0808/ry-vue3/src/components/NoticeList.vue
T
郭庆泰 56615f9806 feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
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 + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00

363 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
共享通知列表组件
------------------------------------------------------------
自包含: 数据拉取 / 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>