- 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 防止误删
353 lines
14 KiB
Vue
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>
|