Files
guoju0808/ry-vue3/src/views/doctor/Meetings.vue
T
郭庆泰 5a0a892574 feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源
邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md):
- 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML
- 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置
- 上传进度: UploadProgressRegistry + UploadProgressController
- 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js
- 原型: proto/html/components/invite-detail / new-invitation / send-invitation

登录/账号:
- 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗
- 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name
- OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader)

其它:
- OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll
- doctor 项目详情 ProjectDetail.vue
- 数据库/测试/设计文档 (md) 入库
2026-09-10 20:40:49 +08:00

381 lines
15 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.
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 我参与的会议</div>
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<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 v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="签署状态">
<el-select v-model="signedStatus" placeholder="全部" clearable>
<el-option label="待签署" value="unsigned" />
<el-option label="已签署" value="signed" />
</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>
</el-form>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="170" />
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="140">
<template #default="{ row }">
<el-tag :type="stageTag('doctor', row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="日程" width="130" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-link :underline="false" type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-link>
<el-link :underline="false" :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-link>
</div>
</template>
</el-table-column>
<el-table-column label="邀请函" width="130" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-link :underline="false" type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-link>
<el-link :underline="false" :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-link>
</div>
</template>
</el-table-column>
<el-table-column label="签署状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.attendeeLaborProtocol ? 'success' : 'info'" size="small">{{ row.attendeeLaborProtocol ? '已签署' : '未签署' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" v-if="!row.attendeeLaborProtocol" type="primary" @click="onSign(row)">签署劳务</el-link>
<el-link :underline="false" v-else type="primary" @click="onViewLabor(row)">查看劳务</el-link>
</div></template>
</el-table-column>
</el-table>
<el-pagination
v-if="page.total > page.pageSize"
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
<!-- 日程 / 邀请函 预览 dialog (PDF iframe / 图片 img, 复用 publicity OSS 代理预览技术) -->
<el-dialog v-model="previewOpen" :title="previewTitle" width="780px" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="previewUrl" class="file-preview">
<PdfViewer v-if="isPdf(previewUrl)" :url="previewUrl" class="file-preview-pdf" />
<el-image v-else-if="isImg(previewUrl)" :src="previewUrl" :preview-src-list="[previewUrl]" fit="contain" class="file-preview-img" />
<div v-else class="file-preview-fallback">
<p>该文件类型暂不支持页内预览, 请在新窗口打开查看</p>
<el-button type="primary" @click="openPreviewNew">在新窗口打开</el-button>
</div>
</div>
<el-empty v-else description="暂无附件" />
<template #footer>
<el-button @click="previewOpen = false">关闭</el-button>
<el-button v-if="previewUrl" type="primary" @click="onDownload(previewUrl, previewTitle)">下载</el-button>
</template>
</el-dialog>
<!-- 签署劳务 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>
</template>
</el-dialog>
<!-- 查看劳务 dialog: 已签状态显示, PDF iframe 预览 -->
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
<div class="pdf-preview">
<PdfViewer v-if="detail.attendeeLaborProtocol" :url="detail.attendeeLaborProtocol" class="file-preview-pdf" />
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
</div>
<template #footer>
<el-button v-if="detail.attendeeLaborProtocol" type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
<el-button @click="laborOpen=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
import PdfViewer from '@/components/PdfViewer.vue'
// Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议
// 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL
const route = useRoute()
const signedStatus = ref(String(route.query.signedStatus || '') || '')
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
const rows = ref([])
const loading = ref(false)
const detail = ref({})
const laborOpen = ref(false)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const signOpen = ref(false)
const signForm = reactive({ meetingName: '' })
const signLink = ref('')
const signQrUrl = ref('')
const signQrLoading = ref(false)
async function load() {
loading.value = true
try {
const params = { ...q, pageNum: page.pageNum, pageSize: page.pageSize }
if (signedStatus.value) params.signedStatus = signedStatus.value
const { data } = await bizList('meeting', params)
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) {
rows.value = []
page.total = 0
} finally { loading.value = false }
}
function reset() {
q.projectNo = ''
q.meetingName = ''
q.currentStage = ''
signedStatus.value = ''
page.pageNum = 1
load()
}
function onViewLabor(row) {
detail.value = row
laborOpen.value = true
}
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
function isPdf(url) {
if (!url) return false
const path = url.split('?')[0].toLowerCase()
return /\.pdf$/.test(path)
}
function isImg(url) {
if (!url) return false
const path = url.split('?')[0].toLowerCase()
return /\.(png|jpg|jpeg|gif|webp)$/.test(path)
}
// OSS bucket 设置 Content-Disposition: attachment, iframe 直接访问会被强制下载,
// 走 /common/oss/proxy 后端代理重写为 inline (#toolbar=0 隐藏工具栏, #zoom=page-width 撑满宽度)
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function onPreview(url, title) {
if (!url) { ElMessage.warning('暂无附件'); return }
previewUrl.value = url
previewTitle.value = title
previewOpen.value = true
}
function onDownload(url, name) {
if (!url) { ElMessage.warning('暂无附件'); return }
const a = document.createElement('a')
a.href = url
a.download = name
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function openPreviewNew() {
const url = proxyUrl(previewUrl.value)
if (!url) return
window.open(url, '_blank')
}
async function onSign(row) {
signForm.meetingName = row.meetingName || ''
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
signQrUrl.value = ''
signQrLoading.value = true
try {
signQrUrl.value = await QRCode.toDataURL(url, {
width: 240,
margin: 2,
color: { dark: '#1a1a1a', light: '#ffffff' }
})
} catch (e) {
ElMessage.error('生成二维码失败')
} finally {
signQrLoading.value = false
}
}
function copySignLink() {
if (!signLink.value) return
try {
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('复制失败, 请手动复制')
}
}
function downloadPdf(url, name) {
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = `${name || '劳务'}_${Date.now()}.pdf`
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
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; }
.pdf-preview { margin-top: 12px; }
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.file-preview-pdf { width: 100%; }
.file-preview-img { width: 100%; }
.file-preview-img :deep(.el-image__inner) { width: 100%; height: auto; max-height: 70vh; object-fit: contain; }
.file-preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.file-preview-fallback p { margin-bottom: 16px; }
.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; }
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽 */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
}
</style>