feat: 会议ID应用赋值 + 会议表单/详情优化 + admin工作台统计与用户管理

- meetingId 由 DB 自增改为应用赋值 10 位数字 (yyMMdd + 4 位序列, Redis 按日期计数)
- 会议开始时间不能晚于结束时间校验; 参会人表单身份证附件/角色单独一行
- 会议详情左右列比例 7:3 -> 8:2 (材料审核列收窄)
- admin/workbench 用户总数/角色类型数/各角色分布统计修复 (改用 listByRole 按 role_type 过滤)
- 新增 admin 用户管理接口 + 门户页脚 + H5 签到/上传优化
This commit is contained in:
郭庆泰
2026-08-25 23:33:14 +08:00
parent 591a43fe61
commit ea2024d083
66 changed files with 1454 additions and 1228 deletions
+37 -16
View File
@@ -33,8 +33,9 @@
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中</el-tag>
</div>
<!-- 监察员 + 执行人员 (各占整行, 2 ). 临时: manager 详情页隐掉 -->
<template v-if="!isManager">
<!-- 监察员 + 执行人员: 暂时整体注释掉 (所有角色都不看, admin). 需要时取消注释恢复 -->
<!--
<template v-if="!isManager && !isExecutor && !isSponsor">
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
@@ -52,6 +53,7 @@
</div>
</div>
</template>
-->
</div>
</div>
@@ -99,7 +101,7 @@
</el-table-column>
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<a v-if="row.idCardAttachments" :href="row.idCardAttachments.split(',')[0]" target="_blank" class="file-link">查看</a>
<el-button v-if="idCardFiles(row).length" link type="primary" size="small" @click="openPreview(idCardFiles(row), '身份证')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -119,7 +121,7 @@
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="protocolUrl(row)" :href="protocolUrl(row)" target="_blank" class="file-link">查看</a>
<el-button v-if="protocolUrl(row)" link type="primary" size="small" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -187,7 +189,7 @@
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<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>
@@ -206,13 +208,13 @@
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
<div class="file-hint">会议日程海报</div>
</div>
<div class="file-row">
<div v-if="!isExecutor" 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>
</div>
<!-- 临时: manager 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isReadonly" class="file-row">
<!-- 临时: manager / executor 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !isReadonly" class="file-row">
<span class="file-label">海报生成:</span>
<div class="poster-actions">
<el-button v-if="!isSponsor" size="small" type="primary" :loading="genPosterLoading" @click="onGeneratePoster">生成海报</el-button>
@@ -225,7 +227,7 @@
<div class="file-list">
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<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>
@@ -250,7 +252,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" :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" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
@@ -259,7 +261,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" :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" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
@@ -275,7 +277,7 @@
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<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>
<el-button v-if="!isSponsor && !isReadonly" type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button v-if="!isSponsor && !isReadonly && materialsEditable" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</div>
@@ -314,7 +316,7 @@
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div v-if="!(round.compliance?.auditResult === 'REJECTED')" :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
@@ -388,7 +390,7 @@
<el-form-item label="身份证号">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</el-form-item>
<el-form-item label="身份证附件">
<el-form-item label="身份证附件" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
</el-form-item>
<el-form-item label="科室">
@@ -416,7 +418,7 @@
<el-form-item label="银行详细地址">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色">
<el-form-item label="角色" style="grid-column: 1 / -1">
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
</el-form-item>
<el-form-item label="应发金额">
@@ -497,6 +499,7 @@
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
@@ -517,6 +520,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 Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
@@ -543,6 +547,10 @@ const genPosterVisible = ref(false)
const genForm = reactive({ addText: false, textColor: '#FFFFFF' })
/** 海报预览 dialog 显隐 */
const posterPreviewVisible = ref(false)
/** 通用附件预览 (身份证多图 / 劳务协议 PDF), 复用 components/Preview.vue */
const previewOpen = ref(false)
const previewUrls = ref([])
const previewTitle = ref('附件预览')
const loading = ref(false)
const activeTab = ref('labor')
const saving = ref(false)
@@ -608,6 +616,10 @@ function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
}
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
@@ -720,6 +732,8 @@ const frozen = computed(() => isOne(row.value.isFrozen))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
// 材料可编辑 (保存修改): 未提交 / 被驳回 时; 提交审核后 (SUBMITTED) / 通过后 (APPROVED) 锁定, 隐藏保存按钮
const materialsEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
function canComplianceAudit() {
if (!isManager.value) return false
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
@@ -1501,6 +1515,13 @@ function openPosterPreview() {
posterPreviewVisible.value = true
}
/** 通用附件预览: urls 数组 → Preview 组件 (多图走画廊, 单 PDF 走 iframe) */
function openPreview(urls, title) {
previewUrls.value = Array.isArray(urls) ? urls : [urls]
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
async function onSave() {
if (saving.value) return
saving.value = true
@@ -1803,7 +1824,7 @@ onBeforeUnmount(stopFeePolling)
.info-value.fee-val { font-weight: 600; color: #f5222d; }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 3fr); gap: 16px; align-items: flex-start; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.