feat: 会议提交剩余时间+解冻 + 会务/劳务材料批量下载 + 多角色人员/账号模块完善

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-25 06:53:18 +08:00
co-authored by Claude
parent 0c882ae846
commit 591a43fe61
126 changed files with 3789 additions and 1334 deletions
+12 -104
View File
@@ -15,37 +15,25 @@
<!-- 个人信息 -->
<el-tab-pane label="个人信息" name="profile">
<div class="card">
<!-- 头像 + 用户信息 -->
<div class="avatar-block">
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
<template v-else>{{ avatarChar }}</template>
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
</div>
<div class="avatar-meta">
<div class="name">{{ form.name || '专家账号' }}</div>
<div class="role">专家账号</div>
<div class="phone-row">
<span class="phone-label">联系电话:</span>
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</div>
</div>
<!-- 基本资料 (2 ) -->
<div class="section-title">基本资料</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="专家姓名" :required="true">
<el-input v-model="form.name" placeholder="请填写姓名" />
</el-form-item>
<el-form-item label="联系电话">
<div class="phone-row">
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</el-form-item>
<el-form-item label="科室" :required="true">
<doctor-dept-select v-model="form.department" value-field="label" placeholder="输入关键词搜索" filterable />
</el-form-item>
<el-form-item label="地区" :required="true">
<area-cascader v-model="form.region" format="string" join-sep="/" placeholder="请选择省/市/区" />
<area-cascader v-model="form.region" format="string" join-sep="/" :max-level="2" placeholder="请选择省/市" />
</el-form-item>
<el-form-item label="证件号码" :required="true">
<el-input v-model="form.idCardNo" placeholder="请填写证件号码" />
@@ -131,9 +119,6 @@
</el-tab-pane>
</el-tabs>
<!-- 隐藏的 avatar 上传 input -->
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
<!-- 修改手机号 dialog (独立于其它字段) -->
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
<el-form :model="phoneForm" label-width="100px">
@@ -159,12 +144,11 @@
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import AreaCascader from '@/components/AreaCascader.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
@@ -209,50 +193,8 @@ const pwdError = ref('')
const saveError = ref('')
const saving = ref(false)
const avatarUrl = ref(store.user?.avatar || '')
const avatarChar = computed(() => (form.name || '专').slice(0, 1))
const avatarUploading = ref(false)
const avatarInput = ref(null)
let snapshot = ref(null)
function onAvatarClick() {
if (avatarUploading.value) return
avatarInput.value?.click()
}
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = '' // 重置以便可重选
if (!file.type.startsWith('image/')) {
return ElMessage.warning('请选择图片文件')
}
if (file.size > 5 * 1024 * 1024) {
return ElMessage.warning('图片大小不能超过 5MB')
}
avatarUploading.value = true
try {
const url = await uploadToOss(file, 'ry8080/avatar/')
await request({
url: '/system/user/profile/avatarUrl',
method: 'put',
data: { avatarUrl: url }
})
avatarUrl.value = url
if (store.user) {
store.user.avatar = url
// 同步 localStorage, 否则刷新后头像丢失
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
ElMessage.success('头像更新成功')
} catch (err) {
ElMessage.error(err?.msg || '头像上传失败')
} finally {
avatarUploading.value = false
}
}
function goHome() {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.goHome?.()
@@ -296,7 +238,6 @@ async function loadUserProfile() {
store.user.phonenumber = u.phonenumber || store.user.phonenumber
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
avatarUrl.value = u.avatar || avatarUrl.value
} catch (e) {
console.warn('loadUserProfile:', e?.msg)
}
@@ -305,16 +246,10 @@ async function loadUserProfile() {
onMounted(async () => {
form.name = store.user?.userName || ''
form.phone = store.user?.phonenumber || ''
avatarUrl.value = store.user?.avatar || ''
await Promise.all([loadUserProfile(), loadExpertProfile()])
snapshot = ref(JSON.parse(JSON.stringify(form)))
})
function onAvatarChange(url) {
avatarUrl.value = url
if (store.user) store.user.avatar = url
}
async function onSave() {
saveError.value = ''
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankRegion', 'bankAddress']
@@ -499,36 +434,9 @@ onUnmounted(() => {
.form-grid :deep(.el-form-item) { margin-bottom: 14px; }
.form-grid :deep(.el-form-item__label) { color: #606266; }
/* 头像区 (含电话显示) */
.avatar-block { display: flex; align-items: center; gap: 24px; padding: 16px 0 24px; border-bottom: 1px dashed #ebeef5; margin-bottom: 24px; }
.avatar-circle {
width: 80px; height: 80px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-size: 28px; font-weight: 600;
cursor: pointer; flex-shrink: 0;
overflow: hidden;
position: relative;
transition: transform 0.15s;
}
.avatar-circle:hover { transform: scale(1.04); }
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-tip {
font-size: 11px; opacity: 0.85; margin-top: 4px;
position: absolute; bottom: 4px; left: 0; right: 0;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
.avatar-meta .role { font-size: 13px; color: #909399; }
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
.phone-label { color: #909399; }
/* 联系电话 (基本资料 form 内显示) */
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; }
.phone-value { color: #262626; font-weight: 500; }
.btn-edit-phone { padding: 0 4px; }
/* 银行信息 + 密码 form 单列 */
.password-form { max-width: 480px; }
+81 -26
View File
@@ -21,10 +21,25 @@
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="日程" width="130" align="center">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-button>
<el-button link :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-button>
</template>
</el-table-column>
<el-table-column label="邀请函" width="130" align="center">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-button>
<el-button link :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-button>
</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 }">
<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.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
</template>
@@ -42,6 +57,23 @@
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">
<iframe v-if="isPdf(previewUrl)" :src="proxyUrl(previewUrl)" class="file-preview-iframe"></iframe>
<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">
@@ -57,18 +89,12 @@
<!-- 查看劳务 dialog: 已签状态显示, PDF iframe 预览 -->
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
<el-descriptions :column="1" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="会议名称">{{ detail.meetingName }}</el-descriptions-item>
<el-descriptions-item label="签署状态">已签署</el-descriptions-item>
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="pdf-preview">
<iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
<iframe v-if="detail.attendeeLaborProtocol" :src="proxyUrl(detail.attendeeLaborProtocol)" style="width:100%;height:70vh;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.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
<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>
@@ -77,7 +103,7 @@
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
@@ -88,6 +114,9 @@ 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('')
@@ -119,27 +148,47 @@ function onViewLabor(row) {
laborOpen.value = true
}
function onView(row) {
detail.value = row
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
ElMessageBox.alert(
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
'会议详情',
{ confirmButtonText: '关闭' }
)
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
function isPdf(url) {
if (!url) return false
const path = url.split('?')[0].toLowerCase()
return /\.pdf$/.test(path)
}
function onDownloadFile(row) {
if (!row.invitationUrl) { ElMessage.warning('该会议暂无邀请函附件'); return }
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 = row.invitationUrl
a.download = `${row.meetingName || '会议'}_邀请函.pdf`
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 || ''
@@ -206,6 +255,12 @@ 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; }
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.file-preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
.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; }
-3
View File
@@ -9,8 +9,6 @@
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="filter-tip">*下载项目公示的红头文件</div>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
@@ -139,7 +137,6 @@ load()
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.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; }
.filter-tip { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
.status { font-size: 13px; color: #595959; }
: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); }
+18 -2
View File
@@ -45,9 +45,12 @@
</el-table-column>
<el-table-column prop="planCategory" label="项目类别" width="120" />
<el-table-column prop="projectForm" label="形式" width="100" />
<el-table-column label="设计文件" width="100">
<el-table-column label="设计文件" width="140">
<template #default="{ row }">
<el-link v-if="row.designFileUrl" type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
<template v-if="row.designFileUrl">
<el-button link type="primary" @click="openPreview(row.designFileUrl, '设计文件')">查看</el-button>
<el-link type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
</template>
<span v-else>-</span>
</template>
</el-table-column>
@@ -77,6 +80,7 @@
style="margin-top: 12px; text-align: right;"
/>
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
</div>
</template>
@@ -88,6 +92,7 @@ import request from '@/utils/request'
import { bizList, bizUpdate } from '@/api/public'
import DictSelect from '@/components/DictSelect.vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
import Preview from '@/components/Preview.vue'
const router = useRouter()
const route = useRoute()
@@ -107,6 +112,11 @@ const rows = ref([])
const loading = ref(false)
const selected = ref([])
// 设计文件预览
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('附件预览')
// 项目方向 options (复用 manager 侧接口)
const planDirectionOptions = ref([])
async function loadPlanDirectionOptions() {
@@ -184,6 +194,12 @@ async function onBatch() {
load()
}
function openPreview(url, title) {
previewUrl.value = url
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
onMounted(() => {
loadPlanDirectionOptions()
load()