feat: 会议批量审核 + 公示按发布时间倒序 + 现场照片 dialog 预览 + 重复报错精确

- 会议列表 manager 批量合规审核 (批量通过/退回, best-effort 逐条回执)
- 公示列表按 publish_time 倒序, 发布/开通写本地 publish_time (修 toISOString UTC 时区 bug)
- 签到表/前全景/后全景「查看照片」改 dialog 预览 (复用 Preview.vue)
- 手机号/用户名重复报错精确提示 (后端 fieldMap + 前端 duplicateTip)
- 医生首页欢迎语改「欢迎使用合规系统」
This commit is contained in:
郭庆泰
2026-08-26 00:41:33 +08:00
parent ea2024d083
commit b5b3e3a213
15 changed files with 387 additions and 115 deletions
+16 -3
View File
@@ -21,11 +21,14 @@
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
<el-icon><CameraFilled /></el-icon>&nbsp;扫码拍照
</el-button>
<a v-if="modelValue" :href="modelValue" target="_blank" class="cam-link">查看照片</a>
<el-button v-if="modelValue" link type="primary" size="small" @click="openPreview">查看照片</el-button>
<span v-else class="cam-empty">未上传</span>
<span v-if="polling" class="cam-polling">等待手机回传</span>
</div>
<!-- 照片预览: 复用全局 Preview.vue (dialog 内预览, 图片/PDF 自适应, 非新窗口) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<el-dialog
v-model="visible"
:title="`扫码拍照 - ${label}`"
@@ -52,6 +55,7 @@ import request from '@/utils/request'
import { ElMessage } from 'element-plus'
import { CameraFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import Preview from '@/components/Preview.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -73,6 +77,9 @@ const visible = ref(false)
const qrUrl = ref('')
const qrLoading = ref(false)
const polling = ref(false)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
let pollTimer = null
let baseline = ''
@@ -142,6 +149,14 @@ function onClosed() {
stopPolling()
}
/** 打开照片预览 dialog (复用全局 Preview.vue, 图片/PDF 自适应) */
function openPreview() {
if (!props.modelValue) return
previewUrl.value = props.modelValue
previewTitle.value = props.label
previewOpen.value = true
}
onBeforeUnmount(stopPolling)
</script>
@@ -151,8 +166,6 @@ onBeforeUnmount(stopPolling)
display: flex; align-items: center; gap: 12px;
min-height: 36px;
}
.cam-link { color: var(--brand-primary); text-decoration: none; font-size: 13px; }
.cam-link:hover { text-decoration: underline; }
.cam-empty { color: #c0c4cc; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; }
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="welcome-bar">
<div class="welcome-text">
<h2>下午好,{{ displayName }}专家</h2>
<p>欢迎使用项目管理系统</p>
<p>欢迎使用合规系统</p>
</div>
<div class="welcome-time">
<div class="now">{{ nowTime }}</div>
+8 -2
View File
@@ -213,6 +213,12 @@ function isDuplicateError(msg) {
return /已存在|重复|duplicate|uk_person_phone/i.test(msg || '')
}
// 按后端原因精确提示: 登录账号(用户名) vs 手机号 (后端分别抛 "登录账号已存在" / DB uk_person_phone)
function duplicateTip(msg) {
if (/登录账号|用户名|login/i.test(msg || '')) return '登录账号已存在,请更换'
return '该手机号已存在,请更换'
}
async function onSave() {
try {
await formRef.value.validate()
@@ -248,12 +254,12 @@ async function onSave() {
}
} else {
const msg = res?.msg || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
}
} catch (e) {
const msg = e?.msg || e?.message || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
} finally {
saving.value = false
+7 -1
View File
@@ -145,6 +145,12 @@ const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与 el-date-picker 的本地时间口径一致; 不能用 toISOString 的 UTC)
function nowLocalDatetime() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 项目负责人候选 (合规管理员, sys_user.role_type='manager')
const managerOptions = ref([])
@@ -333,7 +339,7 @@ async function submit(mode = 'save') {
// 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
payload.publishTime = nowLocalDatetime()
}
// 多执行方分配已移至"项目分配"页面单独管理
try {
+83 -1
View File
@@ -35,9 +35,9 @@
<!-- ========== 批量按钮 (admin/manager 共用: 批量提交 + 批量删除; sponsor 隐藏) ========== -->
<div v-if="isRole('admin', 'manager')" class="batch-bar">
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadService">批量下载会务</el-button>
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadLabor">批量下载劳务</el-button>
<el-button v-if="isRole('manager')" type="primary" :disabled="!selectedIds.length" @click="onBatchAudit">批量审核</el-button>
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} </span>
</div>
@@ -119,6 +119,28 @@
<el-button type="primary" :loading="approvalSaving" @click="onApprovalConfirm">确定</el-button>
</template>
</el-dialog>
<!-- 合规人员(manager) 批量审核 dialog: 对选中的待合规审核会议批量通过/退回 -->
<el-dialog v-model="batchAuditOpen" title="批量合规审核" width="560px">
<el-form label-width="100px">
<el-form-item label="审核范围">
<span>已选 {{ selectedIds.length }} 个会议, 其中待合规审核 {{ batchEligibleIds.length }} </span>
</el-form-item>
<el-form-item label="审核结果">
<el-radio-group v-model="batchAuditForm.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">退回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="batchAuditForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="退回时意见必填, 将通知执行方整改" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchAuditOpen = false">取消</el-button>
<el-button type="primary" :loading="batchAuditSaving" @click="onBatchAuditConfirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -364,6 +386,66 @@ async function onApprovalConfirm() {
}
}
// ========== 合规人员(manager) 批量审核 ==========
const batchAuditOpen = ref(false)
const batchAuditSaving = ref(false)
const batchAuditForm = reactive({ approved: true, opinion: '' })
const batchEligibleIds = ref([])
// 待合规审核判据: material_audit_stage=SUBMITTED 且 compliance_approved≠1 (与详情页 canComplianceAudit 一致)
function isCompliancePending(row) {
return row.materialAuditStage === 'SUBMITTED' && !isOneVal(row.materialComplianceApproved)
}
function onBatchAudit() {
if (!selectedIds.value.length) return ElMessage.warning('请先勾选会议')
// 从当前页 rows 里过滤出「待合规审核」的选中会议 (selection 只来自当前页)
const eligible = rows.value.filter(r => selectedIds.value.includes(r.meetingId) && isCompliancePending(r))
batchEligibleIds.value = eligible.map(r => r.meetingId)
if (!batchEligibleIds.value.length) {
ElMessage.warning('所选会议中没有处于「待合规审核」状态的, 无法批量审核')
return
}
batchAuditForm.approved = true
batchAuditForm.opinion = ''
batchAuditOpen.value = true
}
async function onBatchAuditConfirm() {
if (!batchAuditForm.approved && !batchAuditForm.opinion.trim()) {
ElMessage.warning('退回时意见不能为空')
return
}
batchAuditSaving.value = true
try {
const r = await request.post('/business/meeting/batch-audit-compliance', {
meetingIds: batchEligibleIds.value,
approved: batchAuditForm.approved,
opinion: batchAuditForm.opinion
})
const d = r?.data || {}
const successCount = d.successCount ?? 0
const failCount = d.failCount ?? 0
const failures = d.failures || []
if (failCount > 0) {
const names = failures.map(f => `会议ID ${f.meetingId}: ${f.reason}`).join('\n')
ElMessageBox.alert(
`批量审核完成\n成功 ${successCount} 个, 失败 ${failCount}\n\n失败明细:\n${names}`,
'批量审核结果',
{ type: successCount > 0 ? 'warning' : 'error', confirmButtonText: '知道了' }
)
} else {
ElMessage.success(`批量审核完成, 成功 ${successCount}`)
}
batchAuditOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '批量审核失败')
} finally {
batchAuditSaving.value = false
}
}
// ========== 会务下载 (OSS 端打 zip: 后端 staging copy + 阿里云 FC 打包) ==========
async function onDownloadService(row) {
try {
+161 -92
View File
@@ -42,30 +42,48 @@
</div>
<div class="filter-bar">
<input v-model="currentKeyword" @keydown.enter="applyFilter" type="text" class="filter-input" placeholder="">
<div class="filter-input-wrap">
<svg class="filter-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/>
<path d="M21 21l-4.3-4.3"/>
</svg>
<input v-model="currentKeyword" @keydown.enter="applyFilter" type="text" class="filter-input" placeholder="搜索项目名称">
</div>
<button class="filter-btn" @click="applyFilter">查询</button>
<button class="filter-btn" style="background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);" @click="resetFilter">重置</button>
<button class="filter-btn filter-btn-plain" @click="resetFilter">重置</button>
</div>
<div class="notice-list">
<template v-if="pageItems.length === 0">
<div style="padding: 40px 0; text-align: center; color: #9ca3af; font-size: 13px;">暂无数据</div>
<div class="notice-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
<span>暂无公示项目</span>
</div>
</template>
<template v-else>
<div class="notice-header">
<span class="notice-header-no">序号</span>
<span class="notice-header-title">项目</span>
<span class="notice-header-date">开始日期</span>
<span class="notice-header-end">截止日期</span>
<span class="notice-header-title">项目名称</span>
<span class="notice-header-date">发布日期</span>
</div>
<div v-for="(n, i) in pageItems" :key="n.annId || i" class="notice-item" @click="goDetail(n)">
<span class="notice-item-no">{{ String((currentPage - 1) * PAGE_SIZE + i + 1).padStart(3, '0') }}</span>
<span class="notice-title">
<span v-if="n.projectNo" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectNo }}</span>
{{ n.title }}
<svg class="notice-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6l6 6-6 6"/>
</svg>
</span>
<span class="notice-date">
<svg class="notice-date-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{{ fmtDate(n.publishTime) }}
</span>
<span class="notice-date">{{ fmtDate(n.date) }}</span>
<span class="notice-end">{{ fmtDate(n.endTime) }}</span>
</div>
</template>
</div>
@@ -121,8 +139,7 @@ async function load() {
allRows.value = list.map(r => {
return {
annId: r.projectId,
date: r.startTime,
endTime: r.endTime,
publishTime: r.publishTime,
type: '公示',
projectNo: r.projectNo || '',
projectName: r.projectName,
@@ -430,30 +447,47 @@ main.container {
/* ========== 筛选条 ========== */
.filter-bar {
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 0;
padding: 14px 0;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
gap: 14px;
gap: 12px;
font-size: 13px;
}
.filter-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.filter-icon {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.filter-input {
height: 30px;
padding: 0 10px;
border: 1px solid #d1d5db;
background: #fff;
height: 34px;
padding: 0 12px 0 34px;
border: 1px solid #dde1e6;
border-radius: 17px;
background: #f7f8fa;
font-size: 13px;
color: #1f2937;
font-family: inherit;
outline: none;
width: 220px;
transition: border-color 0.2s;
width: 240px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.filter-input:focus {
border-color: var(--brand-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05);
}
.filter-input::placeholder {
@@ -461,20 +495,33 @@ main.container {
}
.filter-btn {
height: 30px;
padding: 0 18px;
height: 34px;
padding: 0 20px;
background: var(--brand-primary);
color: #fff;
border: none;
font-size: 12px;
border: 1px solid var(--brand-primary);
border-radius: 17px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
letter-spacing: 1px;
transition: background 0.2s;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.filter-btn:hover {
background: var(--brand-primary-deep);
border-color: var(--brand-primary-deep);
}
.filter-btn-plain {
background: #fff;
color: var(--brand-primary);
}
.filter-btn-plain:hover {
background: #f7f8fa;
border-color: var(--brand-primary);
color: var(--brand-primary);
}
/* ========== 公示列表 ========== */
@@ -482,40 +529,14 @@ main.container {
padding: 0;
}
.notice-item {
.notice-empty {
display: flex;
align-items: flex-start;
padding: 14px 0;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
gap: 24px;
}
.notice-item:hover {
background: #fafbfc;
}
.notice-item:hover .notice-title {
color: var(--brand-primary);
}
.notice-date {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: #6b7280;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
}
.notice-title {
flex: 1;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 64px 0;
color: #c0c6cf;
font-size: 14px;
color: #1f2937;
line-height: 1.7;
transition: color 0.2s;
min-width: 0;
}
/* 表头: 与 notice-item 相同的 flex + gap + 列宽, 保证列对齐 */
@@ -523,23 +544,12 @@ main.container {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 0;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
font-size: 13px;
padding: 14px 20px;
border-bottom: 1px solid #eef0f3;
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.notice-header-no {
flex-shrink: 0;
width: 32px;
text-align: center;
}
.notice-header-date {
flex-shrink: 0;
width: 100px;
color: #9ca3af;
letter-spacing: 1px;
}
.notice-header-title {
@@ -547,29 +557,88 @@ main.container {
min-width: 0;
}
.notice-header-end {
.notice-header-date {
flex-shrink: 0;
width: 100px;
width: 130px;
text-align: right;
}
/* 右侧截止时间: 等宽数字 + 灰色, 与日期列同宽对齐 */
.notice-end {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: #6b7280;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
.notice-item {
position: relative;
display: flex;
align-items: center;
gap: 24px;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
}
.notice-item-no {
.notice-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 52%;
border-radius: 2px;
background: var(--brand-primary);
opacity: 0;
transition: opacity 0.2s ease;
}
.notice-item:hover {
background: #f7f9fb;
}
.notice-item:hover::before {
opacity: 1;
}
.notice-item:hover .notice-title {
color: var(--brand-primary);
}
.notice-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 500;
color: #1f2937;
line-height: 1.6;
transition: color 0.2s;
}
.notice-arrow {
flex-shrink: 0;
width: 32px;
text-align: center;
color: var(--brand-primary);
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.notice-item:hover .notice-arrow {
opacity: 1;
transform: translateX(0);
}
.notice-date {
flex-shrink: 0;
width: 130px;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 12px;
color: #9ca3af;
font-family: ui-monospace, "Courier New", monospace;
padding-top: 2px;
color: #8a919c;
}
.notice-date-icon {
color: #b6bcc6;
}
/* ========== 分页 ========== */
+8 -2
View File
@@ -212,6 +212,12 @@ function isDuplicateError(msg) {
return /已存在|重复|duplicate|uk_person_phone/i.test(msg || '')
}
// 按后端原因精确提示: 登录账号(用户名) vs 手机号 (后端分别抛 "登录账号已存在" / DB uk_person_phone)
function duplicateTip(msg) {
if (/登录账号|用户名|login/i.test(msg || '')) return '登录账号已存在,请更换'
return '该手机号已存在,请更换'
}
async function onSave() {
try {
await formRef.value.validate()
@@ -247,12 +253,12 @@ async function onSave() {
}
} else {
const msg = res?.msg || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
}
} catch (e) {
const msg = e?.msg || e?.message || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
if (isDuplicateError(msg)) ElMessage.warning(duplicateTip(msg))
else ElMessage.error(msg)
} finally {
saving.value = false
+8 -2
View File
@@ -241,6 +241,12 @@ const detail = ref({})
const detailOpen = ref(false)
function formatMoney(v) { return Number(v || 0).toFixed(2) }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与后端 GMT+8 一致; 不能用 toISOString 的 UTC)
function nowLocalDatetime() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
async function load() {
loading.value = true
@@ -387,7 +393,7 @@ async function onBatchOpen() {
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量开通吗?`, '批量开通', { type: 'warning' }) } catch { return }
let ok = 0
for (const r of selected.value) {
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1' }); ok++ } catch {}
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1', publishTime: nowLocalDatetime() }); ok++ } catch {}
}
ElMessage.success(`已批量开通 ${ok}`)
load()
@@ -405,7 +411,7 @@ async function onFinish(row) {
async function onOpen(row) {
try { await ElMessageBox.confirm(`确定开通「${row.projectName}」吗?`, '开通', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isPublished: '1' })
await bizUpdate('project', { projectId: row.projectId, isPublished: '1', publishTime: nowLocalDatetime() })
ElMessage.success('已开通')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }