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:
@@ -37,52 +37,32 @@
|
||||
<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)">
|
||||
<li
|
||||
class="simple-item"
|
||||
:class="{ disabled: s.isEsigned !== 1 }"
|
||||
v-for="(s, idx) in pendingAgreements"
|
||||
:key="s.id"
|
||||
@click="s.isEsigned === 1 && showQrcode(s, idx)"
|
||||
>
|
||||
<div class="item-main">
|
||||
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
|
||||
</div>
|
||||
<span class="item-status">待签署</span>
|
||||
<span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
|
||||
</li>
|
||||
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知消息 -->
|
||||
<!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
|
||||
<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>
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
</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>
|
||||
<NoticeList :limit="5" :show-header="false" />
|
||||
</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">
|
||||
@@ -106,16 +86,14 @@ 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'
|
||||
import { listUnsignedMeetingProtocols, listInvitedMeetings } from '@/api/business/meetingAttendee'
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
|
||||
const store = useUserStore()
|
||||
|
||||
const upcomingMeetings = ref([])
|
||||
const pendingAgreements = ref([])
|
||||
const notices = ref([])
|
||||
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
|
||||
const expertName = ref('')
|
||||
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
|
||||
@@ -140,7 +118,7 @@ async function showQrcode(row, idx) {
|
||||
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
|
||||
const fullUrl = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + row.id
|
||||
qrcodeMobileUrl.value = fullUrl
|
||||
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
|
||||
width: 240,
|
||||
@@ -202,8 +180,8 @@ async function load() {
|
||||
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏)
|
||||
if (store.expertAuditApproved) {
|
||||
try {
|
||||
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
|
||||
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
|
||||
const { data } = await listInvitedMeetings()
|
||||
upcomingMeetings.value = (Array.isArray(data) ? data : []).slice(0, 5)
|
||||
} catch (e) { upcomingMeetings.value = [] }
|
||||
|
||||
try {
|
||||
@@ -212,91 +190,24 @@ async function load() {
|
||||
id: s.id,
|
||||
meetingId: s.meetingId,
|
||||
meetingName: s.meetingName,
|
||||
startTime: s.startTime
|
||||
startTime: s.startTime,
|
||||
isEsigned: s.isEsigned
|
||||
}))
|
||||
} 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 = [] }
|
||||
// 通知列表已抽到 <NoticeList> 组件, 本页不再处理
|
||||
}
|
||||
|
||||
/** 仅重拉通知列表 (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>
|
||||
|
||||
@@ -312,7 +223,6 @@ onBeforeUnmount(() => {
|
||||
.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; }
|
||||
@@ -323,17 +233,9 @@ onBeforeUnmount(() => {
|
||||
.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; }
|
||||
.simple-item.disabled { cursor: default; }
|
||||
.simple-item.disabled .item-title { color: #bfbfbf; }
|
||||
.simple-item.disabled:hover .item-title { color: #bfbfbf; }
|
||||
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -344,9 +246,4 @@ onBeforeUnmount(() => {
|
||||
.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>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable>
|
||||
<el-option label="未开始" value="未开始" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已结束" value="已结束" />
|
||||
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
@@ -18,16 +16,16 @@
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="120">
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="stageTag(row.currentStage)" size="small">{{ row.currentStage || '-' }}</el-tag>
|
||||
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
|
||||
<el-button v-if="row.laborSigned !== '1'" link type="primary" @click="onSign(row)">签署劳务</el-button>
|
||||
<el-button v-if="!row.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
|
||||
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -44,30 +42,16 @@
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<!-- 签署劳务 dialog: 未签状态显示, 含 PDF 上传 -->
|
||||
<el-dialog v-model="signOpen" title="签署劳务" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="项目编号">{{ signForm.projectNo }}</el-form-item>
|
||||
<el-form-item label="会议名称">{{ signForm.meetingName }}</el-form-item>
|
||||
<el-form-item label="签字 PDF">
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:before-upload="beforeSignUpload"
|
||||
:http-request="uploadSignPdf"
|
||||
:file-list="signFileList"
|
||||
:on-remove="onSignRemove"
|
||||
accept=".pdf"
|
||||
>
|
||||
<i class="el-icon-upload"></i>
|
||||
<div class="el-upload__text">将签字 PDF 拖到此处,或<em>点击上传</em></div>
|
||||
<div class="el-upload__tip" slot="tip">仅支持 PDF, ≤ 20MB</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 签署劳务 dialog: 二维码 (带 token, 手机扫码可直接登录填写) -->
|
||||
<el-dialog v-model="signOpen" title="签署劳务" width="380px" align-center destroy-on-close>
|
||||
<div class="sign-qr-wrap">
|
||||
<div v-if="signQrLoading" v-loading="true" class="sign-qr-loading"></div>
|
||||
<img v-else-if="signQrUrl" :src="signQrUrl" class="sign-qr-img" />
|
||||
<div class="sign-link-tip">会议:{{ signForm.meetingName }}</div>
|
||||
<el-link type="primary" :underline="false" class="sign-copy" @click="copySignLink">复制链接</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="signOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!signForm.signedPdfUrl" @click="submitSign">签署劳务</el-button>
|
||||
<el-button @click="signOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -80,11 +64,11 @@
|
||||
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="pdf-preview">
|
||||
<iframe v-if="detail.signedPdfUrl" :src="detail.signedPdfUrl" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
|
||||
<iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
|
||||
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="detail.signedPdfUrl" link type="primary" @click="downloadPdf(detail.signedPdfUrl, detail.meetingName)">下载</el-button>
|
||||
<el-button v-if="detail.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
|
||||
<el-button @click="laborOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -94,8 +78,9 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList } from '@/api/public'
|
||||
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
|
||||
|
||||
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
@@ -104,9 +89,10 @@ const loading = ref(false)
|
||||
const detail = ref({})
|
||||
const laborOpen = ref(false)
|
||||
const signOpen = ref(false)
|
||||
const signForm = reactive({ meetingId: '', projectNo: '', meetingName: '', signedPdfUrl: '' })
|
||||
const signFileList = ref([])
|
||||
const submitting = ref(false)
|
||||
const signForm = reactive({ meetingName: '' })
|
||||
const signLink = ref('')
|
||||
const signQrUrl = ref('')
|
||||
const signQrLoading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -128,12 +114,6 @@ function reset() {
|
||||
load()
|
||||
}
|
||||
|
||||
function stageTag(s) {
|
||||
if (s === '已结束') return 'info'
|
||||
if (s === '执行中') return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function onViewLabor(row) {
|
||||
detail.value = row
|
||||
laborOpen.value = true
|
||||
@@ -144,7 +124,7 @@ function onView(row) {
|
||||
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
|
||||
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
|
||||
ElMessageBox.alert(
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${row.currentStage || '-'}`,
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
|
||||
'会议详情',
|
||||
{ confirmButtonText: '关闭' }
|
||||
)
|
||||
@@ -161,54 +141,49 @@ function onDownloadFile(row) {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function onSign(row) {
|
||||
signForm.meetingId = row.meetingId
|
||||
signForm.projectNo = row.projectNo || ''
|
||||
async function onSign(row) {
|
||||
signForm.meetingName = row.meetingName || ''
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
const attendeeId = row.attendeeId
|
||||
if (!attendeeId) { ElMessage.warning('缺少参会记录, 无法生成签署链接'); return }
|
||||
const token = localStorage.getItem('ry_token') || ''
|
||||
const url = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + attendeeId
|
||||
+ (row.meetingId != null ? '&meetingId=' + row.meetingId : '')
|
||||
+ '&token=' + encodeURIComponent(token)
|
||||
signLink.value = url
|
||||
signOpen.value = true
|
||||
}
|
||||
|
||||
function beforeSignUpload(file) {
|
||||
const max = 20 * 1024 * 1024
|
||||
if (file.size > max) { ElMessage.error('文件不能超过 20MB'); return false }
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) { ElMessage.error('仅支持 PDF 格式'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadSignPdf(opts) {
|
||||
signQrUrl.value = ''
|
||||
signQrLoading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(opts.file, 'ry8080/labor/signed/')
|
||||
signForm.signedPdfUrl = url
|
||||
ElMessage.success('签字 PDF 上传成功')
|
||||
signQrUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#1a1a1a', light: '#ffffff' }
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
||||
ElMessage.error('生成二维码失败')
|
||||
} finally {
|
||||
signQrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSignRemove() {
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
}
|
||||
|
||||
async function submitSign() {
|
||||
if (!signForm.signedPdfUrl) return ElMessage.warning('请先上传签字 PDF')
|
||||
submitting.value = true
|
||||
function copySignLink() {
|
||||
if (!signLink.value) return
|
||||
try {
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: signForm.meetingId,
|
||||
laborSigned: '1',
|
||||
signedPdfUrl: signForm.signedPdfUrl,
|
||||
signedTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
ElMessage.success('签署成功')
|
||||
signOpen.value = false
|
||||
load()
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(signLink.value)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = signLink.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(e?.msg || '签署失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
ElMessage.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +206,13 @@ load()
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.pdf-preview { margin-top: 12px; }
|
||||
.sign-link-tip { font-size: 13px; color: #606266; margin-bottom: 10px; }
|
||||
.sign-link-hint { font-size: 12px; color: #909399; margin-top: 10px; }
|
||||
.sign-qr-wrap { text-align: center; padding: 12px 0; }
|
||||
.sign-qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||
.sign-qr-img { width: 240px; height: 240px; }
|
||||
.sign-qr-wrap .sign-link-tip { margin-top: 16px; }
|
||||
.sign-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
|
||||
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
|
||||
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">
|
||||
首页 / 消息通知
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" class="mark-all" @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 }">
|
||||
<div class="dot"></div>
|
||||
<div class="body" @click="openDetail(n)">
|
||||
<div class="title"><span class="cat" :class="catClass(n.category)">{{ 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">暂无消息</li>
|
||||
</ul>
|
||||
|
||||
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px">
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span class="cat" :class="catClass(detail.category)">{{ detail.category }}</span>
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.text || '-' }}</div>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
|
||||
const rows = ref([])
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 }); const list = (r.data && r.data.rows) || r.rows || []
|
||||
rows.value = list.map(a => ({
|
||||
id: a.id,
|
||||
category: a.category,
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read
|
||||
}))
|
||||
} catch (e) { rows.value = [] }
|
||||
}
|
||||
|
||||
function mapCategory(t) {
|
||||
if (t === 'system') return '系统通知'
|
||||
if (t === 'audit') return '审核结果'
|
||||
if (t === 'project') return '项目动态'
|
||||
if (t === 'meeting') return '会议提醒'
|
||||
return '通知'
|
||||
}
|
||||
|
||||
function catClass(c) {
|
||||
if (c === '系统通知') return 'sys'
|
||||
if (c === '审核结果') return 'audit'
|
||||
if (c === '项目动态') return 'proj'
|
||||
if (c === '会议提醒') return 'meet'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatAgo(t) {
|
||||
const diff = (Date.now() - new Date(t).getTime()) / 1000
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
|
||||
return new Date(t).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
if (!n.read) {
|
||||
// 乐观本地置已读 + 后端 markRead (后端触发 SSE 推新未读, navbar 角标自动减)
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.mark-all { font-size: 13px; }
|
||||
.notice-list { list-style: none; }
|
||||
.notice-item { padding: 14px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.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; cursor: pointer; }
|
||||
.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; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.notice-item:hover .title { color: var(--brand-primary); }
|
||||
.cat { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 2px; background: #f0f0f0; color: #595959; }
|
||||
.cat.sys { background: #e6f7ff; color: var(--brand-primary); }
|
||||
.cat.audit { background: #f9f0ff; color: #722ed1; }
|
||||
.cat.proj { background: #fff7e6; color: #fa8c16; }
|
||||
.cat.meet { background: #f6ffed; color: #52c41a; }
|
||||
.empty { padding: 24px; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -1,9 +1,16 @@
|
||||
<template>
|
||||
<div class="sign-fill">
|
||||
<div v-loading="loading">
|
||||
<div v-if="notInvited" class="not-invited">
|
||||
<div class="not-invited-text">您暂未被邀请,请联系执会人员</div>
|
||||
<el-button type="primary" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div v-else v-loading="loading">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
|
||||
<!-- 参会人信息 -->
|
||||
<div class="section-title">参会人信息</div>
|
||||
<!-- 大标题: 会议名称 + 期数 -->
|
||||
<div class="sign-header">
|
||||
<div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div>
|
||||
<div class="sign-header-period">期数:{{ periodDisplay || '-' }}</div>
|
||||
</div>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
@@ -16,9 +23,15 @@
|
||||
<el-form-item label="科室" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入科室" maxlength="100" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 银行账户 -->
|
||||
<div class="section-title">银行账户</div>
|
||||
<el-form-item label="医务职称">
|
||||
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 100%">
|
||||
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
|
||||
<template #footer>
|
||||
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
|
||||
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户名称(持卡人姓名)" prop="accountName">
|
||||
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
|
||||
</el-form-item>
|
||||
@@ -53,8 +66,6 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 劳务信息 (只读, admin 预填) -->
|
||||
<div class="section-title">本次劳务</div>
|
||||
<el-form-item label="劳务形式">
|
||||
<div class="labor-form-cell">
|
||||
<el-checkbox-group v-model="laborFormSelected">
|
||||
@@ -65,15 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="医务职称">
|
||||
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 240px">
|
||||
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
|
||||
<template #footer>
|
||||
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
|
||||
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button>
|
||||
@@ -88,13 +90,18 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSignInfo, saveSignProfile } from '@/api/business/sign'
|
||||
import { getSignInfo, saveSignProfile, resolveSign } from '@/api/business/sign'
|
||||
import { getInfo } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
|
||||
const userStore = useUserStore()
|
||||
const attendeeId = ref(null)
|
||||
const meetingId = computed(() => route.query.meetingId ? Number(route.query.meetingId) : null)
|
||||
const notInvited = ref(false)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -120,6 +127,19 @@ const form = reactive({
|
||||
})
|
||||
const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' })
|
||||
|
||||
// 会议大标题 (会议名称 + 期数), 由后端 getSignInfo 返回
|
||||
const meetingName = ref('')
|
||||
const periodNo = ref(null)
|
||||
const totalPeriods = ref(null)
|
||||
const periodDisplay = computed(() => {
|
||||
const p = periodNo.value
|
||||
const t = totalPeriods.value
|
||||
if (p == null && t == null) return ''
|
||||
if (p == null) return `${t}`
|
||||
if (t == null) return `${p}`
|
||||
return `${p}/${t}`
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
@@ -135,6 +155,73 @@ const rules = {
|
||||
bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 扫码带 token 直登 / 无 token 校验登录; 返回 true 表示已就绪 */
|
||||
async function ensureLogin() {
|
||||
const token = route.query.token
|
||||
if (token) {
|
||||
// 扫码带 token: 用新 token 重新登录 (覆盖本地已登录态)
|
||||
userStore.setToken(token)
|
||||
try {
|
||||
const info = await getInfo()
|
||||
const u = info.user || {}
|
||||
const role = u.roleType
|
||||
if (!role) {
|
||||
ElMessage.error('账号角色未配置, 请联系管理员')
|
||||
router.replace({ name: 'login' })
|
||||
return false
|
||||
}
|
||||
userStore.setUser({
|
||||
userId: u.userId,
|
||||
userName: u.userName || u.nickName || '',
|
||||
nickName: u.nickName || u.userName || '',
|
||||
phonenumber: u.phonenumber || '',
|
||||
accountType: u.accountType || 'MAIN',
|
||||
parentUserId: u.parentUserId || null,
|
||||
role
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
ElMessage.error('登录已失效, 请重新登录')
|
||||
router.replace({ name: 'login' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 无 token: 必须已登录
|
||||
if (userStore.user) return true
|
||||
ElMessage.error('请先登录')
|
||||
router.replace({ name: 'login', query: { redirect: route.fullPath } })
|
||||
return false
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loading.value = true
|
||||
const ok = await ensureLogin()
|
||||
if (!ok) return
|
||||
// 解析 attendeeId: 优先 URL 里的 attendeeId; 没有则按 meetingId 查
|
||||
let aid = route.query.attendeeId ? Number(route.query.attendeeId) : null
|
||||
if (!aid && meetingId.value) {
|
||||
try {
|
||||
const { data } = await resolveSign(meetingId.value)
|
||||
if (!data || !data.attendeeId) {
|
||||
notInvited.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
aid = data.attendeeId
|
||||
// 标题: 有 meetingId → 按 meetingId 查 (resolve 已返回会议名/期数)
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
attendeeId.value = aid
|
||||
await load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!attendeeId.value) {
|
||||
ElMessage.error('参数缺失, 请从工作台进入')
|
||||
@@ -162,25 +249,43 @@ async function load() {
|
||||
idCardAttachments: cur.idCardAttachments || '',
|
||||
title: cur.title || def.title || ''
|
||||
})
|
||||
// 先设置选项 (项目角色 + "其他"), 解析劳务形式需要用它区分"项目角色"和"其他自定义"
|
||||
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
|
||||
titleOptions.value = data.titleOptions || []
|
||||
|
||||
// 劳务形式回显: 兼容旧 JSON 数组 (["主席","__other__:xx"]) + 新逗号分隔 ("主席,主持,xx")
|
||||
laborFormSelected.value = []
|
||||
laborFormOther.value = ''
|
||||
if (cur.laborForm) {
|
||||
try {
|
||||
const parsed = JSON.parse(cur.laborForm)
|
||||
if (Array.isArray(parsed)) {
|
||||
laborFormSelected.value = parsed.filter(x => x !== '__other__' && !x.startsWith('__other__:'))
|
||||
const otherItem = parsed.find(x => x === '__other__' || x.startsWith('__other__:'))
|
||||
if (otherItem) {
|
||||
laborFormSelected.value.push('__other__')
|
||||
laborFormOther.value = otherItem.startsWith('__other__:') ? otherItem.substring('__other__:'.length) : ''
|
||||
}
|
||||
let items = []
|
||||
if (typeof cur.laborForm === 'string' && cur.laborForm.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(cur.laborForm); if (Array.isArray(p)) items = p } catch (e) { items = [cur.laborForm] }
|
||||
} else {
|
||||
items = String(cur.laborForm).split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
const known = new Set(laborFormOptions.value.map(o => o.value))
|
||||
items.forEach(it => {
|
||||
const s = String(it)
|
||||
if (s === '__other__' || s.startsWith('__other__:')) {
|
||||
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
|
||||
if (s.startsWith('__other__:')) laborFormOther.value = s.substring('__other__:'.length)
|
||||
} else if (known.has(s)) {
|
||||
if (!laborFormSelected.value.includes(s)) laborFormSelected.value.push(s)
|
||||
} else {
|
||||
// 不在项目角色里 → 当作"其他"自定义
|
||||
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
|
||||
laborFormOther.value = s
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
})
|
||||
}
|
||||
|
||||
feeDisplay.preTax = cur.feePreTax || '—'
|
||||
feeDisplay.tax = cur.tax || '—'
|
||||
feeDisplay.fee = cur.fee || '—'
|
||||
|
||||
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
|
||||
titleOptions.value = data.titleOptions || []
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally {
|
||||
@@ -195,16 +300,13 @@ async function onSubmit() {
|
||||
ElMessage.warning('请上传身份证正反面')
|
||||
return
|
||||
}
|
||||
// 劳务形式序列化 (JSON 数组字符串, 含 __other__ + 自定义内容)
|
||||
// 劳务形式序列化: 逗号分隔字符串, "其他"自定义内容作为普通项
|
||||
let laborForm = ''
|
||||
if (laborFormSelected.value.length) {
|
||||
const arr = laborFormSelected.value.map(x => {
|
||||
if (x === '__other__' && laborFormOther.value) {
|
||||
return '__other__:' + laborFormOther.value
|
||||
}
|
||||
return x
|
||||
})
|
||||
laborForm = JSON.stringify(arr)
|
||||
laborForm = laborFormSelected.value
|
||||
.map(x => (x === '__other__' ? (laborFormOther.value || '').trim() : x))
|
||||
.filter(x => x !== '' && x !== '__other__')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
@@ -228,23 +330,21 @@ function goBack() {
|
||||
router.push('/doctor/home')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 手机端全屏表单 (无 navbar / 无 page-card) */
|
||||
.sign-fill { padding: 16px; }
|
||||
|
||||
/* 章节标题: 左边 3px 蓝竖线 */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
/* 大标题: 会议名称 + 期数 */
|
||||
.sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; }
|
||||
.sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
|
||||
.sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
|
||||
|
||||
/* 未受邀提示 */
|
||||
.not-invited { padding: 60px 20px; text-align: center; }
|
||||
.not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
|
||||
|
||||
/* 开户地址 3 列布局 */
|
||||
.region-row {
|
||||
|
||||
Reference in New Issue
Block a user