feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology: - BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档 - ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配 - application-prod/test.yml: ecology 只读库数据源 会议邀请函/分享: - InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享 - PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页 参会人/执行意向: - BizMeetingAttendee*: 参会人模块增强 - BizExecutionIntent*: 执行意向增强 签到/劳务/导入: - BizSignServiceImpl + SignFill.vue: 签到劳务 - FieldMismatch/ImportMismatch: 导入校验 VO 公示/会议详情: - PublicityDetail.vue / MeetingDetail.vue 等前端增强 配置: - test 短信 mock + 供应商/招标接口切生产域名
This commit is contained in:
@@ -30,4 +30,14 @@ export function updateHandsign(id, handsign) {
|
||||
*/
|
||||
export function updateLaborProtocol(id, laborProtocol) {
|
||||
return request({ url: `/business/meetingAttendee/${id}/laborProtocol`, method: 'put', data: { laborProtocol } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议邀请函详情 (参会人/医生点开站内信邀请链接后查看)
|
||||
* @param {number|string} attendeeId 参会人中间表主键
|
||||
* 返回 { attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl }
|
||||
*/
|
||||
export function getMeetingInvitation(attendeeId) {
|
||||
// __silentError: GET 拦截器默认兜底 toast, 这里由调用方 catch 用后端具体 msg 弹, 避免双 toast
|
||||
return request({ url: `/business/meetingAttendee/invitation/${attendeeId}`, method: 'get', __silentError: true })
|
||||
}
|
||||
@@ -170,6 +170,16 @@ export function exportPublicitySupportIntent(params) {
|
||||
return request.post('/business/publicitySupportIntent/export', null, { params, responseType: 'blob' })
|
||||
}
|
||||
|
||||
// ========== ecology 项目拉取 (admin/projects/new 按项目编号自动回填) ==========
|
||||
// 项目编号模糊搜索下拉: 返回 [{projectNo, projectName}]
|
||||
export function searchEcologyProjects(query) {
|
||||
return request.get('/business/ecology/project/search', { params: query })
|
||||
}
|
||||
// 按项目编号拉取完整信息 + 负责人静默建档; opts 可传 { __silentError: true } 让拦截器不弹错
|
||||
export function pullEcologyProject(projectNo, opts) {
|
||||
return request.get('/business/ecology/project/byNo', { params: { projectNo }, ...(opts || {}) })
|
||||
}
|
||||
|
||||
// ========== 项目导出 (manager/Projects.vue, 跟随当前筛选) ==========
|
||||
export function exportProjectList(params) {
|
||||
return request.post('/business/project/export', null, { params, responseType: 'blob' })
|
||||
|
||||
@@ -84,11 +84,14 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
limit: { type: Number, default: 5 },
|
||||
showCategory: { type: Boolean, default: false },
|
||||
@@ -149,8 +152,34 @@ function extractLink(content) {
|
||||
return m[0].replace(/[。,、;:,.!?;:))\]]+$/, '')
|
||||
}
|
||||
|
||||
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
|
||||
// bizType → 路由映射: 这类通知点击后直接进入对应页面 (带 layout), 不弹 dialog 二次点击.
|
||||
// 后端约定: bizId 存路由参数 (如会议邀请函 bizId = attendeeId). 后续新类型在此扩展.
|
||||
const BIZ_NAV = {
|
||||
meetingInvitation: (bizId) => `/doctor/invitation/${bizId}`
|
||||
}
|
||||
|
||||
function navigateTarget(n) {
|
||||
if (!n.bizType || n.bizId == null) return ''
|
||||
const fn = BIZ_NAV[n.bizType]
|
||||
return fn ? fn(n.bizId) : ''
|
||||
}
|
||||
|
||||
/** 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
|
||||
function markRead(n) {
|
||||
if (!n.read) {
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
/** 点列表项: 特定类型直接跳页面; 其余打开详情 dialog */
|
||||
function openDetail(n) {
|
||||
const target = navigateTarget(n)
|
||||
if (target) {
|
||||
markRead(n)
|
||||
router.push(target)
|
||||
return
|
||||
}
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
const link = extractLink(n.content)
|
||||
@@ -161,10 +190,7 @@ function openDetail(n) {
|
||||
.then(url => { detailQrUrl.value = url })
|
||||
.catch(() => { detailQrUrl.value = '' })
|
||||
}
|
||||
if (!n.read) {
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
markRead(n)
|
||||
}
|
||||
|
||||
/** 复制消息正文中的链接 */
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div ref="wrapRef" class="pdf-viewer">
|
||||
<div v-if="status === 'loading'" class="pdf-tip">PDF 加载中…</div>
|
||||
<div v-else-if="status === 'error'" class="pdf-tip pdf-error">
|
||||
<p>PDF 加载失败</p>
|
||||
<p class="hint">{{ errorMsg }}</p>
|
||||
<el-button size="small" type="primary" style="margin-top: 12px" @click="render">重新加载</el-button>
|
||||
</div>
|
||||
<div v-show="status === 'done'" ref="hostRef" class="pdf-host"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
const props = defineProps({
|
||||
url: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const wrapRef = ref(null)
|
||||
const hostRef = ref(null)
|
||||
const status = ref('') // '' | 'loading' | 'done' | 'error'
|
||||
const errorMsg = ref('')
|
||||
|
||||
let renderToken = 0
|
||||
let pdfDoc = null
|
||||
|
||||
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
|
||||
// PDF.js 需要干净的字节流, 走 /common/oss/proxy 后端代理 (重写为 inline), 不带 #toolbar fragment.
|
||||
function pdfProxyUrl(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)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const url = pdfProxyUrl(props.url)
|
||||
if (!url) return
|
||||
const token = ++renderToken
|
||||
// 释放上一份文档
|
||||
if (pdfDoc) { try { await pdfDoc.destroy() } catch (e) { /* ignore */ } pdfDoc = null }
|
||||
status.value = 'loading'
|
||||
errorMsg.value = ''
|
||||
const host = hostRef.value
|
||||
if (host) host.innerHTML = ''
|
||||
try {
|
||||
const doc = await pdfjsLib.getDocument({ url }).promise
|
||||
if (token !== renderToken) { await doc.destroy(); return }
|
||||
pdfDoc = doc
|
||||
await nextTick() // 等容器完成布局再量宽度 (dialog 打开动画 / v-show 切换后)
|
||||
const wrapWidth = wrapRef.value?.clientWidth || (window.innerWidth - 32) || 800
|
||||
const first = await doc.getPage(1)
|
||||
const base = first.getViewport({ scale: 1 })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2) // 高清屏放大绘制, 上限 2 避免内存过大
|
||||
const scale = (wrapWidth / base.width) * dpr
|
||||
let shown = false
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
if (token !== renderToken) break
|
||||
const page = await doc.getPage(i)
|
||||
const viewport = page.getViewport({ scale })
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(viewport.width)
|
||||
canvas.height = Math.floor(viewport.height)
|
||||
// 动态创建的 canvas 不受 scoped CSS 作用, 内联设置样式 (display:block + margin auto 居中)
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.margin = '8px auto'
|
||||
canvas.style.background = '#fff'
|
||||
canvas.style.boxShadow = '0 1px 4px rgba(0,0,0,.15)'
|
||||
canvas.style.width = Math.floor(viewport.width / dpr) + 'px'
|
||||
canvas.style.height = Math.floor(viewport.height / dpr) + 'px'
|
||||
host.appendChild(canvas)
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
|
||||
// 第一页渲染完立即显示, 后续页后台追加, 避免等全部页都画完才可见
|
||||
if (!shown && token === renderToken) {
|
||||
shown = true
|
||||
status.value = 'done'
|
||||
}
|
||||
}
|
||||
if (token === renderToken) status.value = 'done'
|
||||
} catch (e) {
|
||||
console.error('[PdfViewer] render failed', e)
|
||||
if (token === renderToken) {
|
||||
status.value = 'error'
|
||||
errorMsg.value = e?.message || 'PDF 加载失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.url, (url) => {
|
||||
if (url) {
|
||||
nextTick(() => render())
|
||||
} else {
|
||||
status.value = ''
|
||||
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
renderToken++ // 让进行中的渲染尽早中断
|
||||
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-viewer {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 72vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: #f2f3f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pdf-host { width: 100%; padding: 8px 0; }
|
||||
.pdf-tip { padding: 60px 20px; text-align: center; color: #909399; font-size: 14px; }
|
||||
.pdf-error p { margin: 6px 0 0; }
|
||||
.pdf-error .hint { font-size: 12px; color: #c0c4cc; }
|
||||
|
||||
/* H5: 移动端 dialog 全屏化后, 容器高度跟随可用视口, 避免套两层滚动条 */
|
||||
@media (max-width: 640px) {
|
||||
.pdf-viewer { max-height: calc(100vh - 200px); border-radius: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,8 @@
|
||||
<div v-else-if="primary" class="preview-box">
|
||||
<!-- 图片 -->
|
||||
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
|
||||
<!-- PDF:iframe 预览,浏览器内置 viewer -->
|
||||
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(primary)" class="preview-iframe" />
|
||||
<!-- PDF:PDF.js canvas 渲染(微信/移动端浏览器无 Chrome 内置 PDF viewer) -->
|
||||
<PdfViewer v-else-if="kind === 'pdf'" :url="primary" class="preview-pdf" />
|
||||
<!-- 其它(doc/xls/zip 等):给下载链接 -->
|
||||
<div v-else class="preview-fallback">
|
||||
<el-icon class="fallback-icon"><Document /></el-icon>
|
||||
@@ -37,6 +37,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PdfViewer from '@/components/PdfViewer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
@@ -68,9 +69,8 @@ function kindOf(u) {
|
||||
}
|
||||
const kind = computed(() => kindOf(primary.value))
|
||||
|
||||
// OSS bucket 设置了 Content-Disposition: attachment, iframe 直接访问会被强制下载 (空白+下载)
|
||||
// PDF 走 /common/oss/proxy 后端代理重写为 inline (与 publicity/{id} / doctor/Meetings / manager/Plans 同技术)
|
||||
// #toolbar=0 隐藏 Chrome PDF viewer 工具栏, #zoom=page-width 强制 PDF 撑满 iframe 宽度
|
||||
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
|
||||
// 「在新窗口打开」走 /common/oss/proxy 后端代理重写为 inline (页内 PDF 预览已改用 PdfViewer/pdf.js)
|
||||
function proxyUrl(url) {
|
||||
if (!url) return url
|
||||
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||
@@ -97,7 +97,7 @@ async function copyUrl() {
|
||||
<style scoped>
|
||||
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
||||
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||
.preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.preview-pdf { width: 100%; }
|
||||
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
||||
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
|
||||
.preview-fallback p { margin: 12px 0 24px; }
|
||||
|
||||
@@ -27,6 +27,9 @@ const routes = [
|
||||
{ path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
|
||||
{ path: 'projects/detail/:projectId', name: 'admin-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||
{ path: 'projects/new', name: 'admin-projects-new', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '新建项目' } },
|
||||
{ path: 'projects/edit/:projectId', name: 'admin-projects-edit', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '编辑项目' } },
|
||||
{ path: 'projects/assign', name: 'admin-projects-assign', component: () => import('@/views/manager/ManagerProjectsAssign.vue'), meta: { title: '项目分配' } },
|
||||
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } },
|
||||
{ path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
|
||||
{ path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
|
||||
@@ -107,7 +110,8 @@ const routes = [
|
||||
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } },
|
||||
{ path: 'sign-fill', name: 'doctor-sign-fill', component: () => import('@/views/doctor/SignFill.vue'), meta: { title: '填写劳务信息', role: 'doctor', hideMenu: true } },
|
||||
{ path: 'sign-contract', name: 'doctor-sign-contract', component: () => import('@/views/doctor/SignContract.vue'), meta: { title: '签署劳务协议', role: 'doctor', hideMenu: true } },
|
||||
{ path: 'sign-success', name: 'doctor-sign-success', component: () => import('@/views/doctor/SignSuccess.vue'), meta: { title: '签字成功', role: 'doctor', hideMenu: true } }
|
||||
{ path: 'sign-success', name: 'doctor-sign-success', component: () => import('@/views/doctor/SignSuccess.vue'), meta: { title: '签字成功', role: 'doctor', hideMenu: true } },
|
||||
{ path: 'invitation/:attendeeId', name: 'doctor-meeting-invitation', component: () => import('@/views/doctor/MeetingInvitation.vue'), meta: { title: '会议邀请函', role: 'doctor' } }
|
||||
]
|
||||
},
|
||||
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
|
||||
|
||||
@@ -32,6 +32,9 @@ function serviceState(row) {
|
||||
/**
|
||||
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
|
||||
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态).
|
||||
* 注意: "已执行"此处含时间兜底 (executionPhase: is_executed=1 或 now≥end_time), 与后端纯事实镜像略有差异 —
|
||||
* 后端由调度器 markExecuted 落 is_executed=1, 前端在调度器未翻之前提前视作 RUNNING, 仅为让详情时间轴
|
||||
* 与列表"已执行"文案/绿点一致, 不参与数据/筛选.
|
||||
*/
|
||||
export function derivePhysicalStage(row) {
|
||||
if (!row) return 'NOT_STARTED'
|
||||
@@ -43,7 +46,9 @@ export function derivePhysicalStage(row) {
|
||||
if (s === 2) return 'AWAITING_COMPLIANCE'
|
||||
if (s === 3) return 'AWAITING_SUPERVISION'
|
||||
if (s === 4) return 'AWAITING_SETTLEMENT'
|
||||
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
|
||||
// 已执行 = is_executed=1 或 时间已过 end_time (与 executionPhase/列表展示对齐):
|
||||
// 否则详情时间轴"会议已执行"绿点只在调度器 markExecuted 翻 is_executed 后才变绿, 列表却已显示"已执行" → 两边错位.
|
||||
return executionPhase(row) === 2 ? 'RUNNING' : 'NOT_STARTED'
|
||||
}
|
||||
|
||||
/** manager 优先级: C0=0, R=1, N=2, C1=3, A=4 (越小越优先). C0=有活最优先; 无活时按最早环节优先(R<N<C1<A). */
|
||||
|
||||
@@ -182,7 +182,7 @@ const userStore = useUserStore()
|
||||
|
||||
const form = reactive({ username: '', password: '', code: '', uuid: '', role: 'admin' })
|
||||
const captchaImg = ref('')
|
||||
const captchaEnabled = ref(true)
|
||||
const captchaEnabled = ref(false)
|
||||
const loading = ref(false)
|
||||
const showRoleModal = ref(false)
|
||||
const registerUserType = ref('')
|
||||
@@ -558,9 +558,10 @@ body { margin: 0; padding: 0; }
|
||||
|
||||
/* 主体 */
|
||||
.login-page .main-content{
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
margin: auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 60px 80px;
|
||||
gap: 80px;
|
||||
@@ -569,6 +570,7 @@ body { margin: 0; padding: 0; }
|
||||
.login-page .banner-section{
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.login-page .banner-inner{
|
||||
@@ -577,7 +579,10 @@ body { margin: 0; padding: 0; }
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
height: 520px;
|
||||
flex: 1;
|
||||
/* 稳定基准高度: banner 高度不再只靠 align-items:stretch 兜, 给个确定 min-height,
|
||||
避免刷新首帧 stretch 未定高时 banner 被拉得过大、等图片/验证码加载完重排才恢复 */
|
||||
min-height: 440px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
@@ -595,6 +600,8 @@ body { margin: 0; padding: 0; }
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.1);
|
||||
/* 与 banner 同高下限, 让左右两栏高度确定、贴合 (stretch 仍会在内容更高时同步拉高) */
|
||||
min-height: 440px;
|
||||
}
|
||||
|
||||
.login-page .login-title{
|
||||
@@ -891,8 +898,8 @@ body { margin: 0; padding: 0; }
|
||||
.footer { padding: 16px 20px; }
|
||||
}
|
||||
|
||||
/* === 用户要求: 两栏在 main-content 中居中, 卡片高度自适应 === */
|
||||
.login-page .main-content { align-items: center; }
|
||||
/* === 用户要求: 左 banner 高度贴齐右侧卡片 (改左侧, 不动卡片) === */
|
||||
.login-page .main-content { align-items: stretch; }
|
||||
|
||||
/* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */
|
||||
.modal-overlay {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="page-card invitation-page">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">首页 / 会议邀请函</div>
|
||||
|
||||
<!-- 称谓 -->
|
||||
<p class="salutation">尊敬的 {{ attendeeName || '专家' }}:</p>
|
||||
|
||||
<!-- 正文 -->
|
||||
<p class="body-text">
|
||||
您好!由衷感谢您长期以来对北京整合医学学会工作的鼎力支持。您报名参与的
|
||||
<span class="highlight">【{{ projectName || '(项目待定)' }}】</span>,现定于
|
||||
<span class="highlight">【{{ meetingDate || '(会议日期待定)' }}】</span>正式举办。我们诚挚邀请您担任
|
||||
<span class="highlight">【{{ laborForm || '(角色待定)' }}】</span>。
|
||||
</p>
|
||||
|
||||
<!-- 落款 -->
|
||||
<div class="signature">
|
||||
<div>北京整合医学学会</div>
|
||||
<div>{{ issueDate }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件:日程海报 + 邀请函 缩略图 -->
|
||||
<div class="attach-row">
|
||||
<div class="attach-card">
|
||||
<div class="attach-label">日程海报</div>
|
||||
<div v-if="scheduleUrl" class="attach-thumb" @click="preview(scheduleUrl, '日程海报')">
|
||||
<img v-if="scheduleThumb" :src="scheduleThumb" alt="日程海报" />
|
||||
<div v-else class="thumb-loading">缩略图生成中…</div>
|
||||
</div>
|
||||
<div v-else class="attach-missing">未上传,请联系会务方</div>
|
||||
<div v-if="scheduleUrl" class="attach-actions">
|
||||
<el-button size="small" @click="preview(scheduleUrl, '日程海报')">预览</el-button>
|
||||
<el-button size="small" type="primary" @click="download(scheduleUrl, '日程海报')">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attach-card">
|
||||
<div class="attach-label">邀请函</div>
|
||||
<div v-if="invitationUrl" class="attach-thumb" @click="preview(invitationUrl, '邀请函')">
|
||||
<img v-if="invitationThumb" :src="invitationThumb" alt="邀请函" />
|
||||
<div v-else class="thumb-loading">缩略图生成中…</div>
|
||||
</div>
|
||||
<div v-else class="attach-missing">未上传,请联系会务方</div>
|
||||
<div v-if="invitationUrl" class="attach-actions">
|
||||
<el-button size="small" @click="preview(invitationUrl, '邀请函')">预览</el-button>
|
||||
<el-button size="small" type="primary" @click="download(invitationUrl, '邀请函')">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览 dialog (PDF 走 PDF.js canvas 渲染, 图片走 el-image) -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewOpen = false">关闭</el-button>
|
||||
<el-button v-if="previewUrl" type="primary" @click="download(previewUrl, previewTitle)">下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getMeetingInvitation } from '@/api/business/meetingAttendee'
|
||||
import PdfViewer from '@/components/PdfViewer.vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const attendeeName = ref('')
|
||||
const laborForm = ref('')
|
||||
const projectName = ref('')
|
||||
const meetingDate = ref('')
|
||||
const issueDate = ref('')
|
||||
const scheduleUrl = ref('')
|
||||
const invitationUrl = ref('')
|
||||
const scheduleThumb = ref('')
|
||||
const invitationThumb = ref('')
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
|
||||
// 缩略图渲染令牌: 路由切换/卸载时作废, 避免异步回写串页
|
||||
let renderToken = 0
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0') }
|
||||
function isPdf(url) {
|
||||
if (!url) return false
|
||||
return /\.pdf$/.test(url.split('?')[0].toLowerCase())
|
||||
}
|
||||
function isImg(url) {
|
||||
if (!url) return false
|
||||
return /\.(png|jpg|jpeg|gif|webp)$/.test(url.split('?')[0].toLowerCase())
|
||||
}
|
||||
// PDF.js 专用代理 URL: 不带 #toolbar fragment (PDF.js 需要干净字节流)
|
||||
function pdfProxyUrl(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)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function fmtMeetingDate(v) {
|
||||
if (!v) return ''
|
||||
const d = new Date(String(v).replace(/-/g, '/'))
|
||||
if (isNaN(d.getTime())) return v
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
function fmtCnDate(d) {
|
||||
if (!d) return ''
|
||||
return `${d.getFullYear()} 年 ${pad(d.getMonth() + 1)} 月 ${pad(d.getDate())} 日`
|
||||
}
|
||||
|
||||
/** PDF 首帧转缩略图 (canvas → dataURL); 图片直接返回原 url */
|
||||
async function renderThumb(url) {
|
||||
if (isImg(url)) return url
|
||||
if (!isPdf(url)) return ''
|
||||
const doc = await pdfjsLib.getDocument({ url: pdfProxyUrl(url) }).promise
|
||||
const page = await doc.getPage(1)
|
||||
const base = page.getViewport({ scale: 1 })
|
||||
const scale = Math.min(280 / base.width, 1.6) // 缩略图目标宽 ~280px
|
||||
const viewport = page.getViewport({ scale })
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(viewport.width)
|
||||
canvas.height = Math.floor(viewport.height)
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
await doc.destroy()
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
async function loadThumbs() {
|
||||
const token = renderToken
|
||||
if (scheduleUrl.value) {
|
||||
scheduleThumb.value = await renderThumb(scheduleUrl.value).catch(() => '')
|
||||
}
|
||||
if (token !== renderToken) return
|
||||
if (invitationUrl.value) {
|
||||
invitationThumb.value = await renderThumb(invitationUrl.value).catch(() => '')
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await getMeetingInvitation(route.params.attendeeId)
|
||||
const d = resp?.data || {}
|
||||
attendeeName.value = d.attendeeName || ''
|
||||
laborForm.value = d.laborForm || ''
|
||||
projectName.value = d.projectName || ''
|
||||
meetingDate.value = fmtMeetingDate(d.meetingTime)
|
||||
issueDate.value = fmtCnDate(new Date())
|
||||
scheduleUrl.value = d.scheduleUrl || ''
|
||||
invitationUrl.value = d.invitationUrl || ''
|
||||
scheduleThumb.value = ''
|
||||
invitationThumb.value = ''
|
||||
await loadThumbs()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '加载邀请函失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function preview(url, name) {
|
||||
if (!url) { ElMessage.warning('该附件尚未上传'); return }
|
||||
previewUrl.value = url
|
||||
previewTitle.value = name
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
function download(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)
|
||||
}
|
||||
|
||||
onMounted(() => { renderToken++; load() })
|
||||
onBeforeUnmount(() => { renderToken++ })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整页面板 (与 doctor/SubmissionDetail.vue 一致: 单卡片, max-width 1200px, 不套小框) */
|
||||
.invitation-page { max-width: 1200px; padding: 16px 20px; }
|
||||
.salutation { font-size: 16px; color: #1a1a1a; margin: 20px 0 18px; }
|
||||
.body-text { font-size: 15px; color: #303133; margin: 0 0 28px; line-height: 1.9; text-indent: 2em; }
|
||||
.highlight { color: var(--brand-primary); font-weight: 600; }
|
||||
.signature { text-align: right; font-size: 15px; color: #303133; margin-bottom: 40px; }
|
||||
.signature div + div { margin-top: 4px; }
|
||||
|
||||
.attach-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 24px;
|
||||
}
|
||||
.attach-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.attach-label { font-size: 14px; color: #303133; font-weight: 600; }
|
||||
.attach-thumb {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.thumb-loading { font-size: 12px; color: #909399; }
|
||||
.attach-missing {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fdf6ec;
|
||||
border: 1px dashed #f3d19e;
|
||||
border-radius: 6px;
|
||||
color: #e6a23c;
|
||||
font-size: 13px;
|
||||
}
|
||||
.attach-actions { display: flex; gap: 8px; }
|
||||
|
||||
.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; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.invitation-page { padding: 12px 14px; }
|
||||
.attach-row { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
@@ -64,7 +64,7 @@
|
||||
<!-- 日程 / 邀请函 预览 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>
|
||||
<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>
|
||||
@@ -94,7 +94,7 @@
|
||||
<!-- 查看劳务 dialog: 已签状态显示, 含 PDF iframe 预览 -->
|
||||
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
|
||||
<div class="pdf-preview">
|
||||
<iframe v-if="detail.attendeeLaborProtocol" :src="proxyUrl(detail.attendeeLaborProtocol)" style="width:100%;height:70vh;border:1px solid #ebeef5"></iframe>
|
||||
<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>
|
||||
@@ -111,6 +111,7 @@ 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'
|
||||
|
||||
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
|
||||
@@ -259,7 +260,7 @@ load()
|
||||
.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-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.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; }
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<el-button type="primary" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div v-else v-loading="loading">
|
||||
<!-- 已签署: 再次打开签署链接, 直接展示劳务协议 PDF (复用 publicity 的 OSS 代理预览) -->
|
||||
<!-- 已签署: 再次打开签署链接, 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer) -->
|
||||
<div v-if="signed" class="signed-pdf">
|
||||
<div class="sign-header">
|
||||
<div class="sign-header-title">{{ meetingName || '劳务协议' }}</div>
|
||||
<div v-if="periodDisplay" class="sign-header-period">期数:{{ periodDisplay }}</div>
|
||||
</div>
|
||||
<iframe v-if="laborPdfUrl" :src="proxyUrl(laborPdfUrl)" class="signed-pdf-frame"></iframe>
|
||||
<PdfViewer v-if="laborPdfUrl" :url="laborPdfUrl" class="signed-pdf-viewer" />
|
||||
<div v-else class="signed-pdf-empty">协议已签署,暂无可展示的 PDF</div>
|
||||
<div class="signed-pdf-actions">
|
||||
<el-button v-if="laborPdfUrl" type="primary" @click="downloadPdf">下载劳务协议</el-button>
|
||||
@@ -108,6 +108,7 @@ import { getInfo } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
import PdfViewer from '@/components/PdfViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -247,16 +248,10 @@ async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSignInfo(attendeeId.value)
|
||||
// 已签署: 再次打开签署链接 → 整页跳转到 OSS proxy 的 PDF 地址 (全屏显示, 不用 iframe 内嵌)
|
||||
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
|
||||
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
|
||||
const url = data.laborProtocol
|
||||
if (url) {
|
||||
window.location.href = proxyUrl(url)
|
||||
return
|
||||
}
|
||||
// 无 PDF URL 兜底: 仍留在本页显示"已签署但无可展示 PDF"
|
||||
signed.value = true
|
||||
laborPdfUrl.value = ''
|
||||
laborPdfUrl.value = data.laborProtocol || ''
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
@@ -357,14 +352,6 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// OSS 代理预览 (同 publicity / doctor/Meetings.vue): 重写 Content-Disposition 为 inline, 隐藏工具栏撑满宽度
|
||||
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 downloadPdf() {
|
||||
if (!laborPdfUrl.value) return
|
||||
const a = document.createElement('a')
|
||||
@@ -391,9 +378,9 @@ onMounted(init)
|
||||
.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; }
|
||||
|
||||
/* 已签署: 直接展示 PDF (宽度 100%, 高度不限制) */
|
||||
/* 已签署: 直接展示 PDF (PdfViewer 内部自适应宽度/高度 + 滚动) */
|
||||
.signed-pdf { display: flex; flex-direction: column; }
|
||||
.signed-pdf-frame { width: 100%; height: 424vw; border: 0; }
|
||||
.signed-pdf-viewer { width: 100%; }
|
||||
.signed-pdf-empty { padding: 48px 16px; text-align: center; color: #909399; }
|
||||
.signed-pdf-actions { margin-top: 16px; display: flex; gap: 12px; }
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<!-- 操作按钮 — 从 el-form 提出来, 避免 Element Plus form-item 容器影响按钮布局 -->
|
||||
<div class="detail-actions">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button v-if="showSaveAndSubmit" type="primary" :loading="submitting" @click="onSaveAndSubmit">保存并提交</el-button>
|
||||
<el-button @click="confirmCancel">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,8 +89,11 @@ const base = computed(() => `/${route.meta.role || 'doctor'}`)
|
||||
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const isEdit = !!route.params.planId
|
||||
// 保存并提交按钮: 仅 专家(doctor)/执行方(executor) 新建时显示 (点击直接提交审核)
|
||||
const showSaveAndSubmit = computed(() => !isEdit && ['doctor', 'executor'].includes(route.meta.role))
|
||||
|
||||
// 项目方向 options (复用 manager 侧接口)
|
||||
const planDirectionOptions = ref([])
|
||||
@@ -160,19 +164,20 @@ function confirmCancel() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
async function doSave(submit = false) {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (e) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const loadingRef = submit ? submitting : saving
|
||||
loadingRef.value = true
|
||||
try {
|
||||
let payload = { ...form }
|
||||
if (!isEdit) {
|
||||
// 新建: 强制 status='0' (未提交), submitter 后端兜底
|
||||
payload.status = '0'
|
||||
// 新建: 默认 status='0' (未提交); "保存并提交" 直接 status='1' (待审核), submitter 后端兜底
|
||||
payload.status = submit ? '1' : '0'
|
||||
payload.submitterId = userStore.user?.userId || null
|
||||
} else if (!payload.status) {
|
||||
payload.status = '0'
|
||||
@@ -182,16 +187,19 @@ async function onSave() {
|
||||
} else {
|
||||
await bizAdd('projectPlan', payload, { __silentError: true })
|
||||
}
|
||||
ElMessage.success(isEdit ? '修改成功' : '新建成功')
|
||||
ElMessage.success(submit ? '已保存并提交审核' : (isEdit ? '修改成功' : '新建成功'))
|
||||
goBack()
|
||||
} catch (e) {
|
||||
console.error('[submission-new] save failed', e)
|
||||
ElMessage.error(e?.msg || (isEdit ? '修改失败' : '保存失败'))
|
||||
ElMessage.error(e?.msg || (submit ? '保存并提交失败' : (isEdit ? '修改失败' : '保存失败')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
loadingRef.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() { return doSave(false) }
|
||||
async function onSaveAndSubmit() { return doSave(true) }
|
||||
|
||||
onMounted(() => {
|
||||
loadPlanDirectionOptions()
|
||||
if (isEdit) loadDetail()
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" min-width="100" />
|
||||
<el-table-column prop="position" label="职务" min-width="100" />
|
||||
<!-- 管理员 switch: 仅 admin 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
|
||||
<el-table-column v-if="isAdmin" label="管理员" width="90" align="center">
|
||||
<!-- 管理员 switch: admin/manager 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
|
||||
<el-table-column v-if="isAdmin || isManager" label="管理员" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.accountType === 'MAIN'"
|
||||
@@ -135,6 +135,7 @@ const router = useRouter()
|
||||
|
||||
// 角色判定 + 路由基础路径 (admin/manager 共用页面, 但跳回各自 list)
|
||||
const isAdmin = computed(() => route.path.startsWith('/admin/'))
|
||||
const isManager = computed(() => route.path.startsWith('/manager/'))
|
||||
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
|
||||
|
||||
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
|
||||
|
||||
@@ -52,7 +52,12 @@
|
||||
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}</template>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onFilterByProject(row)">
|
||||
{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
|
||||
@@ -153,6 +158,17 @@ function onCopy(row) {
|
||||
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
|
||||
}
|
||||
|
||||
// ========== 点击「总期数」: 过滤到该项目下本执行方的会议 ==========
|
||||
// projectId 精确过滤为准; 后端 list 已按执行方叠加数据范围 (execution_unit_id / create_by), 前端无需分支.
|
||||
// 显示值 assignedSessions = 分配给本执行方的场次, 但点击仍是"按项目筛选" (语义不变).
|
||||
function onFilterByProject(row) {
|
||||
if (row.projectId == null) return
|
||||
q.value.projectId = row.projectId
|
||||
q.value.projectNo = row.projectNo || '' // 回填「项目编号」输入框做视觉反馈 (projectId 精确过滤为准)
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -365,9 +365,10 @@ function goBack() {
|
||||
if (role === 'sponsor') router.push('/sponsor/my-projects')
|
||||
else if (role === 'executor') router.push('/executor/projects')
|
||||
else if (role === 'admin') router.push('/admin/projects')
|
||||
else if (role === 'leader') router.push('/leader/projects')
|
||||
else router.push('/manager/projects')
|
||||
}
|
||||
function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) }
|
||||
function goEdit() { router.push(`/${roleType.value}/projects/edit/${projectId.value}`) }
|
||||
|
||||
// ===================== 预览 =====================
|
||||
function openPreview(url, title) {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="new-card-title">项目分配</div>
|
||||
|
||||
<el-form-item label="支持单位">
|
||||
<el-select v-model="assignForm.sponsorOrgId" placeholder="请选择支持单位" filterable
|
||||
<el-select v-model="assignForm.sponsorOrgId" placeholder="输入支持单位名称搜索" filterable
|
||||
:filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
|
||||
<el-option v-for="u in sponsorOrgOptions" :key="u.orgId"
|
||||
:label="u.orgName + (isOrgDisabled(u) ? '(已禁用)' : '')" :value="u.orgId"
|
||||
@@ -76,7 +76,7 @@
|
||||
<el-select v-model="row.executionUnitId" filterable
|
||||
:filter-method="q => searchExecutors(q, row)"
|
||||
:loading="row._loading"
|
||||
placeholder="搜索执行单位"
|
||||
placeholder="输入执行单位名称搜索"
|
||||
style="width:100%"
|
||||
@change="v => onExecUserPick(row, v)">
|
||||
<el-option v-for="u in execOptions(row)" :key="u.orgId"
|
||||
@@ -243,11 +243,15 @@ function addExecRow() {
|
||||
// 加载支持方 (sponsor 角色) - 按公司名查, JOIN biz_org + sys_user
|
||||
async function loadSponsorOrgs(query) {
|
||||
if (_supporterTimer) clearTimeout(_supporterTimer)
|
||||
const kw = (query || '').trim()
|
||||
// 空关键词不拉数据: 下拉清空, 只有用户输入时才边输边搜 (不预加载全量支持单位)
|
||||
if (!kw) {
|
||||
sponsorOrgOptions.value = []
|
||||
return
|
||||
}
|
||||
_supporterTimer = setTimeout(async () => {
|
||||
try {
|
||||
const params = {}
|
||||
if (query) params.orgName = query
|
||||
const r = await listSponsorOrgs(params)
|
||||
const r = await listSponsorOrgs({ orgName: kw })
|
||||
sponsorOrgOptions.value = r.data || []
|
||||
// 已选项不在结果里时按 orgId 单独拉取并加首位
|
||||
if (assignForm.sponsorOrgId && !sponsorOrgOptions.value.find(u => u.orgId === assignForm.sponsorOrgId)) {
|
||||
@@ -260,6 +264,17 @@ async function loadSponsorOrgs(query) {
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
// 仅按 orgId 拉回已选支持单位的 label (初始化回显用, 不拉全量列表)
|
||||
async function loadSelectedSponsorOption() {
|
||||
if (!assignForm.sponsorOrgId) return
|
||||
try {
|
||||
const r2 = await listSponsorOrgs({ orgId: assignForm.sponsorOrgId })
|
||||
const sel = (r2.data || []).find(u => u.orgId === assignForm.sponsorOrgId)
|
||||
if (sel) sponsorOrgOptions.value = [sel]
|
||||
} catch (e) {
|
||||
sponsorOrgOptions.value = []
|
||||
}
|
||||
}
|
||||
function searchSponsorOrgs(q) { loadSponsorOrgs(q) }
|
||||
|
||||
// 选中支持方后回填 user_name (sponsorAdminUserName 后端已改派生, 此处仅缓存展示用)
|
||||
@@ -309,12 +324,17 @@ async function loadSelectedExecOption(row) {
|
||||
// label=orgName (执行单位名称), value=org_id (直接写 biz_project_assign.execution_unit_id)
|
||||
async function searchExecutors(query, row) {
|
||||
if (row._timer) clearTimeout(row._timer)
|
||||
const kw = (query || '').trim()
|
||||
// 空关键词不拉数据: 下拉清空, 只有用户输入时才边输边搜 (不预加载全量执行单位)
|
||||
if (!kw) {
|
||||
row._options = []
|
||||
row._loading = false
|
||||
return
|
||||
}
|
||||
row._loading = true
|
||||
row._timer = setTimeout(async () => {
|
||||
try {
|
||||
const params = {}
|
||||
if (query) params.orgName = query
|
||||
const r = await listExecutorOrgs(params)
|
||||
const r = await listExecutorOrgs({ orgName: kw })
|
||||
row._options = r.data || []
|
||||
// 选中的项如果不在结果里, 按 orgId 单独拉取并加到首位
|
||||
if (row.executionUnitId && !row._options.find(u => u.orgId === row.executionUnitId)) {
|
||||
@@ -362,8 +382,8 @@ async function loadSingleProject() {
|
||||
assignForm.deadlineDays = Number(p.submitDeadlineDays || 30)
|
||||
// 拉已分配记录
|
||||
await loadAssigns(singleProjectId)
|
||||
// 拉支持方候选 (含已选项回拉)
|
||||
loadSponsorOrgs('')
|
||||
// 拉支持方候选: 仅按 orgId 回显已选 label, 不预加载全量
|
||||
loadSelectedSponsorOption()
|
||||
} catch (e) {
|
||||
ElMessage.error('加载项目数据失败: ' + (e?.msg || e?.message || e))
|
||||
} finally {
|
||||
@@ -411,10 +431,9 @@ async function loadBatchProjects() {
|
||||
assignForm.sponsorOrgId = list[0].sponsorOrgId || null
|
||||
assignForm.sponsorAdminUserName = list[0].sponsorAdminUserName || ''
|
||||
}
|
||||
loadSponsorOrgs('')
|
||||
// 批量模式: 行默认空 1 行 (各项目共用, 保存时按各项目循环)
|
||||
loadSelectedSponsorOption()
|
||||
// 批量模式: 行默认空 1 行 (各项目共用, 保存时按各项目循环); 执行单位不预加载, 输入时才搜索
|
||||
assignForm.execRows = [makeEmptyExecRow()]
|
||||
assignForm.execRows.forEach(r => searchExecutors('', r))
|
||||
} catch (e) {
|
||||
ElMessage.error('加载批量项目失败: ' + (e?.msg || e?.message || e))
|
||||
} finally {
|
||||
@@ -430,7 +449,9 @@ onMounted(() => {
|
||||
|
||||
// ========== 返回列表 ==========
|
||||
function goBack() {
|
||||
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
|
||||
// 本页被 admin/manager 复用, 按当前路由前缀回各自项目列表 (否则 admin 会被角色守卫拦回工作台)
|
||||
const base = '/' + route.path.split('/')[1]
|
||||
router.push({ path: `${base}/projects`, query: { _t: Date.now() } })
|
||||
}
|
||||
function confirmCancel() {
|
||||
ElMessageBox.confirm('确定取消项目分配?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button v-if="!isLeader" @click="doExport">导出</el-button>
|
||||
<el-button v-if="!isLeader" @click="doExportRating">项目评价导出</el-button>
|
||||
<el-button v-if="isManager" @click="doExportSignupExpert">已报名专家导出</el-button>
|
||||
<el-button v-if="isManager || isAdmin" @click="doExportSignupExpert">已报名专家导出</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
|
||||
<el-dropdown-item command="view">查看</el-dropdown-item>
|
||||
<el-dropdown-item command="edit">编辑</el-dropdown-item>
|
||||
<el-dropdown-item command="activate">开通</el-dropdown-item>
|
||||
<el-dropdown-item v-if="false" command="activate">开通</el-dropdown-item> <!-- 暂时隐藏开通功能 (勿删, 需要时去掉 v-if="false") -->
|
||||
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
|
||||
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
|
||||
@@ -300,6 +300,8 @@ import { useUserStore } from '@/store/user'
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
// 本列表页被 admin/manager/leader 三角色共用, 跳子页面须按当前角色前缀 (否则被 beforeEach 角色守卫拦回工作台)
|
||||
const basePath = computed(() => userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager')
|
||||
|
||||
const q = ref({ sponsorOrgIds: [], execOrgIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '' })
|
||||
const rows = ref([])
|
||||
@@ -336,14 +338,14 @@ const canDelete = computed(() => {
|
||||
const r = userStore.role
|
||||
return r === 'admin'
|
||||
})
|
||||
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
|
||||
function doCreateMeeting(row) { router.push(`${basePath.value}/meetings/new?projectId=${row.projectId}`) }
|
||||
function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === 1 }
|
||||
function doAssign(row) {
|
||||
if (isFinishedRow(row)) return ElMessage.warning('已结题项目不能分配')
|
||||
// 跳转独立子页面 /manager/projects/assign?projectId=X
|
||||
router.push({ path: '/manager/projects/assign', query: { projectId: row.projectId } })
|
||||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: row.projectId } })
|
||||
}
|
||||
function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) }
|
||||
function doEdit(row) { router.push(`${basePath.value}/projects/edit/${row.projectId}`) }
|
||||
|
||||
// 删除项目 (软删除 + 级联置 5 张项目表 + 项目下所有会议 is_deleted=1)
|
||||
// 后端 BizProjectController.remove 已强制仅 admin, 前端只做可见性 v-if
|
||||
@@ -395,8 +397,9 @@ function readQueryFromRoute() {
|
||||
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
|
||||
}
|
||||
|
||||
// 仅 manager 可见「已报名专家导出」 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||||
// 「已报名专家导出」: manager + admin 可见 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||||
const isManager = computed(() => userStore.role === 'manager')
|
||||
const isAdmin = computed(() => userStore.role === 'admin')
|
||||
// 项目负责人(leader): 只读, 复用本列表页 (数据由后端 list 按 lead_user_id 隔离), 隐藏所有写操作
|
||||
const isLeader = computed(() => userStore.role === 'leader')
|
||||
|
||||
@@ -549,18 +552,16 @@ async function confirmSingleScore() {
|
||||
}
|
||||
function viewDetail(row) {
|
||||
// admin/leader 复用本列表页, 详情须跳到各自前缀 (否则被 beforeEach 角色守卫拦截)
|
||||
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
|
||||
router.push(`${base}/projects/detail/${row.projectId}`)
|
||||
router.push(`${basePath.value}/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
|
||||
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
|
||||
function goMeetings(row, scope) {
|
||||
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
|
||||
const q = { projectId: row.projectId }
|
||||
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
|
||||
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
|
||||
router.push({ path: `${base}/meetings`, query: q })
|
||||
router.push({ path: `${basePath.value}/meetings`, query: q })
|
||||
}
|
||||
|
||||
// 点击列表「执行单位评分(合规/支持)」列 → 只读显示该角色 4 维度评分明细
|
||||
@@ -626,7 +627,7 @@ const scoreDetailTotal = ref('—')
|
||||
|
||||
// 新建项目已迁到独立页面 /manager/projects/new (ManagerProjectsNew.vue)
|
||||
function openNewProject() {
|
||||
router.push('/manager/projects/new')
|
||||
router.push(`${basePath.value}/projects/new`)
|
||||
}
|
||||
function openAssign() {
|
||||
if (!selection.value.length) {
|
||||
@@ -641,10 +642,10 @@ function openAssign() {
|
||||
// 单条: ?projectId=X → 子页面 v-if 单条分支
|
||||
// 批量: ?projectIds=1,2,3 → 子页面 v-else 批量分支
|
||||
if (targets.length === 1) {
|
||||
router.push({ path: '/manager/projects/assign', query: { projectId: targets[0].projectId } })
|
||||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: targets[0].projectId } })
|
||||
} else {
|
||||
const ids = targets.map(r => r.projectId).join(',')
|
||||
router.push({ path: '/manager/projects/assign', query: { projectIds: ids } })
|
||||
router.push({ path: `${basePath.value}/projects/assign`, query: { projectIds: ids } })
|
||||
}
|
||||
if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
<div class="new-card-title">项目信息</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目名称" prop="projectName">
|
||||
<el-input v-model="form.projectName" placeholder="请输入项目名称" />
|
||||
<el-form-item label="项目编号" prop="projectNo">
|
||||
<el-select v-model="form.projectNo" placeholder="输入项目编号搜索" filterable allow-create default-first-option
|
||||
:filter-method="searchProjectNos" :loading="projectNoLoading" clearable style="width:100%"
|
||||
@change="onProjectNoChange">
|
||||
<el-option v-for="p in projectNoOptions" :key="p.projectNo"
|
||||
:label="p.projectName ? `${p.projectNo}(${p.projectName})` : p.projectNo" :value="p.projectNo" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目编号" prop="projectNo">
|
||||
<el-input v-model="form.projectNo" placeholder="请输入项目编号" />
|
||||
<el-form-item label="项目名称" prop="projectName">
|
||||
<el-input v-model="form.projectName" placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -132,7 +137,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
|
||||
import { bizAdd, bizUpdate, bizGet, searchEcologyProjects, pullEcologyProject } from '@/api/public'
|
||||
import { listExecutor, listLeaders } from '@/api/system'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
@@ -211,6 +216,61 @@ function syncProjectData() {
|
||||
ElMessage.info('同步数据:从外部数据源拉取最新项目信息 (后端支持时启用)')
|
||||
}
|
||||
|
||||
// ===== ecology 项目编号远程搜索 + 自动拉取 =====
|
||||
const projectNoOptions = ref([])
|
||||
const projectNoLoading = ref(false)
|
||||
let _projectNoTimer = null
|
||||
|
||||
// 项目编号边输入边搜索 (空关键词清空, 不发请求)
|
||||
function searchProjectNos(query) {
|
||||
if (_projectNoTimer) clearTimeout(_projectNoTimer)
|
||||
_projectNoTimer = setTimeout(async () => {
|
||||
const kw = (query || '').trim()
|
||||
if (!kw) {
|
||||
projectNoOptions.value = []
|
||||
projectNoLoading.value = false
|
||||
return
|
||||
}
|
||||
projectNoLoading.value = true
|
||||
try {
|
||||
const r = await searchEcologyProjects({ xmbh: kw })
|
||||
projectNoOptions.value = (r?.data) || []
|
||||
} catch (e) {
|
||||
projectNoOptions.value = []
|
||||
} finally {
|
||||
projectNoLoading.value = false
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 选中项目编号 → 拉取完整信息并覆盖表单 (负责人静默建档由后端完成)
|
||||
async function onProjectNoChange(val) {
|
||||
if (!val) return
|
||||
// 仅当所选值来自 ecology 下拉时才拉取 (allow-create 自由输入不拉, 避免无谓请求)
|
||||
const matched = projectNoOptions.value.find(p => p.projectNo === val)
|
||||
if (!matched) return
|
||||
try {
|
||||
const res = await pullEcologyProject(val, { __silentError: true })
|
||||
const d = res?.data || res
|
||||
if (!d) return
|
||||
form.projectName = d.projectName || ''
|
||||
form.projectForm = d.projectForm || ''
|
||||
form.totalAmount = d.totalAmount != null ? Number(d.totalAmount) : 0
|
||||
form.manageFee = d.manageFee != null ? Number(d.manageFee) : 0
|
||||
form.startTime = d.startTime || ''
|
||||
form.endTime = d.endTime || ''
|
||||
if (d.leaderUserId != null) {
|
||||
form.leadUserId = d.leaderUserId
|
||||
// 把自动建档的负责人塞进 leaderOptions, 避免 el-select 选中后 label 空白
|
||||
if (!leaderOptions.value.find(u => u.userId === d.leaderUserId)) {
|
||||
leaderOptions.value = [{ userId: d.leaderUserId, nickName: d.leaderName, userName: d.leaderPhone }, ...leaderOptions.value]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ecology 未找到/不可达: 静默, 保留用户手填内容
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模式:加载已有项目数据并反显到表单
|
||||
async function loadProject() {
|
||||
if (!isEdit || !projectId) return
|
||||
@@ -221,6 +281,8 @@ async function loadProject() {
|
||||
if (!p) return
|
||||
form.projectName = p.projectName || ''
|
||||
form.projectNo = p.projectNo || ''
|
||||
// 编辑模式: 项目编号 select 预置当前项, 避免远程搜索 select 无选项时 label 空白
|
||||
if (form.projectNo) projectNoOptions.value = [{ projectNo: form.projectNo, projectName: form.projectName }]
|
||||
form.projectForm = p.projectForm || ''
|
||||
form.totalSessions = p.totalSessions || 0
|
||||
form.totalAmount = p.totalAmount || 0
|
||||
@@ -293,7 +355,9 @@ onMounted(() => {
|
||||
})
|
||||
function goBack() {
|
||||
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
|
||||
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
|
||||
// 本页被 admin/manager 复用, 按当前路由前缀回各自项目列表 (否则 admin 会被角色守卫拦回工作台)
|
||||
const base = '/' + route.path.split('/')[1]
|
||||
router.push({ path: `${base}/projects`, query: { _t: Date.now() } })
|
||||
}
|
||||
function confirmCancel() {
|
||||
ElMessageBox.confirm(`确定取消${isEdit ? '编辑' : '新建'}项目?未保存的内容将丢失`, '提示', { type: 'warning' })
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">发送邀请函</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
|
||||
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
|
||||
</div>
|
||||
@@ -152,7 +152,7 @@
|
||||
</el-link>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">邀请参会</el-dropdown-item>
|
||||
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">发送邀请函</el-dropdown-item>
|
||||
<el-dropdown-item command="esign" :disabled="esignLoadingId === row.id">推送电子签</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" :disabled="!attendeeEditable" divided><span style="color: #f56c6c;">删除</span></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -205,7 +205,7 @@
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议日程海报</div>
|
||||
</div>
|
||||
<div v-if="!isExecutor" class="file-row">
|
||||
<div class="file-row">
|
||||
<span class="file-label">邀请函:</span>
|
||||
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议邀请函</div>
|
||||
@@ -294,13 +294,13 @@
|
||||
<el-button v-if="canSubmitLabor && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR'])">提交劳务</el-button>
|
||||
<el-button v-if="canSubmitService && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['SERVICE'])">提交会务</el-button>
|
||||
<el-button v-if="canSubmitAll && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR', 'SERVICE'])">提交全部</el-button>
|
||||
<!-- 合规 / 监察审核 (材料, 分轨三按钮) -->
|
||||
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
|
||||
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
|
||||
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
|
||||
<!-- 合规 / 监察审核 (材料, 分轨三按钮); 只读 view 页同样可审 (去掉 !isReadonly 挡板) -->
|
||||
<el-button v-if="isManager && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
|
||||
<el-button v-if="isManager && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
|
||||
<el-button v-if="isManager && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
|
||||
<el-button v-if="supervisorAuditor && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
|
||||
<el-button v-if="supervisorAuditor && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
|
||||
<el-button v-if="supervisorAuditor && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
|
||||
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
|
||||
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
|
||||
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
|
||||
@@ -410,41 +410,41 @@
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
|
||||
<el-form-item label="姓名" prop="name" :error="mismatchError('name')">
|
||||
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="医院" prop="workUnit">
|
||||
<el-form-item label="医院" prop="workUnit" :error="mismatchError('workUnit')">
|
||||
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号" prop="idCard">
|
||||
<el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')">
|
||||
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1">
|
||||
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
|
||||
</el-form-item>
|
||||
<el-form-item label="科室" prop="department">
|
||||
<el-form-item label="科室" prop="department" :error="mismatchError('department')">
|
||||
<doctor-dept-select v-model="attendeeDialog.form.department" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称" prop="title">
|
||||
<el-form-item label="职称" prop="title" :error="mismatchError('title')">
|
||||
<doctor-title-select v-model="attendeeDialog.form.title" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行" prop="bankName">
|
||||
<el-form-item label="银行" prop="bankName" :error="mismatchError('bankName')">
|
||||
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行卡号码" prop="bankCard">
|
||||
<el-form-item label="银行卡号码" prop="bankCard" :error="mismatchError('bankCard')">
|
||||
<el-input v-model="attendeeDialog.form.bankCard" placeholder="持卡人银行卡号" maxlength="25" />
|
||||
</el-form-item>
|
||||
<!-- Col 2: 财务 + 备注 (8 字段) -->
|
||||
<el-form-item label="开户行" prop="bankBranch">
|
||||
<el-form-item label="开户行" prop="bankBranch" :error="mismatchError('bankBranch')">
|
||||
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账户名称" prop="accountName">
|
||||
<el-input v-model="attendeeDialog.form.accountName" placeholder="持卡人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户行地址" prop="bankRegion">
|
||||
<el-form-item label="开户行地址" prop="bankRegion" :error="mismatchError('bankRegion')">
|
||||
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行详细地址" prop="bankAddress">
|
||||
<el-form-item label="银行详细地址" prop="bankAddress" :error="mismatchError('bankAddress')">
|
||||
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="laborForm" style="grid-column: 1 / -1">
|
||||
@@ -480,18 +480,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参会人 Excel 批量导入 (沿用 dialog-import SKILL: v-if + on-error + 模板下载在框外 + ImportResultDialog) -->
|
||||
<!-- 参会人 Excel 批量导入 (两段式: dry-run 比对 → 二次确认 → force 入库; 模板下载在框外 + ImportResultDialog) -->
|
||||
<el-dialog v-model="importOpen" title="批量导入参会人" width="400px" append-to-body :close-on-click-modal="false">
|
||||
<el-upload
|
||||
v-if="importOpen"
|
||||
ref="importUploadRef"
|
||||
:limit="1"
|
||||
:headers="importHeaders"
|
||||
accept=".xlsx, .xls"
|
||||
:action="importAction"
|
||||
:on-progress="onImportProgress"
|
||||
:on-success="onImportSuccess"
|
||||
:on-error="onImportError"
|
||||
:on-change="onImportFileChange"
|
||||
:on-remove="onImportFileRemove"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
>
|
||||
@@ -532,6 +529,23 @@
|
||||
<div v-else class="text-muted">尚未生成海报</div>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
|
||||
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
|
||||
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
|
||||
<div class="mismatch-intro">{{ mismatchDialog.intro }}</div>
|
||||
<div v-for="(entry, ei) in mismatchDialog.entries" :key="ei" class="mismatch-entry">
|
||||
<div class="mismatch-entry-title">{{ entry.title }}</div>
|
||||
<el-table :data="entry.fields" size="small" border>
|
||||
<el-table-column prop="label" label="字段" width="110" />
|
||||
<el-table-column prop="expertValue" label="专家档案" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="inputValue" label="您输入" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="mismatchDialog.show = false">{{ mismatchDialog.cancelText }}</el-button>
|
||||
<el-button type="primary" @click="confirmMismatch">{{ mismatchDialog.confirmText }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -595,7 +609,7 @@ const currentRole = computed(() => userStore.role)
|
||||
const isManager = computed(() => currentRole.value === 'manager')
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
const isSponsor = computed(() => currentRole.value === 'sponsor')
|
||||
/** 只读模式: 从会议列表「查看」跳入 (route.meta.readonly), 仅展示内容 + 返回, 无任何操作按钮 */
|
||||
/** 只读模式: 从会议列表「查看」/「审核」跳入 (route.meta.readonly), 仅展示内容 + 返回, 除审核/监察按钮外无其他操作按钮 */
|
||||
const isReadonly = computed(() => route.meta.readonly === true)
|
||||
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
|
||||
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
|
||||
@@ -882,6 +896,16 @@ async function loadMaterials() {
|
||||
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
|
||||
}
|
||||
|
||||
/** 只刷新 materialsLoaded 金额/fee_status (供 refreshFee 轮询用).
|
||||
* 与 loadMaterials 区别: 不重写各行 r.url/r.fileName, 避免轮询时覆盖「已上传未保存」的本地文件. */
|
||||
async function refreshMaterialAmounts() {
|
||||
try {
|
||||
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`, { __silentError: true })
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
materialsLoaded.value = Array.isArray(list) ? list : []
|
||||
} catch (e) { console.error('[meeting-detail] refreshMaterialAmounts failed', e) }
|
||||
}
|
||||
|
||||
// ===================== 未保存改动检测 (结算前拦截用) =====================
|
||||
/** 已落库快照 (load 后 + 保存后各刷新一次): 4 组上传行 url + 海报/邀请函 */
|
||||
const savedSnapshot = ref(null)
|
||||
@@ -990,7 +1014,10 @@ const attendeeRules = {
|
||||
phone: [{ required: true, message: '请填写联系方式', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
|
||||
idCard: [{ required: true, message: '请填写身份证号', trigger: 'blur' }],
|
||||
idCard: [
|
||||
{ required: true, message: '请填写身份证号', trigger: 'blur' },
|
||||
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
|
||||
],
|
||||
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
|
||||
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
|
||||
@@ -1007,6 +1034,42 @@ const attendeeRules = {
|
||||
fee: [{ required: true, message: '请填写实发金额', trigger: 'change' }]
|
||||
}
|
||||
|
||||
/** 参会人表单字段与专家档案不一致标记 (field → {expertValue, inputValue}), 用于输入框标红 */
|
||||
const mismatchMap = reactive({})
|
||||
function mismatchError(field) {
|
||||
const m = mismatchMap[field]
|
||||
return m ? `专家档案为「${m.expertValue || '空'}」` : ''
|
||||
}
|
||||
function clearMismatchMap() {
|
||||
Object.keys(mismatchMap).forEach(k => delete mismatchMap[k])
|
||||
}
|
||||
|
||||
/** 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) */
|
||||
const mismatchDialog = reactive({
|
||||
show: false,
|
||||
title: '检测到与专家档案不一致',
|
||||
intro: '',
|
||||
entries: [],
|
||||
confirmText: '确认提交',
|
||||
cancelText: '返回修改',
|
||||
onConfirm: null
|
||||
})
|
||||
function openMismatchDialog(entries, opts = {}) {
|
||||
mismatchDialog.entries = entries || []
|
||||
mismatchDialog.title = opts.title || '检测到与专家档案不一致'
|
||||
mismatchDialog.intro = opts.intro || '以下字段与专家档案不一致,请确认是否仍按当前输入提交。'
|
||||
mismatchDialog.confirmText = opts.confirmText || '确认提交'
|
||||
mismatchDialog.cancelText = opts.cancelText || '返回修改'
|
||||
mismatchDialog.onConfirm = opts.onConfirm || null
|
||||
mismatchDialog.show = true
|
||||
}
|
||||
function confirmMismatch() {
|
||||
const fn = mismatchDialog.onConfirm
|
||||
mismatchDialog.show = false
|
||||
mismatchDialog.onConfirm = null
|
||||
if (fn) fn()
|
||||
}
|
||||
|
||||
function resetAttendeeForm() {
|
||||
attendeeDialog.value = {
|
||||
show: false, title: '', editing: false,
|
||||
@@ -1014,6 +1077,7 @@ function resetAttendeeForm() {
|
||||
}
|
||||
// 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
|
||||
attendeeFormRef.value?.clearValidate()
|
||||
clearMismatchMap()
|
||||
}
|
||||
|
||||
/** 参会人表单 ref (el-form validate 用) */
|
||||
@@ -1238,6 +1302,7 @@ function onBatchInvite() {
|
||||
/** 打开新增/编辑 dialog; row=null → 新增 */
|
||||
function openAttendeeDialog(row) {
|
||||
suppressFeeLink = true
|
||||
clearMismatchMap()
|
||||
if (row) {
|
||||
attendeeDialog.value = {
|
||||
show: true,
|
||||
@@ -1320,23 +1385,9 @@ async function lookupExpertByPhone() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
|
||||
async function confirmAttendee() {
|
||||
/** 真正落库 (新增 → POST; 编辑 → PUT), 二次确认通过后调用 */
|
||||
async function doSubmitAttendee() {
|
||||
const { editing, form } = attendeeDialog.value
|
||||
if (!form.phone || !form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.phone.trim().match(/^1\d{10}$/)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
// 校验实发金额不超所选角色劳务金额合计 (未选角色时跳过)
|
||||
try {
|
||||
await attendeeFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
attendeeDialog.value.saving = true
|
||||
try {
|
||||
if (editing) {
|
||||
@@ -1355,6 +1406,44 @@ async function confirmAttendee() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交 dialog: 校验 → 预检比对专家表 → 无差异直接提交 / 有差异标红 + 二次确认 */
|
||||
async function confirmAttendee() {
|
||||
const { form } = attendeeDialog.value
|
||||
if (!form.phone || !form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.phone.trim().match(/^1\d{10}$/)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await attendeeFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await request.post('/business/meetingAttendee/diff', form, { __silentError: true })
|
||||
const mismatches = Array.isArray(resp && resp.data) ? resp.data : []
|
||||
if (!mismatches.length) {
|
||||
doSubmitAttendee()
|
||||
return
|
||||
}
|
||||
// 标红 + 二次确认
|
||||
clearMismatchMap()
|
||||
mismatches.forEach(m => { if (m && m.field) mismatchMap[m.field] = m })
|
||||
openMismatchDialog(
|
||||
[{
|
||||
title: `${form.name || ''} ${form.phone || ''}`.trim() || '参会人',
|
||||
fields: mismatches.map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
|
||||
}],
|
||||
{ confirmText: '确认提交', cancelText: '返回修改', onConfirm: doSubmitAttendee }
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '校验失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除参会人 (单条确认) */
|
||||
async function confirmDeleteAttendee(row) {
|
||||
try {
|
||||
@@ -1376,20 +1465,21 @@ function onAttendeeCommand(cmd, row) {
|
||||
else if (cmd === 'delete') confirmDeleteAttendee(row)
|
||||
}
|
||||
|
||||
// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) =====================
|
||||
// ===================== 参会人 Excel 批量导入 (两段式: dry-run 比对专家表 → 二次确认 → force 入库) =====================
|
||||
const importOpen = ref(false)
|
||||
const importing = ref(false)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref(null)
|
||||
const exporting = ref(false)
|
||||
const importUploadRef = ref(null)
|
||||
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
|
||||
// 下载模板用 (fetch 流式下载, 不走 axios, 需显式带 Authorization)
|
||||
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
|
||||
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串
|
||||
const importAction = computed(() => `${import.meta.env.VITE_APP_BASE_API}/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
|
||||
/** 待导入的 Excel 文件 (on-change 捕获, 同一文件 dry-run + force 各发一次) */
|
||||
const importFile = ref(null)
|
||||
|
||||
function openAttendeeImport() {
|
||||
importResult.value = null
|
||||
importFile.value = null
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1472,8 +1562,17 @@ async function onAgreementZipChange(ev) {
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
|
||||
const data = (resp && resp.data) || {}
|
||||
const updated = data.updated ?? 0
|
||||
const total = data.total ?? 0
|
||||
const results = Array.isArray(data.results) ? data.results : []
|
||||
const skipped = results.filter(r => r && r.matched === false)
|
||||
if (skipped.length) {
|
||||
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
|
||||
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
|
||||
} else {
|
||||
ElMessage.success(`已回填 ${updated}/${total} 位参会人的劳务协议`)
|
||||
}
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
@@ -1526,8 +1625,17 @@ async function onExpertPhotoZipChange(ev) {
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
|
||||
const data = (resp && resp.data) || {}
|
||||
const updated = data.updated ?? 0
|
||||
const total = data.total ?? 0
|
||||
const results = Array.isArray(data.results) ? data.results : []
|
||||
const skipped = results.filter(r => r && r.matched === false)
|
||||
if (skipped.length) {
|
||||
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
|
||||
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
|
||||
} else {
|
||||
ElMessage.success(`已回填 ${updated}/${total} 位参会人的专家照片`)
|
||||
}
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
@@ -1536,40 +1644,74 @@ async function onExpertPhotoZipChange(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
function submitImportFile() {
|
||||
importUploadRef.value?.submit()
|
||||
function onImportFileChange(uploadFile) {
|
||||
importFile.value = uploadFile.raw || null
|
||||
}
|
||||
function onImportFileRemove() {
|
||||
importFile.value = null
|
||||
}
|
||||
|
||||
function onImportProgress() { importing.value = true }
|
||||
|
||||
function onImportSuccess(res) {
|
||||
importing.value = false
|
||||
const result = (res && res.data) ? res.data : res
|
||||
if (!result || typeof result.okNum !== 'number') {
|
||||
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
|
||||
importResult.value = null
|
||||
/** 确定导入: 先 dry-run 比对专家表, 有冲突弹二次确认, 确认后 force 入库 */
|
||||
async function submitImportFile() {
|
||||
const file = importFile.value
|
||||
if (!file) {
|
||||
ElMessage.warning('请先选择要导入的文件')
|
||||
return
|
||||
}
|
||||
importResult.value = result
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
// 刷新列表 (新参会人会出现)
|
||||
loadAttendees()
|
||||
importing.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// 1. dry-run (缺省 force=false): 只解析 + 比对专家表, 不落库
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/importData?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const data = (resp && resp.data) || {}
|
||||
const mismatches = Array.isArray(data.mismatches) ? data.mismatches : []
|
||||
if (!mismatches.length) {
|
||||
await doForceImport(file)
|
||||
return
|
||||
}
|
||||
// 2. 有冲突 → 二次确认
|
||||
openMismatchDialog(
|
||||
mismatches.map(m => ({
|
||||
title: `第${m.rowNo}行 ${m.name || ''} ${m.phone || ''}`.trim(),
|
||||
fields: (m.fields || []).map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
|
||||
})),
|
||||
{ confirmText: '确认导入', cancelText: '取消', onConfirm: () => doForceImport(file) }
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onImportError(err) {
|
||||
importing.value = false
|
||||
let msg = '上传失败, 请重试'
|
||||
if (err && err.response != null) {
|
||||
try {
|
||||
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
|
||||
msg = body.msg || msg
|
||||
} catch { /* JSON 解析失败用兜底 */ }
|
||||
} else if (err && err.message) {
|
||||
msg = err.message
|
||||
/** force=true 真正入库, 成功展示 ImportResultDialog */
|
||||
async function doForceImport(file) {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/importData?meetingId=${meetingId.value}&force=true`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const result = (resp && resp.data) ? resp.data : null
|
||||
if (!result || typeof result.okNum !== 'number') {
|
||||
ElMessage.error(resp?.msg || '导入失败, 返回数据异常')
|
||||
importResult.value = null
|
||||
return
|
||||
}
|
||||
importResult.value = result
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '导入失败')
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
importResult.value = null
|
||||
}
|
||||
async function loadStaff() {
|
||||
try {
|
||||
@@ -1598,6 +1740,11 @@ async function load() {
|
||||
invitationUrl.value = row.value.invitationUrl || ''
|
||||
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles(), loadInvoices()])
|
||||
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
|
||||
// 从列表跳入带 ?tab=xxx: 结算→laborVoucher, 审核→labor/service; 不传则保持默认 (劳务材料)
|
||||
const targetTab = route.query.tab
|
||||
if (['labor', 'service', 'laborVoucher'].includes(targetTab)) {
|
||||
activeTab.value = targetTab
|
||||
}
|
||||
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
|
||||
if (route.query.settle === '1' && canSettle.value) {
|
||||
router.replace(route.path)
|
||||
@@ -1686,8 +1833,10 @@ async function saveMaterials() {
|
||||
submitOcrForMaterials(toOcr)
|
||||
}
|
||||
|
||||
// 5. 保存即刷新发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id,
|
||||
// 移除材料后立即可见减少 (doSave 走 refreshFee, onSubmitMaterials 走这里)
|
||||
// 5. 保存即刷新材料 + 发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id.
|
||||
// 会务费合计(meetingFee)与单行金额(materialAmount)都读 materialsLoaded, 移除材料时 toOcr=0,
|
||||
// 若不重拉 loadMaterials 会残留旧金额/旧行 → 这里无条件重拉 (doSave 走 refreshFee, onSubmitMaterials 走这里)
|
||||
loadMaterials()
|
||||
loadInvoices()
|
||||
|
||||
// 6. 1.5s 后重拉 materials + 发票表, 让 OCR 回写的 amount/发票号 更新可见
|
||||
@@ -1713,7 +1862,7 @@ async function saveVouchers() {
|
||||
async function saveSchedulePoster() {
|
||||
const cur = row.value.scheduleUrl || ''
|
||||
if (scheduleUrl.value === cur) return
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), scheduleUrl: scheduleUrl.value }, { __silentError: true })
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, scheduleUrl: scheduleUrl.value }, { __silentError: true })
|
||||
row.value.scheduleUrl = scheduleUrl.value
|
||||
}
|
||||
|
||||
@@ -1721,7 +1870,7 @@ async function saveSchedulePoster() {
|
||||
async function saveInvitation() {
|
||||
const cur = row.value.invitationUrl || ''
|
||||
if (invitationUrl.value === cur) return
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), invitationUrl: invitationUrl.value }, { __silentError: true })
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, invitationUrl: invitationUrl.value }, { __silentError: true })
|
||||
row.value.invitationUrl = invitationUrl.value
|
||||
}
|
||||
|
||||
@@ -2045,7 +2194,12 @@ async function refreshFee() {
|
||||
row.value = { ...row.value, laborFee: m.laborFee, meetingFee: m.meetingFee, totalFee: m.totalFee, feeCalcStatus: m.feeCalcStatus }
|
||||
} catch (e) { /* 拉取失败忽略, 下轮再试 */ }
|
||||
loadInvoices()
|
||||
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
|
||||
// 会务费合计(tab)读 materialsLoaded.amount — OCR 回写金额后必须刷新, 否则 zip 上传后金额不更新
|
||||
await refreshMaterialAmounts()
|
||||
// 继续轮询条件: 费用未汇总完(feeCalcStatus=0) 或 仍有材料待 OCR(feeStatus=0).
|
||||
// 后者覆盖「已算过的会议重传 zip」场景: 上传瞬间 feeCalcStatus 仍是 1, 但材料 feeStatus=0 在等 OCR.
|
||||
const ocrPending = (materialsLoaded.value || []).some(m => m.feeStatus === 0)
|
||||
if (row.value.feeCalcStatus === 0 || ocrPending) scheduleFeePoll()
|
||||
else stopFeePolling()
|
||||
}
|
||||
function scheduleFeePoll() {
|
||||
@@ -2438,3 +2592,11 @@ onBeforeUnmount(stopFeePolling)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 专家档案不一致 二次确认 dialog (append-to-body 脱离 scoped, 用全局样式) */
|
||||
.mismatch-intro { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.mismatch-entry { margin-bottom: 16px; }
|
||||
.mismatch-entry:last-child { margin-bottom: 0; }
|
||||
.mismatch-entry-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
不用 el-input-number: 移动端其 +/- 按钮 absolute 定位在容器最右,
|
||||
容器 100% 后按钮飘到行尾. 改用 type="number" 原生 input, 浏览器自带步进按钮在 input 内右贴边
|
||||
-->
|
||||
<el-input v-model.number="form.periodNo" type="number" :min="1" :max="totalSessionsNum" placeholder="请输入期数" />
|
||||
<el-input v-model.number="form.periodNo" type="number" step="1" :min="1" :max="totalSessionsNum" placeholder="请输入期数" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />
|
||||
@@ -134,8 +134,19 @@ const form = reactive({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 期数必须是正整数 (第几期), 小数/负数会触发后端 Long 反序列化报错
|
||||
function validatePeriodNo(_rule, value, cb) {
|
||||
if (value === null || value === undefined || value === '') { cb(); return }
|
||||
const n = Number(value)
|
||||
if (!Number.isInteger(n) || n < 1) { cb(new Error('期数必须为正整数')); return }
|
||||
cb()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
periodNo: [{ required: true, message: '请填写期数', trigger: 'blur' }],
|
||||
periodNo: [
|
||||
{ required: true, message: '请填写期数', trigger: 'blur' },
|
||||
{ validator: validatePeriodNo, trigger: 'blur' }
|
||||
],
|
||||
startTime: [{ required: true, message: '请选择会议开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择会议结束时间', trigger: 'change' }],
|
||||
address: [{ required: true, message: '请填写会议地点', trigger: 'blur' }]
|
||||
|
||||
@@ -60,7 +60,14 @@
|
||||
<el-table-column label="会议结束时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
|
||||
<el-table-column label="总期数" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onFilterByProject(row)">
|
||||
{{ row.totalPeriods ?? '-' }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.totalPeriods ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
|
||||
</el-table-column>
|
||||
@@ -275,6 +282,17 @@ function onCopy(row) {
|
||||
router.push({ name: newRouteName.value, query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
|
||||
}
|
||||
|
||||
// ========== 点击「总期数」: 过滤到该项目下的会议 ==========
|
||||
// projectId 精确过滤为准; 后端 list 已按角色叠加数据范围 (manager/sponsor/executor/leader), 前端无需按角色分支.
|
||||
// 一次性过滤, 不清除其它筛选条件 (会议名称/阶段等), 清除走「重置」.
|
||||
function onFilterByProject(row) {
|
||||
if (row.projectId == null) return
|
||||
q.value.projectId = row.projectId
|
||||
q.value.projectNo = row.projectNo || '' // 回填「项目编号」输入框做视觉反馈 (projectId 精确过滤为准)
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ========== 删除会议 (软删除, 后端强校验 admin/manager) ==========
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
@@ -375,8 +393,8 @@ function canSettleRow(row) {
|
||||
return row.laborAuditStage === 'APPROVED' && row.serviceAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
|
||||
}
|
||||
function onSettle(row) {
|
||||
// 结算: 跳详情页即可, 不自动弹结算 dialog
|
||||
router.push(`${detailBase.value}/detail/${row.meetingId}`)
|
||||
// 结算: 跳详情页并切到劳务凭证 tab (结算前需确认付款凭证), 不自动弹结算 dialog
|
||||
router.push({ path: `${detailBase.value}/detail/${row.meetingId}`, query: { tab: 'laborVoucher' } })
|
||||
}
|
||||
// 列表完结按钮显隐: 已结算 且 未完结
|
||||
function canFinishRow(row) {
|
||||
@@ -405,15 +423,24 @@ async function onUnfreeze(row) {
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') }
|
||||
}
|
||||
|
||||
// ========== 支持方(监察员) 审核: 跳详情页 (详情页内按 C1 轨给 审核劳务/会务/全部) ==========
|
||||
// ========== 支持方(监察员) 审核: 跳只读 view 页 (view 页内按 C1 轨给 审核劳务/会务/全部) ==========
|
||||
// 有任一轨处于 C1 (已提交待支持方审) 即显示「审核」; 会务被退回时物理阶段=待整改, 但劳务 C1 仍需审, 不能用物理阶段判
|
||||
function isSponsorPending(row) {
|
||||
return (row.laborAuditStage === 'SUBMITTED' && isOneVal(row.laborComplianceApproved))
|
||||
|| (row.serviceAuditStage === 'SUBMITTED' && isOneVal(row.serviceComplianceApproved))
|
||||
}
|
||||
// 审核: 跳详情页 (非 readonly), 详情页按角色控制显示三按钮
|
||||
// 审核: 跳只读 view 页并按待审轨切 tab (只审劳务→劳务材料, 只审会务→会务材料, 两轨都要审→不传 tab 保持默认)
|
||||
function onAudit(row) {
|
||||
router.push(`${detailBase.value}/detail/${row.meetingId}`)
|
||||
// sponsor 监察看 C1 (合规已通过待支持方审), manager/admin 合规看 C0 (合规未通过)
|
||||
const needC1 = isRole('sponsor')
|
||||
const laborPending = row.laborAuditStage === 'SUBMITTED'
|
||||
&& (needC1 ? isOneVal(row.laborComplianceApproved) : !isOneVal(row.laborComplianceApproved))
|
||||
const servicePending = row.serviceAuditStage === 'SUBMITTED'
|
||||
&& (needC1 ? isOneVal(row.serviceComplianceApproved) : !isOneVal(row.serviceComplianceApproved))
|
||||
const query = {}
|
||||
if (laborPending && !servicePending) query.tab = 'labor'
|
||||
else if (servicePending && !laborPending) query.tab = 'service'
|
||||
router.push({ path: `${detailBase.value}/view/${row.meetingId}`, query })
|
||||
}
|
||||
|
||||
// ========== 合规人员(manager) 批量审核 ==========
|
||||
|
||||
@@ -47,11 +47,16 @@
|
||||
<div class="skeleton-loading-tip">公示信息加载中...</div>
|
||||
</div>
|
||||
<div v-else class="main-content">
|
||||
<iframe v-if="isPdf(currentTab?.fileUrl)"
|
||||
:src="proxyUrl(currentTab.fileUrl)"
|
||||
class="content-frame"
|
||||
style="width: 100%; height: 700px; border: 0;"
|
||||
frameborder="0"></iframe>
|
||||
<!-- PDF 用 PDF.js 渲染 (微信/移动端浏览器无 Chrome 内置 PDF viewer) -->
|
||||
<div v-if="isPdf(currentTab?.fileUrl)" ref="pdfWrap" class="pdf-wrap">
|
||||
<div v-if="pdfStatus === 'loading'" class="pdf-tip">PDF 加载中…</div>
|
||||
<div v-else-if="pdfStatus === 'error'" class="pdf-tip pdf-error">
|
||||
<p>PDF 加载失败</p>
|
||||
<p class="hint">{{ pdfErrorMsg }}</p>
|
||||
<button class="action-btn primary" style="margin-top: 12px;" @click="renderCurrentPdf">重新加载</button>
|
||||
</div>
|
||||
<div v-show="pdfStatus === 'done'" ref="pdfViewer" class="pdf-viewer"></div>
|
||||
</div>
|
||||
<img v-else-if="isImg(currentTab?.fileUrl)"
|
||||
:src="proxyUrl(currentTab.fileUrl)"
|
||||
class="content-img"
|
||||
@@ -87,12 +92,12 @@
|
||||
<button v-if="activeTab === 'notice' && isBidProject" class="action-btn primary" @click="onViewBidAnnouncement">
|
||||
<span>查看招标公告</span>
|
||||
</button>
|
||||
<!-- 分享二维码: 所有 tab 均显示 -->
|
||||
<button class="action-btn primary share-btn" @click="showQr = true">
|
||||
<!-- 分享: 微信环境走微信分享, 其他环境走二维码 -->
|
||||
<button class="action-btn primary share-btn" @click="onShare">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4z"/>
|
||||
</svg>
|
||||
<span>分享二维码</span>
|
||||
<span>{{ isWechat ? '微信分享' : '分享二维码' }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
</main>
|
||||
@@ -157,7 +162,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, reactive, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -172,6 +177,9 @@ import {
|
||||
} from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import QRCode from 'qrcode'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -203,6 +211,8 @@ const showQr = ref(false)
|
||||
const qrUrl = ref('')
|
||||
// 分享 URL: 当前页面地址 (window.location.href)
|
||||
const shareUrl = computed(() => window.location.href)
|
||||
// 是否微信内置浏览器 (MicroMessenger UA)
|
||||
const isWechat = computed(() => /MicroMessenger/i.test(navigator.userAgent))
|
||||
|
||||
// 打开二维码弹窗时生成二维码 (避免页面加载时就调 QRCode.toDataURL)
|
||||
watch(showQr, async (v) => {
|
||||
@@ -290,6 +300,92 @@ function proxyUrl(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
// PDF.js 专用代理 URL: 不带 Chrome viewer 的 #toolbar fragment (PDF.js 需要干净的文件字节流)
|
||||
function pdfProxyUrl(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)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ============ PDF.js 渲染 (替代 Chrome 内置 PDF viewer, 兼容微信/移动端浏览器) ============
|
||||
const pdfWrap = ref(null)
|
||||
const pdfViewer = ref(null)
|
||||
const pdfStatus = ref('') // '' | 'loading' | 'done' | 'error'
|
||||
const pdfErrorMsg = ref('')
|
||||
let pdfRenderToken = 0
|
||||
let pdfDoc = null
|
||||
|
||||
async function renderCurrentPdf() {
|
||||
const url = pdfProxyUrl(currentTab.value?.fileUrl)
|
||||
if (!url) return
|
||||
const token = ++pdfRenderToken
|
||||
// 释放上一份文档
|
||||
if (pdfDoc) { try { await pdfDoc.destroy() } catch (e) { /* ignore */ } pdfDoc = null }
|
||||
pdfStatus.value = 'loading'
|
||||
pdfErrorMsg.value = ''
|
||||
const host = pdfViewer.value
|
||||
if (host) host.innerHTML = ''
|
||||
const t0 = performance.now()
|
||||
try {
|
||||
const doc = await pdfjsLib.getDocument({ url }).promise
|
||||
if (token !== pdfRenderToken) { await doc.destroy(); return }
|
||||
pdfDoc = doc
|
||||
// 按容器宽度缩放: 让每页正文占满预览宽度 (移动端也自适应)
|
||||
const wrapWidth = pdfWrap.value?.clientWidth || (window.innerWidth - 32) || 800
|
||||
const first = await doc.getPage(1)
|
||||
const base = first.getViewport({ scale: 1 })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2) // 高清屏放大绘制, 上限 2 避免内存过大
|
||||
const scale = (wrapWidth / base.width) * dpr
|
||||
console.log(`[pdf] 下载+解析 ${Math.round(performance.now() - t0)}ms, 页数 ${doc.numPages}, dpr ${dpr}`)
|
||||
let shown = false
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
if (token !== pdfRenderToken) break
|
||||
const page = await doc.getPage(i)
|
||||
const viewport = page.getViewport({ scale })
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(viewport.width)
|
||||
canvas.height = Math.floor(viewport.height)
|
||||
// 动态创建的 canvas 不受 scoped CSS 作用, 必须内联设置样式 (display:block + margin auto 才能水平居中)
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.margin = '8px auto'
|
||||
canvas.style.background = '#fff'
|
||||
canvas.style.boxShadow = '0 1px 4px rgba(0,0,0,.15)'
|
||||
canvas.style.width = Math.floor(viewport.width / dpr) + 'px'
|
||||
canvas.style.height = Math.floor(viewport.height / dpr) + 'px'
|
||||
host.appendChild(canvas)
|
||||
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
|
||||
// 第一页渲染完立即显示 (结束 loading), 后续页继续后台渲染并追加, 避免等全部页都画完才可见
|
||||
if (!shown && token === pdfRenderToken) {
|
||||
shown = true
|
||||
pdfStatus.value = 'done'
|
||||
}
|
||||
}
|
||||
if (token === pdfRenderToken) {
|
||||
pdfStatus.value = 'done'
|
||||
console.log(`[pdf] 全部渲染完成 ${Math.round(performance.now() - t0)}ms`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[pdf] render failed', e)
|
||||
if (token === pdfRenderToken) {
|
||||
pdfStatus.value = 'error'
|
||||
pdfErrorMsg.value = e?.message || 'PDF 加载失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 tab / 项目加载完成后, 若当前是 PDF 则渲染
|
||||
watch(() => currentTab.value?.fileUrl, async (url) => {
|
||||
if (isPdf(url)) {
|
||||
await nextTick()
|
||||
renderCurrentPdf()
|
||||
} else {
|
||||
pdfStatus.value = ''
|
||||
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function onView() {
|
||||
const url = proxyUrl(currentTab.value?.fileUrl)
|
||||
if (!url) return ElMessage.warning('该公告暂无文件')
|
||||
@@ -569,12 +665,86 @@ function onDownloadQr() {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
// ============ 微信 JS-SDK 分享 (仅微信环境) ============
|
||||
let wxSdkPromise = null
|
||||
function loadWxSdk() {
|
||||
if (window.wx) return Promise.resolve(window.wx)
|
||||
if (wxSdkPromise) return wxSdkPromise
|
||||
wxSdkPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
|
||||
s.onload = () => resolve(window.wx)
|
||||
s.onerror = () => { wxSdkPromise = null; reject(new Error('微信 SDK 加载失败')) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return wxSdkPromise
|
||||
}
|
||||
|
||||
// 分享入口: 微信环境走微信分享, 否则弹二维码
|
||||
function onShare() {
|
||||
if (isWechat.value) onWechatShare()
|
||||
else showQr.value = true
|
||||
}
|
||||
|
||||
const wxSharing = ref(false)
|
||||
async function onWechatShare() {
|
||||
if (wxSharing.value) return
|
||||
wxSharing.value = true
|
||||
try {
|
||||
const wx = await loadWxSdk()
|
||||
// 微信签名只校验不含 # fragment 的 url (hash 路由下 split('#')[0] 会丢失路由, 但签名必须这样)
|
||||
const url = window.location.href.split('#')[0]
|
||||
// __silentError: 拦截器不自动 toast, 由下方 catch 统一提示 (避免双 toast)
|
||||
const res = await request({ url: '/business/public/wx/jssdk', method: 'get', params: { url }, __silentError: true })
|
||||
const cfg = res?.data || res
|
||||
if (!cfg || !cfg.signature) {
|
||||
ElMessage.error(res?.msg || '微信分享配置获取失败')
|
||||
return
|
||||
}
|
||||
const title = ann.value?.projectName || '项目公示'
|
||||
const desc = '北京整合医学学会 · 项目公示'
|
||||
const link = window.location.href
|
||||
const imgUrl = window.location.origin + (import.meta.env.VITE_BASE || '/') + 'logo.png'
|
||||
|
||||
wx.config({
|
||||
debug: false,
|
||||
appId: cfg.appId,
|
||||
timestamp: cfg.timestamp,
|
||||
nonceStr: cfg.nonceStr,
|
||||
signature: cfg.signature,
|
||||
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData']
|
||||
})
|
||||
wx.ready(function () {
|
||||
wx.updateAppMessageShareData({
|
||||
title,
|
||||
desc,
|
||||
link,
|
||||
imgUrl,
|
||||
success: () => ElMessage.success('已配置分享,请点击右上角「…」分享'),
|
||||
fail: () => ElMessage.warning('分享配置失败,请重试')
|
||||
})
|
||||
wx.updateTimelineShareData({ title, link, imgUrl })
|
||||
})
|
||||
wx.error(function (err) {
|
||||
console.error('[wx-share] config error', err)
|
||||
ElMessage.error('微信分享初始化失败,请确认已配置 JS 安全域名')
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[wx-share] failed', e)
|
||||
ElMessage.error(e?.msg || '微信分享失败,请重试')
|
||||
} finally {
|
||||
wxSharing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
load()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
pdfRenderToken++ // 让进行中的渲染尽早中断
|
||||
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -647,6 +817,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.content-frame { width: 100%; height: 700px; display: block; border: 0; min-width: 0; }
|
||||
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
|
||||
/* PDF.js 渲染容器 */
|
||||
.pdf-wrap { width: 100%; min-width: 0; max-height: 72vh; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
|
||||
.pdf-tip { padding: 80px 20px; text-align: center; color: #909399; font-size: 14px; }
|
||||
.pdf-tip .hint { font-size: 12px; color: #c0c4cc; }
|
||||
.pdf-error p { margin: 6px 0 0; }
|
||||
.pdf-viewer { width: 100%; background: #f2f3f5; padding: 8px 0; }
|
||||
.content-empty {
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
|
||||
@@ -50,10 +50,9 @@
|
||||
|
||||
<!-- 上传文件: PDF / PNG -->
|
||||
<div v-else class="plan-body plan-body-file">
|
||||
<iframe v-if="isPdf(plan.fileUrl)"
|
||||
:src="proxyUrl(plan.fileUrl)"
|
||||
class="content-frame"
|
||||
frameborder="0"></iframe>
|
||||
<PdfViewer v-if="isPdf(plan.fileUrl)"
|
||||
:url="plan.fileUrl"
|
||||
class="content-pdf" />
|
||||
<img v-else-if="isImg(plan.fileUrl)"
|
||||
:src="proxyUrl(plan.fileUrl)"
|
||||
class="content-img"
|
||||
@@ -78,6 +77,7 @@ import { useUserStore } from '@/store/user'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
import request from '@/utils/request'
|
||||
import PortalFooter from '@/components/PortalFooter.vue'
|
||||
import PdfViewer from '@/components/PdfViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -257,7 +257,7 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* 文件预览 */
|
||||
.plan-body-file { padding: 0; min-height: 600px; }
|
||||
.content-frame { width: 100%; height: 700px; display: block; border: 0; }
|
||||
.content-pdf { width: 100%; }
|
||||
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
|
||||
.content-empty { padding: 80px 20px; text-align: center; color: #c0c4cc; }
|
||||
.content-empty p { margin: 8px 0 0; }
|
||||
|
||||
@@ -61,9 +61,24 @@
|
||||
<el-link :underline="false" type="primary" @click="onView(row)">{{ row.projectName }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalSessions" label="总场次" width="80" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column prop="totalSessions" label="总场次" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.totalSessions" :underline="false" type="primary" @click="goMeetings(row, 'all')">{{ row.totalSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.doneSessions" :underline="false" type="primary" @click="goMeetings(row, 'done')">{{ row.doneSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.todoSessions" :underline="false" type="primary" @click="goMeetings(row, 'todo')">{{ row.todoSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalAmount" label="总金额" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="availableAmount" label="可用金额" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidLaborAmount" label="劳务费" width="120" align="right" :formatter="fmtMoney" />
|
||||
@@ -259,6 +274,16 @@ function onView(row) {
|
||||
router.push(`/sponsor/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
// 点击「总场次/已执行/未执行」列 → 跳 sponsor 会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限).
|
||||
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行) — 与 manager/Projects.vue 口径一致:
|
||||
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中); 未执行 = NOT_STARTED.
|
||||
function goMeetings(row, scope) {
|
||||
const q = { projectId: row.projectId }
|
||||
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
|
||||
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
|
||||
router.push({ path: '/sponsor/meetings', query: q })
|
||||
}
|
||||
|
||||
// ========== 执行单位评分 (与 manager/Projects.vue 完全相同的样式和逻辑) ==========
|
||||
const singleScoreModalOpen = ref(false)
|
||||
const singleScoreTargetRow = ref(null)
|
||||
|
||||
Reference in New Issue
Block a user