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:
2026-11-12 01:13:17 +00:00
co-authored by Claude Code
parent 44d4920777
commit 3f587fbd9f
39 changed files with 1544 additions and 192 deletions
+22 -18
View File
@@ -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>
+8 -2
View File
@@ -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
+29 -9
View File
@@ -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 = []
}
+16 -2
View File
@@ -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
+1 -1
View File
@@ -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
+14 -1
View File
@@ -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 自动恢复)
+236 -39
View File
@@ -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; }
+15 -1
View File
@@ -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)