Files
guoju0808/ry-vue3/src/components/NoticeList.vue
T
guoju0808 d2fe5a4035 style: 重塑政务风格 + 操作列统一 el-link + 表格紧凑化
业务背景
- ry-vue3 为政务/卫健委风合规管理系统 (医疗机构合规系统), 不要可爱配色、不要插画
- 品牌色: 主色 #15803D (低调深绿) + 次色 #0E7490 (青蓝), 不要蓝色系主色

变更概要
1. 全局地基
   - theme.scss: Element Plus 主色改为 #15803D, slate 9 档, 联动组件库
   - main.scss: 字体/页面骨架/筛选栏/表格/控件/滚动条 全套样式
   - AdminLayout.vue: 政务顶栏 + 浅色侧栏
   - Workbench.vue: 替换原绿底大字卡为 KPI 白底色条 + 快捷入口 + 进度条

2. 表格操作列改造
   - el-button link 全面替换为 el-link (:underline="false" + type="primary/danger")
   - 36 文件 132 处替换, 操作列外加 .table-actions 容器 (内含分隔线)
   - 修复 el-link 不支持的属性 (size="small"/text/loading)

3. 筛选栏
   - 弹性布局 + gap 14px 16px + align-items: flex-start + min-height 32px
   - 删除所有 scoped .filter-form 覆盖 (28 处) 让全局样式生效
   - 控件宽度 180px 统一, 标签 32px 居中, 折行不紧贴

4. 表格紧凑化
   - cell-padding 10px → 6px, 表头高度 36px
   - 行高 ~46px → ~36px, 字号 13px

5. 每页默认 20 条
   - 21 文件 pageSize: 10 → 20

6. meetings 操作列折叠
   - 11 操作 → 外置 3 个主操作 + 1 个"更多" el-dropdown (8 个次操作)
   - width 500px → 260px
   - 配 .op-dropdown / .op-caret 样式 (hover 旋转)
   - 新增 onMoreAction dispatch 函数 (复用原 handler)

7. 操作列文字防拆行
   - .table-actions flex-wrap: nowrap + white-space: nowrap
   - el-link padding 0 8px → 0 6px, flex-shrink: 0

8. 环境配置
   - .env.development: 直连远端 https://risingdoctor.com/hg-api (绕过 vite proxy 卡顿)
   - vite.config.js: proxy fallback 保留
2026-08-26 11:04:15 +00:00

298 lines
8.5 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>
<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, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
import { onGlobal } from '@/utils/sseClient'
const props = defineProps({
limit: { type: Number, default: 5 },
showCategory: { type: Boolean, default: false },
showHeader: { type: Boolean, default: true },
emptyText: { type: String, default: '暂无通知' }
})
const rows = ref([])
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 {
const r = await listMyMessages({ limit: props.limit })
rows.value = r.rows || []
} catch (e) {
rows.value = []
}
}
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见) */
async function refresh() {
try {
const r = await listMyMessages({ limit: props.limit })
rows.value = r.rows || []
} 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(/[。,、;:,.!?;:)\]]+$/, '')
}
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
function openDetail(n) {
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 = '' })
}
if (!n.read) {
n.read = true
markMessageRead(n.id).catch(() => { n.read = false })
}
}
/** 复制消息正文中的链接 */
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;
}
/* 详情 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>