feat: 会议材料上传/发票OCR识别/用户管理增强 + 日志降噪 + 薪亿通接口规范
- 会议/劳务材料支持直接上传与提交审核 (BizMeetingMaterial 等) - 发票 OCR 识别增强 (InvoiceOcrService) - 现场照片/证书批量导入覆盖式回填 (ExcelUtil/AttendeeImportVo/CameraQrUpload) - 用户管理增强、超级管理员校验与权限拒绝处理 (SuperManagerChecker/AccessDeniedHandlerImpl) - 日志级别调整为 info/warn (生产降噪) - 新增薪亿通·个人票税接口规范文档 (自然人开票服务分册 + 轻量化对接分册) - .gitignore 排除 key.txt/*.rdb/*.key/tmp/opt 等敏感与本地文件 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -23,21 +23,24 @@
|
||||
</el-button>
|
||||
<span v-if="photos.length" class="cam-count">{{ photos.length }} 张</span>
|
||||
<span v-else class="cam-empty">未上传</span>
|
||||
<el-link v-if="photos.length" type="primary" :underline="false" @click="previewVisible = true">预览</el-link>
|
||||
<span v-if="polling" class="cam-polling">等待手机回传…</span>
|
||||
</div>
|
||||
|
||||
<!-- 多张缩略图: 点任意一张走 el-image 全屏轮播预览 (签到表多张 / 前后全景单张) -->
|
||||
<div v-if="photos.length" class="camera-thumbs">
|
||||
<el-image
|
||||
v-for="(p, i) in photos"
|
||||
:key="i"
|
||||
:src="p"
|
||||
:preview-src-list="photos"
|
||||
:initial-index="i"
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
</div>
|
||||
<!-- 预览弹窗: 不直接铺缩略图, 点「预览」打开 el-dialog 查看 (签到表多张 / 前后全景单张) -->
|
||||
<el-dialog v-model="previewVisible" :title="`${label}预览`" width="640px" append-to-body>
|
||||
<div class="preview-grid">
|
||||
<el-image
|
||||
v-for="(p, i) in photos"
|
||||
:key="i"
|
||||
:src="p"
|
||||
:preview-src-list="photos"
|
||||
:initial-index="i"
|
||||
fit="contain"
|
||||
class="preview-img"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
@@ -83,6 +86,7 @@ const PAGE_PATH = {
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const qrUrl = ref('')
|
||||
const qrLoading = ref(false)
|
||||
const polling = ref(false)
|
||||
@@ -184,14 +188,14 @@ onBeforeUnmount(stopPolling)
|
||||
.cam-count { color: #67c23a; font-size: 13px; }
|
||||
.cam-polling { color: #e6a23c; font-size: 12px; }
|
||||
|
||||
.camera-thumbs {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 8px;
|
||||
.preview-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 12px;
|
||||
max-height: 70vh; overflow-y: auto;
|
||||
}
|
||||
.thumb {
|
||||
width: 56px; height: 56px;
|
||||
.preview-img {
|
||||
width: 180px; height: 180px;
|
||||
border: 1px solid #ebeef5; border-radius: 4px;
|
||||
cursor: pointer;
|
||||
object-fit: contain; background: #f5f7fa;
|
||||
}
|
||||
|
||||
.qr-wrap { text-align: center; padding: 8px 0 4px; }
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="劳务打包上传结果"
|
||||
width="680px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="summary">
|
||||
<span v-if="result && result.updated != null">共回填 {{ result.updated }} 项</span>
|
||||
<span v-if="failTotal > 0" class="fail">,失败 {{ failTotal }} 项</span>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="errors.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="其他问题"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<div v-for="(e, i) in errors" :key="i" class="error-line">{{ e }}</div>
|
||||
</el-alert>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane v-if="detailRows.length" label="劳务明细表" name="detail">
|
||||
<el-table :data="detailRows" border size="small" :height="340">
|
||||
<el-table-column width="90" align="center" prop="rowNum" label="行号" />
|
||||
<el-table-column label="失败原因" prop="message" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="agreementRows.length" label="劳务协议" name="agreement">
|
||||
<el-table :data="agreementRows" border size="small" :height="340">
|
||||
<el-table-column width="180" prop="name" label="姓名/文件" show-overflow-tooltip />
|
||||
<el-table-column label="失败原因" prop="reason" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="expertRows.length" label="专家照片" name="expert">
|
||||
<el-table :data="expertRows" border size="small" :height="340">
|
||||
<el-table-column width="180" prop="name" label="姓名" show-overflow-tooltip />
|
||||
<el-table-column label="失败原因" prop="reason" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="close">知道了</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
// v-model 默认绑 modelValue / update:modelValue (Vue 3 规范)
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
result: { type: Object, default: null }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
// 打开时默认落到第一个有失败内容的 tab
|
||||
const activeTab = ref('detail')
|
||||
watch(() => props.result, (r) => {
|
||||
const detail = (r && r.detail) || {}
|
||||
const agreement = (r && r.agreement) || {}
|
||||
const expert = (r && r.expertPhoto) || {}
|
||||
if (detail.ngList && detail.ngList.length) activeTab.value = 'detail'
|
||||
else if (agreement.failures && agreement.failures.length) activeTab.value = 'agreement'
|
||||
else if (expert.failures && expert.failures.length) activeTab.value = 'expert'
|
||||
else activeTab.value = 'detail'
|
||||
}, { immediate: true })
|
||||
|
||||
const detailRows = computed(() => (props.result && props.result.detail && props.result.detail.ngList) || [])
|
||||
const agreementRows = computed(() => (props.result && props.result.agreement && props.result.agreement.failures) || [])
|
||||
const expertRows = computed(() => (props.result && props.result.expertPhoto && props.result.expertPhoto.failures) || [])
|
||||
const errors = computed(() => (Array.isArray(props.result && props.result.errors) ? props.result.errors : []))
|
||||
|
||||
const failTotal = computed(() => {
|
||||
const r = props.result || {}
|
||||
const detailNg = (r.detail && r.detail.ngNum) || 0
|
||||
const agreement = (r.agreement && r.agreement.failures) || []
|
||||
const expert = (r.expertPhoto && r.expertPhoto.failures) || []
|
||||
return detailNg + agreement.length + expert.length + errors.value.length
|
||||
})
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary { margin-bottom: 12px; font-size: 14px; }
|
||||
.summary .fail { color: #f56c6c; }
|
||||
.error-line { line-height: 22px; }
|
||||
</style>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemoveAt(i)">移除</el-link>
|
||||
<a class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||
<a v-if="downloadable" class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick">
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
|
||||
<a class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||
<a v-if="downloadable" class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||
</div>
|
||||
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
|
||||
<div v-if="showPreview" class="preview-box">
|
||||
@@ -105,6 +105,8 @@ const props = defineProps({
|
||||
maxSize: { type: Number, default: 200 }, // MB
|
||||
block: { type: Boolean, default: false },
|
||||
readonly: { type: Boolean, default: false },
|
||||
downloadable: { type: Boolean, default: true }, // 是否显示"下载"按钮 (如 sponsor 只读查看时不显示)
|
||||
maxCount: { type: Number, default: 0 }, // 多文件模式最多文件数 (0 = 不限制)
|
||||
showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他)
|
||||
multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串
|
||||
})
|
||||
@@ -232,6 +234,10 @@ async function onFileChange(e) {
|
||||
const list = [...modelList.value]
|
||||
let added = 0
|
||||
for (const file of files) {
|
||||
if (props.maxCount && list.length >= props.maxCount) {
|
||||
ElMessage.warning(`最多上传 ${props.maxCount} 个文件`)
|
||||
break
|
||||
}
|
||||
if (!validateFile(file)) continue
|
||||
uploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
<el-table-column prop="orgName" label="单位名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="管理员" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isOrgAdmin(row) ? 'success' : 'danger'" disable-transitions>
|
||||
{{ isOrgAdmin(row) ? '是' : '否' }}
|
||||
<el-tag :type="isAdminAccount(row) ? 'success' : 'danger'" disable-transitions>
|
||||
{{ isAdminAccount(row) ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -100,6 +100,12 @@
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.roleType === 'manager'" label="全局管理员">
|
||||
<el-radio-group v-model="editForm.accountType">
|
||||
<el-radio value="SUB">否 (普通合规员)</el-radio>
|
||||
<el-radio value="MAIN">是 (可见全量)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -130,6 +136,12 @@
|
||||
<template v-if="isSimpleRole">
|
||||
<el-form-item label="姓名"><el-input v-model="createForm.nickName" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
|
||||
<el-form-item v-if="createForm.roleType === 'manager'" label="全局管理员" required>
|
||||
<el-radio-group v-model="createForm.accountType">
|
||||
<el-radio value="SUB">否 (普通合规员)</el-radio>
|
||||
<el-radio value="MAIN">是 (可见全量)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- doctor: 评审专家 -->
|
||||
@@ -201,10 +213,12 @@ const ROLE_LABEL = {
|
||||
}
|
||||
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
// 机构管理员 = 执行方/支持方单位的主账号。
|
||||
// admin/manager/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员
|
||||
function isOrgAdmin(row) {
|
||||
return (row.roleType === 'executor' || row.roleType === 'sponsor') && row.accountType === 'MAIN'
|
||||
// 管理员 = account_type 为 MAIN 的角色:
|
||||
// executor/sponsor: 机构主账号 (机构管理员)
|
||||
// manager: 全局管理员 (可见全量, 复用 account_type 标记)
|
||||
// admin/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员
|
||||
function isAdminAccount(row) {
|
||||
return (row.roleType === 'executor' || row.roleType === 'sponsor' || row.roleType === 'manager') && row.accountType === 'MAIN'
|
||||
}
|
||||
|
||||
const q = reactive({ userName: '', nickName: '', phonenumber: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
|
||||
@@ -232,12 +246,13 @@ function reset() {
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ userId: null, userName: '', nickName: '', roleType: '', phonenumber: '', email: '' })
|
||||
const editForm = reactive({ userId: null, userName: '', nickName: '', roleType: '', phonenumber: '', email: '', accountType: 'SUB' })
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
userId: row.userId, userName: row.userName, nickName: row.nickName,
|
||||
roleType: row.roleType, phonenumber: row.phonenumber, email: row.email
|
||||
roleType: row.roleType, phonenumber: row.phonenumber, email: row.email,
|
||||
accountType: row.accountType || 'SUB'
|
||||
})
|
||||
editVisible.value = true
|
||||
}
|
||||
@@ -246,6 +261,10 @@ async function saveEdit() {
|
||||
try {
|
||||
// 2026-08-16: 角色统一存 sys_user.role_type, 单次 PUT 即可 (mapper updateUser 已包含 role_type 字段)
|
||||
await request({ url: '/system/user', method: 'put', data: { ...editForm } })
|
||||
// 合规人员: 全局管理员标记走独立端点写 account_type (标准 updateUser 不含 account_type)
|
||||
if (editForm.roleType === 'manager') {
|
||||
await request({ url: '/system/user/changeManagerAdmin', method: 'put', data: { userId: editForm.userId, accountType: editForm.accountType } })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
load()
|
||||
@@ -330,7 +349,8 @@ function openCreate() {
|
||||
}
|
||||
|
||||
function onRoleChange() {
|
||||
createForm.accountType = 'MAIN'
|
||||
// manager 默认 SUB (普通合规员); executor/sponsor 默认 MAIN (新建单位)
|
||||
createForm.accountType = createForm.roleType === 'manager' ? 'SUB' : 'MAIN'
|
||||
orgOptions.value = []
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
|
||||
{{ row.projectNo }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.projectNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140">
|
||||
<template #default="{ row }">
|
||||
@@ -114,7 +121,7 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList } from '@/api/public'
|
||||
@@ -124,6 +131,7 @@ import PdfViewer from '@/components/PdfViewer.vue'
|
||||
// Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议
|
||||
// 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const signedStatus = ref(String(route.query.signedStatus || '') || '')
|
||||
|
||||
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
|
||||
@@ -169,6 +177,12 @@ function onViewLabor(row) {
|
||||
laborOpen.value = true
|
||||
}
|
||||
|
||||
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
|
||||
function onViewProject(row) {
|
||||
if (row.projectId == null) return
|
||||
router.push(`/doctor/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
|
||||
function isPdf(url) {
|
||||
if (!url) return false
|
||||
|
||||
@@ -253,7 +253,7 @@ async function load() {
|
||||
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
|
||||
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
|
||||
signed.value = true
|
||||
laborPdfUrl.value = data.laborProtocol || ''
|
||||
laborPdfUrl.value = (data.laborProtocol || '').split(',')[0].trim()
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
|
||||
@@ -37,7 +37,14 @@
|
||||
<GrTable :data="rows" v-loading="loading" stripe border
|
||||
:main-cols="['projectNo', 'meetingName']"
|
||||
>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170" fixed />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
|
||||
{{ row.projectNo }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.projectNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip>
|
||||
@@ -147,6 +154,12 @@ function reset() {
|
||||
|
||||
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
|
||||
|
||||
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
|
||||
function onViewProject(row) {
|
||||
if (row.projectId == null) return
|
||||
router.push(`/executor/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
|
||||
|
||||
// 冻结中 → 上传材料置灰不可点 (仅 executor 列表; 解冻后 isFrozen 翻 0 自动恢复)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
|
||||
<div class="info-row"><span class="info-label">场次/期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
|
||||
<div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ displayTotalPeriods ?? '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会议开始时间:</span><span class="info-value">{{ fmtDateTime(row.startTime) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">支持单位:</span><span class="info-value">{{ row.orgName || '-' }}</span></div>
|
||||
@@ -95,9 +95,6 @@
|
||||
<el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="应发金额" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
|
||||
@@ -113,6 +110,9 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
|
||||
@@ -121,7 +121,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="劳务协议" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link :underline="false" v-if="protocolUrl(row)" type="primary" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-link>
|
||||
<el-link :underline="false" v-if="laborProtocolFiles(row).length" type="primary" @click="openLaborProtocol(row)">查看</el-link>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -171,6 +171,10 @@
|
||||
<span class="fee-label">劳务费合计:</span>
|
||||
<span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="isManager" class="attendee-legend">
|
||||
<span class="legend-item"><span class="legend-name">红色姓名</span>:未确认参会意向</span>
|
||||
<span class="legend-item"><span class="legend-row">淡红背景</span>:实发金额与所选角色配置金额不一致</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-list" style="margin-top: 16px;">
|
||||
@@ -198,16 +202,17 @@
|
||||
<template v-else>
|
||||
<div v-if="isCameraSubType(r.subType)" class="camera-upload-row">
|
||||
<CameraQrUpload v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" accept=".png,.jpg,.jpeg,.gif,.webp,.bmp" placeholder="直接上传(本地图片)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
|
||||
<!-- 直接上传本地图片: 仅 manager/admin 可见; 其他角色只能扫码拍照 (隐藏控件而非置 readonly) -->
|
||||
<OssFileUploader v-if="isManager || isAdmin" v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" accept=".png,.jpg,.jpeg,.gif,.webp,.bmp" placeholder="直接上传(本地图片)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
|
||||
</div>
|
||||
<template v-else-if="r.subType === 'L_ESIGN_IN'">
|
||||
<div class="esign-actions">
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" :downloadable="!isSponsor" />
|
||||
<el-button v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" size="small" type="success" :loading="esignZipSubmitting" @click="onPickEsignZip">上传 zip</el-button>
|
||||
<input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" />
|
||||
</div>
|
||||
</template>
|
||||
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
|
||||
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" :downloadable="!isSponsor" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
</template>
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
@@ -228,14 +233,28 @@
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<span class="file-label">日程海报:</span>
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" />
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" :downloadable="!isSponsor" />
|
||||
<div class="file-hint">会议日程海报</div>
|
||||
</div>
|
||||
<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 || laborLocked" />
|
||||
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" :downloadable="!isSponsor" />
|
||||
<div class="file-hint">会议邀请函</div>
|
||||
</div>
|
||||
<!-- 打包上传: 下载空目录模板 + 上传劳务材料 (劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函) -->
|
||||
<div v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" class="file-row">
|
||||
<span class="file-label">打包上传:</span>
|
||||
<div class="agreement-actions">
|
||||
<el-button size="small" :loading="laborTplLoading" @click="onDownloadLaborTemplate">下载目录</el-button>
|
||||
<el-button size="small" type="success" :loading="laborSubmitting" @click="onPickLaborZip">上传劳务材料</el-button>
|
||||
<input ref="laborZipInput" type="file" accept=".zip" style="display:none" @change="onLaborZipChange" />
|
||||
</div>
|
||||
<div v-if="laborSubmitting" class="upload-progress">
|
||||
<el-progress :percentage="laborProgress" :stroke-width="12" :show-text="false" />
|
||||
<span class="upload-progress-status">{{ laborStatus || '上传中' }}</span>
|
||||
</div>
|
||||
<div class="file-hint">下载空目录模板后放入对应材料压缩上传回填</div>
|
||||
</div>
|
||||
<!-- 临时: manager / executor 详情页隐掉海报生成 (含 生成海报/预览海报) -->
|
||||
<div v-if="!isManager && !isExecutor && !isReadonly" class="file-row">
|
||||
<span class="file-label">海报生成:</span>
|
||||
@@ -274,7 +293,7 @@
|
||||
</div>
|
||||
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :accept="r.accept || SERVICE_ACCEPT_ALL" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable || serviceLocked" @preview="(url) => openPreview([url], r.label)" />
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :max-count="SERVICE_MAX_FILES" :dir="`ry8080/meeting/${meetingId}/service/`" :accept="r.accept || SERVICE_ACCEPT_ALL" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable || serviceLocked" :downloadable="!isSponsor" @preview="(url) => openPreview([url], r.label)" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
@@ -303,7 +322,7 @@
|
||||
<div class="file-list">
|
||||
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" :downloadable="!isSponsor" />
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +331,7 @@
|
||||
<div class="file-list">
|
||||
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
|
||||
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" :downloadable="!isSponsor" />
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,7 +460,7 @@
|
||||
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')">
|
||||
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
|
||||
<el-input v-model="attendeeDialog.form.idCard" placeholder="身份证号/护照号" />
|
||||
</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/`" />
|
||||
@@ -553,6 +572,7 @@
|
||||
<div v-else class="text-muted">尚未生成海报</div>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
<LaborUploadResultDialog v-model="laborResultOpen" :result="laborResult" />
|
||||
|
||||
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
|
||||
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
|
||||
@@ -587,6 +607,22 @@
|
||||
<div v-else class="text-muted" style="padding: 24px 0; text-align: center;">暂无可预览的签到数据</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 劳务协议多文件列表: 点击劳务协议列 (多文件) 弹出, 表格列出各文件可分别预览/下载 -->
|
||||
<el-dialog v-model="protocolListOpen" :title="protocolListTitle" width="720px" append-to-body :close-on-click-modal="false">
|
||||
<el-table :data="protocolListFiles" size="small" border max-height="480">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column label="文件名" min-width="300" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link :underline="false" type="primary" @click="openPreview([row.url], '劳务协议')">预览</el-link>
|
||||
<el-link v-if="!isSponsor" :underline="false" type="primary" style="margin-left: 12px;" @click="openNewTab(row.url)">下载</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
@@ -606,6 +642,7 @@ import CameraQrUpload from '@/components/CameraQrUpload.vue'
|
||||
import OssImageUploader from '@/components/OssImageUploader.vue'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import ImportResultDialog from '@/components/ImportResultDialog.vue'
|
||||
import LaborUploadResultDialog from '@/components/LaborUploadResultDialog.vue'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
|
||||
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
|
||||
@@ -638,6 +675,10 @@ const previewUrls = ref([])
|
||||
const previewTitle = ref('附件预览')
|
||||
/** 电子签到表 (sponsor) 脱敏预览 dialog 状态 */
|
||||
const esignPreview = reactive({ visible: false, sheets: [], activeName: 's0' })
|
||||
/** 劳务协议多文件列表 dialog 状态 (点击劳务协议列多文件时弹出) */
|
||||
const protocolListOpen = ref(false)
|
||||
const protocolListTitle = ref('劳务协议')
|
||||
const protocolListFiles = ref([]) // [{url, name}]
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('labor')
|
||||
const saving = ref(false)
|
||||
@@ -677,11 +718,7 @@ const displayTotalPeriods = computed(() => {
|
||||
})
|
||||
const periodDisplay = computed(() => {
|
||||
const p = row.value.periodNo
|
||||
const t = displayTotalPeriods.value
|
||||
if (p == null && t == null) return '-'
|
||||
if (p == null) return `${t}`
|
||||
if (t == null) return `${p}`
|
||||
return `${p}/${t}`
|
||||
return p == null ? '-' : `${p}`
|
||||
})
|
||||
const AUDIT_STAGE_LABEL = { NOT_SUBMITTED: '未提交', SUBMITTED: '已提交', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
|
||||
@@ -691,10 +728,15 @@ function fmtMoney(v) {
|
||||
return (isNaN(n) ? 0 : n).toFixed(2)
|
||||
}
|
||||
function isOne(v) { return v === 1 || v === '1' || v === true }
|
||||
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */
|
||||
function protocolUrl(row) {
|
||||
if (isSponsor.value) return row.laborProtocolMasked || ''
|
||||
return row.laborProtocol || ''
|
||||
/** 劳务协议文件列表: 单 URL / 逗号分隔多 URL → [{url, name}] (sponsor 看脱敏版) */
|
||||
function laborProtocolFiles(row) {
|
||||
const raw = isSponsor.value ? (row.laborProtocolMasked || '') : (row.laborProtocol || '')
|
||||
return raw.split(',').map(s => s.trim()).filter(Boolean).map(url => ({ url, name: fileNameFromUrl(url) }))
|
||||
}
|
||||
/** 从 OSS URL 末段还原文件名 (decodeURIComponent 处理中文) */
|
||||
function fileNameFromUrl(url) {
|
||||
const seg = ((url || '').split('?')[0]).split('/').pop() || ''
|
||||
try { return decodeURIComponent(seg) } catch (e) { return seg }
|
||||
}
|
||||
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
|
||||
function idCardFiles(row) {
|
||||
@@ -715,18 +757,37 @@ function openOnSitePhotos(row) {
|
||||
openPreview(onSitePhotoFiles(row).map(u => ossThumb(u)), '现场照片')
|
||||
}
|
||||
|
||||
/** 点击劳务协议列: 单文件直接预览, 多文件弹文件列表 dialog (表格内逐个预览/下载) */
|
||||
function openLaborProtocol(row) {
|
||||
const files = laborProtocolFiles(row)
|
||||
if (!files.length) return
|
||||
if (files.length === 1) {
|
||||
openPreview(files.map(f => f.url), '劳务协议')
|
||||
return
|
||||
}
|
||||
protocolListTitle.value = row.name ? `劳务协议 · ${row.name}` : '劳务协议'
|
||||
protocolListFiles.value = files
|
||||
protocolListOpen.value = true
|
||||
}
|
||||
|
||||
/** 新窗口打开文件 (下载/预览, OSS Content-Disposition 为 inline 时可在新页直接查看) */
|
||||
function openNewTab(url) {
|
||||
if (!url) return
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
// ===================== 材料管理 4 个 tab =====================
|
||||
const ROW_CONFIG = [
|
||||
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' },
|
||||
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg', multi: true },
|
||||
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '', multi: true },
|
||||
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '', multi: true },
|
||||
{ type: 'LABOR', subType: 'L_ESIGN_IN', label: '电子签到表', hint: '会议电子签到表', accept: '.xls,.xlsx,.csv' },
|
||||
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
|
||||
@@ -738,6 +799,8 @@ const ROW_CONFIG = [
|
||||
|
||||
/** 会务材料上传默认扩展名 (含 Office/txt); 个别行 (如盖章结算单) 用 r.accept 覆盖 */
|
||||
const SERVICE_ACCEPT_ALL = '.pdf,.png,.jpg,.jpeg,.zip,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.txt'
|
||||
/** 会务材料单类最多文件数 (与后端 SERVICE_MAX_FILES 对齐, 多文件 JSON 数组写 oss_url) */
|
||||
const SERVICE_MAX_FILES = 50
|
||||
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
|
||||
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_ESIGN_IN', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
|
||||
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
|
||||
@@ -1086,8 +1149,7 @@ const attendeeRules = {
|
||||
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
|
||||
idCard: [
|
||||
{ required: true, message: '请填写身份证号', trigger: 'blur' },
|
||||
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
|
||||
{ required: true, message: '请填写身份证号/护照号', trigger: 'blur' }
|
||||
],
|
||||
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
|
||||
@@ -2185,9 +2247,19 @@ function isImageUrl(url) {
|
||||
return /\.(jpe?g|png)$/i.test(url || '')
|
||||
}
|
||||
|
||||
/** 判断是否可识别: 图片或 PDF */
|
||||
/** 判断是否可识别: 图片/PDF/zip; 会务材料多文件 (oss_url 存 JSON 数组) 任一可识别即 true */
|
||||
function isRecognizable(url) {
|
||||
return /\.(jpe?g|png|pdf|zip)$/i.test(url || '')
|
||||
if (!url) return false
|
||||
const s = String(url).trim()
|
||||
if (s.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(s)
|
||||
return Array.isArray(arr) && arr.some(u => /\.(jpe?g|png|pdf|zip)$/i.test(String(u || '').split('?')[0]))
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return /\.(jpe?g|png|pdf|zip)$/i.test(s)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2541,6 +2613,127 @@ async function onServiceZipChange(ev) {
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 劳务材料 打包上传 (下载空目录模板 + 上传按目录分类回填) =====================
|
||||
const laborTplLoading = ref(false)
|
||||
const laborSubmitting = ref(false)
|
||||
const laborProgress = ref(0)
|
||||
const laborStatus = ref('')
|
||||
const laborZipInput = ref(null)
|
||||
const laborResultOpen = ref(false)
|
||||
const laborResult = ref(null)
|
||||
|
||||
/** 下载"劳务材料"空目录模板 zip (劳务明细表/劳务协议/…/邀请函; 非 admin/manager 不含签到表/前全景/后全景) */
|
||||
async function onDownloadLaborTemplate() {
|
||||
laborTplLoading.value = true
|
||||
try {
|
||||
const resp = await fetch(
|
||||
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/laborTemplate/${meetingId.value}`,
|
||||
{ headers: importHeaders.value }
|
||||
)
|
||||
if (!resp.ok) throw new Error('下载失败')
|
||||
const blob = await resp.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = '劳务材料目录.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
laborTplLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPickLaborZip() {
|
||||
laborZipInput.value?.click()
|
||||
}
|
||||
|
||||
async function onLaborZipChange(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许重复选择同一文件
|
||||
if (!file) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
laborSubmitting.value = true
|
||||
laborProgress.value = 0
|
||||
laborStatus.value = ''
|
||||
let jobId = null
|
||||
try {
|
||||
const resp = await request.post(
|
||||
`/business/meetingMaterial/uploadLaborMaterials?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{
|
||||
timeout: 600000,
|
||||
__silentError: true,
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) laborProgress.value = Math.min(100, Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
}
|
||||
)
|
||||
jobId = (resp && resp.data && resp.data.jobId) || null
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
laborSubmitting.value = false
|
||||
laborProgress.value = 0
|
||||
laborStatus.value = ''
|
||||
return
|
||||
}
|
||||
if (!jobId) {
|
||||
ElMessage.error('上传失败: 未获取任务编号')
|
||||
laborSubmitting.value = false
|
||||
laborProgress.value = 0
|
||||
laborStatus.value = ''
|
||||
return
|
||||
}
|
||||
laborProgress.value = 100
|
||||
pollUploadProgress(jobId, {
|
||||
onTick: (p) => {
|
||||
laborStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
|
||||
},
|
||||
onDone: async (result) => {
|
||||
const data = result || {}
|
||||
laborResult.value = data
|
||||
// 刷新材料/参会人 (劳务协议·专家照片回填在参会人上); 日程海报/邀请函 回填在会议字段上, 单独重拉会议
|
||||
loadMaterials()
|
||||
loadAttendees()
|
||||
try {
|
||||
const info = await bizGet('meeting', meetingId.value)
|
||||
const m = (info && (info.data || info)) || {}
|
||||
scheduleUrl.value = m.scheduleUrl || ''
|
||||
invitationUrl.value = m.invitationUrl || ''
|
||||
row.value.scheduleUrl = m.scheduleUrl || ''
|
||||
row.value.invitationUrl = m.invitationUrl || ''
|
||||
} catch (e) { /* 会议字段刷新失败忽略 */ }
|
||||
laborSubmitting.value = false
|
||||
laborProgress.value = 0
|
||||
laborStatus.value = ''
|
||||
if (hasLaborFailures(data)) {
|
||||
laborResultOpen.value = true
|
||||
} else {
|
||||
ElMessage.success(`已回填 ${data.updated ?? 0} 项劳务材料`)
|
||||
}
|
||||
},
|
||||
onFail: (msg) => {
|
||||
ElMessage.error(msg)
|
||||
laborSubmitting.value = false
|
||||
laborProgress.value = 0
|
||||
laborStatus.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 是否存在需弹窗展示的失败 (劳务明细表行 / 劳务协议 / 专家照片 / 其他目录错误) */
|
||||
function hasLaborFailures(data) {
|
||||
const detail = (data && data.detail) || {}
|
||||
const agreement = (data && data.agreement) || {}
|
||||
const expert = (data && data.expertPhoto) || {}
|
||||
const errors = Array.isArray(data && data.errors) ? data.errors : []
|
||||
return (detail.ngNum || 0) > 0
|
||||
|| (Array.isArray(agreement.failures) && agreement.failures.length > 0)
|
||||
|| (Array.isArray(expert.failures) && expert.failures.length > 0)
|
||||
|| errors.length > 0
|
||||
}
|
||||
|
||||
// ===================== 导航 =====================
|
||||
function goBack() {
|
||||
// 角色感知返回: 按 currentRole 跳各自会议列表 (admin/manager/executor/sponsor)
|
||||
@@ -2600,8 +2793,8 @@ onBeforeUnmount(() => {
|
||||
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
/* 电子签到表 多文件上传 + zip 按钮 纵向排列 */
|
||||
.esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存 */
|
||||
.camera-upload-row { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存; 与左侧 label 垂直居中对齐 */
|
||||
.camera-upload-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.camera-upload-row :deep(.camera-qr-upload) { width: auto; flex: 0 0 auto; }
|
||||
.camera-upload-row :deep(.ht-file-upload) { flex: 1 1 0; min-width: 0; }
|
||||
.upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; }
|
||||
@@ -2653,6 +2846,10 @@ onBeforeUnmount(() => {
|
||||
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
|
||||
.attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; }
|
||||
.attendee-subtitle { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 12px; }
|
||||
.attendee-legend { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0 0; font-size: 12px; color: #8c8c8c; }
|
||||
.attendee-legend .legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.attendee-legend .legend-name { color: #f56c6c; font-weight: 600; }
|
||||
.attendee-legend .legend-row { color: #c45656; background: #fef0f0; border: 1px solid #fde2e2; padding: 0 6px; border-radius: 3px; }
|
||||
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
|
||||
|
||||
|
||||
@@ -46,7 +46,14 @@
|
||||
:main-cols="['projectNo', 'meetingName']"
|
||||
>
|
||||
<el-table-column v-if="isRole('admin', 'manager')" type="selection" width="44" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170" fixed />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="170" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
|
||||
{{ row.projectNo }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.projectNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip>
|
||||
@@ -191,6 +198,8 @@ const breadcrumb = computed(() =>
|
||||
)
|
||||
// 详情路由前缀: admin 跳 admin-meetings-detail, manager 跳 manager-meetings-detail
|
||||
const detailBase = computed(() => `/${roleSegment.value}/meetings`)
|
||||
// 项目详情路由前缀: 各角色跳各自 projects/detail (admin/manager/sponsor/leader 均有该路由)
|
||||
const projectDetailBase = computed(() => `/${roleSegment.value}/projects/detail`)
|
||||
// 修改/复制路由名: 按角色选 admin-meetings-new 或 manager-meetings-new
|
||||
const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'manager-meetings-new')
|
||||
|
||||
@@ -260,6 +269,11 @@ function viewDetail(row) {
|
||||
function viewOnly(row) {
|
||||
router.push(`${detailBase.value}/view/${row.meetingId}`)
|
||||
}
|
||||
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
|
||||
function onViewProject(row) {
|
||||
if (row.projectId == null) return
|
||||
router.push(`${projectDetailBase.value}/${row.projectId}`)
|
||||
}
|
||||
// 操作列"更多"下拉分发: 把 cmd 映射到现有 handler
|
||||
function onMoreAction(cmd, row) {
|
||||
if (cmd === 'view') viewOnly(row)
|
||||
|
||||
Reference in New Issue
Block a user