feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)

- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境
- 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost
- 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配
- 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度
- 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver
- 清理 biz_support_intent 旧表 (6 Java/XML 删除)
- ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传
- 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-24 01:00:09 +08:00
co-authored by Claude
parent 80a276e661
commit 904e28710f
123 changed files with 6008 additions and 1706 deletions
+297
View File
@@ -0,0 +1,297 @@
<!--
共享通知列表组件
------------------------------------------------------------
自包含: 数据拉取 / 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-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
</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>