Files
guoju0808/ry-vue3/src/views/doctor/Home.vue
T
郭庆泰 86e85d02cb feat(msg): SSE 实时通知体系 (#1-#6) + dict 接口权限分层
- BizNotifyService 门面: 5 个语义方法 (expertAuditResult / planAuditResult /
  meetingInvitation / agreementAwaitingSign / projectAssignedToExecutor)
- #3 项目分配执行方: 跨调用对比旧 (sessions, amount) 差集去重
- #5 会议邀请: BizMeetingController.add 全量通知, edit 走 userIds 差集去重
- #6 协议待签: updateLaborProtocol 仅在旧 URL 空 → 新 URL 非空时推
- 前端: AdminLayout 全局 bell 角标 + 全局事件总线, doctor/Home 实时刷新,
  Messages 全部已读, Login.vue 角色统一跳首页, Toast 弹窗上移到 layout
- dict 权限分层 (读开放 / 写 admin-only): BizDictController + SysDictDataController
  避免 dropdown 403, 但写操作仍需 admin 防止误删
2026-08-22 15:53:01 +08:00

353 lines
14 KiB
Vue

<template>
<div class="page-card doctor-home">
<div class="breadcrumb">首页</div>
<!-- 欢迎栏 -->
<div class="welcome-bar">
<div class="welcome-text">
<h2>下午好,{{ displayName }}专家</h2>
<p>欢迎使用项目管理系统</p>
</div>
<div class="welcome-time">
<div class="now">{{ nowTime }}</div>
<div class="date">{{ nowDate }}</div>
</div>
</div>
<!-- 待参加的会议 + 待签署的协议: 仅审核通过的医生可见 -->
<div class="cols-row" v-if="store.expertAuditApproved">
<div class="section">
<h2 class="section-title">
待参加的会议
<a class="more" @click.prevent="$router.push('/doctor/meetings')">更多 </a>
</h2>
<ul class="simple-list">
<li class="simple-item" v-for="m in upcomingMeetings" :key="m.meetingId" @click="$router.push('/doctor/meetings')">
<div class="item-main">
<span class="item-title">{{ m.meetingName || m.title }}</span>
</div>
<span class="item-status">{{ formatTime(m.startTime) }}</span>
</li>
<li v-if="!upcomingMeetings.length" class="empty">暂无待参加会议</li>
</ul>
</div>
<div class="section">
<h2 class="section-title">
待签署的协议
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
</h2>
<ul class="simple-list">
<li class="simple-item" v-for="(s, idx) in pendingAgreements" :key="s.id" @click="showQrcode(s, idx)">
<div class="item-main">
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div>
<span class="item-status">待签署</span>
</li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul>
</div>
</div>
<!-- 通知消息 -->
<section class="section">
<h2 class="section-title">
通知消息
<span class="title-actions">
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a>
</span>
</h2>
<ul class="notice-list">
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="openDetail(n)">
<div class="dot"></div>
<div class="body">
<div class="title">{{ n.title }}</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!notices.length" class="empty">暂无通知</li>
</ul>
</section>
<!-- 消息详情 dialog (点列表项触发) -->
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px" align-center destroy-on-close>
<div class="detail-body">
<div class="meta">
<span class="time">{{ detail.time }}</span>
</div>
<div class="text">{{ detail.text || detail.content || '-' }}</div>
</div>
<template #footer>
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
</template>
</el-dialog>
<!-- 二维码弹窗 -->
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
<div class="qrcode-wrap">
<div v-if="qrcodeLoading" v-loading="true" class="qrcode-loading"></div>
<img v-else-if="qrcodeUrl" :src="qrcodeUrl" class="qrcode-img" />
<div class="qrcode-tip">用手机扫码进入会议 {{ pendingAgreements[currentQrcodeIdx]?.meetingName || '' }}劳务协议</div>
<div class="qrcode-hint">医生需先在手机端登录账号</div>
<el-link v-if="qrcodeMobileUrl" type="primary" :underline="false" class="qrcode-copy" @click="copyQrcodeUrl">
复制链接
</el-link>
</div>
<template #footer>
<el-button @click="qrcodeOpen = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList, listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
import { onGlobal } from '@/utils/sseClient'
const store = useUserStore()
const upcomingMeetings = ref([])
const pendingAgreements = ref([])
const notices = ref([])
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
const displayName = computed(() =>
expertName.value || store.user?.nickName || store.user?.userName || '专家'
)
const nowTime = ref('')
const nowDate = ref('')
let timer = null
// 二维码弹窗
const qrcodeOpen = ref(false)
const qrcodeUrl = ref('')
const qrcodeMobileUrl = ref('')
const qrcodeLoading = ref(false)
const currentQrcodeIdx = ref(0)
async function showQrcode(row, idx) {
currentQrcodeIdx.value = idx ?? 0
qrcodeOpen.value = true
qrcodeLoading.value = true
try {
// 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入)
// Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3)
const fullUrl = window.location.origin + '/#/doctor/sign-fill?attendeeId=' + row.id
qrcodeMobileUrl.value = fullUrl
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
width: 240,
margin: 2,
color: { dark: '#1a1a1a', light: '#ffffff' }
})
} catch (e) {
ElMessage.error(e?.msg || '生成二维码失败')
qrcodeOpen.value = false
} finally {
qrcodeLoading.value = false
}
}
async function copyQrcodeUrl() {
if (!qrcodeMobileUrl.value) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(qrcodeMobileUrl.value)
} else {
const ta = document.createElement('textarea')
ta.value = qrcodeMobileUrl.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('复制失败, 请手动长按二维码识别')
}
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
const today = new Date()
const diff = Math.floor((d - today) / 86400000)
if (diff === 0) return d.toTimeString().slice(0, 5)
if (diff === 1) return '明天'
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function updateClock() {
const now = new Date()
nowTime.value = now.toTimeString().slice(0, 5)
nowDate.value = now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
}
async function load() {
// 当前用户的专家真实姓名 (用于欢迎栏, 不论审核状态都需要)
try {
const { data } = await getMyExpertProfile()
expertName.value = data?.name || ''
} catch (e) { expertName.value = '' }
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏)
if (store.expertAuditApproved) {
try {
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] }
try {
const { data } = await listUnsignedMeetingProtocols()
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
id: s.id,
meetingId: s.meetingId,
meetingName: s.meetingName,
startTime: s.startTime
}))
} catch (e) { pendingAgreements.value = [] }
} else {
upcomingMeetings.value = []
pendingAgreements.value = []
}
// 通知
try {
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
id: a.id,
title: a.title,
text: a.text,
time: a.time,
read: a.read // 严格按 DB is_read, 不再用 i>=2 硬编码"第3条算旧"误导用户
}))
} catch (e) { notices.value = [] }
}
/** 仅重拉通知列表 (SSE new_message 事件触发, AdminLayout 已统一弹 toast, 这里只刷新本页面 list) */
async function refreshNotices() {
try {
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
id: a.id,
title: a.title,
text: a.text,
time: a.time,
read: a.read
}))
} catch (e) { /* SSE 重拉失败静默, 用户下次手动刷新可见 */ }
}
// SSE 订阅句柄, 卸载时解订阅避免内存泄漏
let unsubscribeNewMessage = null
// ===== 消息详情 dialog =====
const detailOpen = ref(false)
const detail = ref({})
/** 点通知项: 打开 dialog + 调 markRead (后端会再触发 SSE 推新未读, navbar 角标自动减) */
async function openDetail(n) {
detail.value = n
detailOpen.value = true
if (!n.read) {
try {
await markMessageRead(n.id)
n.read = true // 本地乐观更新, 避免再调 refreshNotices 闪屏
} catch (e) {
// markRead 失败不阻塞 dialog, 用户下次点还会重试
}
}
}
/** 全部已读: 调后端 markAllRead (后端会推 silent=true 的 SSE, 角标自动清零) */
async function markAllRead() {
try {
await markAllMessagesRead()
// 乐观本地全标已读, 不等 SSE 推送 (push 过来后 refreshNotices 也会全 read)
notices.value = notices.value.map(n => ({ ...n, read: true }))
} catch (e) {
ElMessage.error('全部已读失败, 请重试')
}
}
/** 列表里只要有未读就显示"全部已读"按钮 */
const hasUnread = computed(() => notices.value.some(n => !n.read))
onMounted(() => {
updateClock()
timer = setInterval(updateClock, 60000)
load()
// 订阅全局总线 (AdminLayout 把 SSE 事件扇出到这里), 跨路由不掉, 但页面 unmount 时仍需解订阅
// toast 弹窗由 AdminLayout 统一处理 (silent flag), 这里只做本页 list 刷新 + 医生审核状态联动
unsubscribeNewMessage = onGlobal(() => {
refreshNotices()
// manager 审核通过事件过来: 重新拉 store 触发的 audit 状态, 若刚转 approved, 把 pannel 数据拉出来
if (!store.expertAuditApproved && store.role === 'doctor') {
load()
}
})
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
if (unsubscribeNewMessage) unsubscribeNewMessage()
})
</script>
<style scoped>
.doctor-home { padding: 16px 20px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
.welcome-text p { font-size: 13px; opacity: 0.85; }
.welcome-time .now { font-size: 14px; font-weight: 500; }
.welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; }
.section { background: #fff; padding: 16px 20px; margin-bottom: 16px; border: 1px solid #f0f0f0; border-radius: 4px; }
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; }
.more:hover { text-decoration: underline; }
.title-actions { display: inline-flex; align-items: center; gap: 12px; font-weight: normal; }
.cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.cols-row .section { margin-bottom: 16px; }
.simple-list { list-style: none; border-top: 1px solid #f0f0f0; }
.simple-item { padding: 10px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; }
.simple-item:last-child { border-bottom: none; }
.simple-item:hover .item-title { color: var(--brand-primary); }
.item-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.item-title { font-size: 13px; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.item-status { flex-shrink: 0; font-size: 12px; color: #8c8c8c; }
.item-status.done { color: #52c41a; }
.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; }
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
/* 二维码弹窗 */
.qrcode-wrap { text-align: center; padding: 12px 0; }
.qrcode-loading { width: 240px; height: 240px; margin: 0 auto; }
.qrcode-img { width: 240px; height: 240px; }
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
/* 消息详情 dialog */
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.detail-body .time { font-size: 12px; color: #8c8c8c; }
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
</style>