feat: 项目导出 + 会议跳转 + 机构软删除 + 供应商账号同步
- 项目/项目评价/报名专家 3 类导出, 勾选透传 projectIds - 项目列表 总场次/已执行/未执行 三列点击跳对应会议列表 (executor 复用后端 role 隔离) - 机构软删除 + 级联软删机构下账号 - 供应商开放接口账号同步 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -169,3 +169,14 @@ export function exportPublicityExecutionIntent(params) {
|
||||
export function exportPublicitySupportIntent(params) {
|
||||
return request.post('/business/publicitySupportIntent/export', null, { params, responseType: 'blob' })
|
||||
}
|
||||
|
||||
// ========== 项目导出 (manager/Projects.vue, 跟随当前筛选) ==========
|
||||
export function exportProjectList(params) {
|
||||
return request.post('/business/project/export', null, { params, responseType: 'blob' })
|
||||
}
|
||||
export function exportProjectRating(params) {
|
||||
return request.post('/business/project/ratingExport', null, { params, responseType: 'blob' })
|
||||
}
|
||||
export function exportSignupExpert(params) {
|
||||
return request.post('/business/project/signupExpertExport', null, { params, responseType: 'blob' })
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ import { stageLabel, STAGE_OPTIONS, stageClass } from '@/utils/meetingStage'
|
||||
|
||||
// 与 manager/meetings/Meetings.vue 一致: 8 项筛选 (项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注)
|
||||
const q = ref({
|
||||
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
|
||||
projectForm: '', currentStage: '', remark: '',
|
||||
projectNo: '', projectId: '', meetingId: '', meetingName: '', periodNo: null,
|
||||
projectForm: '', currentStage: '', currentStageNotIn: '', remark: '',
|
||||
startTime: '', endTime: ''
|
||||
})
|
||||
const rows = ref([])
|
||||
@@ -125,13 +125,15 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.projectId != null && q2.projectId !== '') q.value.projectId = String(q2.projectId)
|
||||
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
|
||||
if (q2.currentStageNotIn != null && q2.currentStageNotIn !== '') q.value.currentStageNotIn = String(q2.currentStageNotIn)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.value = {
|
||||
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
|
||||
projectForm:'', currentStage:'', remark:'',
|
||||
projectNo:'', projectId:'', meetingId:'', meetingName:'', periodNo:null,
|
||||
projectForm:'', currentStage:'', currentStageNotIn:'', remark:'',
|
||||
startTime:'', endTime:''
|
||||
}
|
||||
page.pageNum = 1
|
||||
|
||||
@@ -50,10 +50,23 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总场次/总期数" width="130" align="center">
|
||||
<template #default="{ row }">{{ row.assignedSessions || 0 }}</template>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.assignedSessions" :underline="false" type="primary" @click="goMeetings(row, 'all')">{{ row.assignedSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.doneSessions" :underline="false" type="primary" @click="goMeetings(row, 'done')">{{ row.doneSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.todoSessions" :underline="false" type="primary" @click="goMeetings(row, 'todo')">{{ row.todoSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column label="总金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatNum(row.assignedAmount) }}</template>
|
||||
</el-table-column>
|
||||
@@ -196,6 +209,15 @@ function reset() {
|
||||
|
||||
function viewDetail(row) { router.push(`/executor/projects/detail/${row.projectId}`) }
|
||||
|
||||
// 点击「总场次/已执行/未执行」列 → 跳 executor 会议列表, 按项目 + 阶段筛选
|
||||
// 数据权限完全交给会议列表后端: MAIN → execution_unit_id 本公司; SUB → create_by 本人, 跳转不新增任何越权参数
|
||||
function goMeetings(row, scope) {
|
||||
const q = { projectId: row.projectId }
|
||||
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
|
||||
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
|
||||
router.push({ path: '/executor/meetings', query: q })
|
||||
}
|
||||
|
||||
async function loadStaff() {
|
||||
// 本 org 下所有执行方用户 (MAIN 管理员 + SUB 执行人员), 走 listExecutorPerson (后端强制 unit_type='executor' + 当前主账号隔离)
|
||||
// status='0': 只拉启用账号; 管理员 (MAIN) 一并返回但由前端置灰 (不可选)
|
||||
|
||||
@@ -60,23 +60,21 @@
|
||||
<el-option label="已结算" value="Y" /><el-option label="未结算" value="N" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
<el-button @click="doExportRating">项目评价导出</el-button>
|
||||
<el-button v-if="isManager" @click="doExportSignupExpert">已报名专家导出</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button class="action-btn" @click="openNewProject">新建项目</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openAssign">
|
||||
项目分配<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('score')">
|
||||
批量评分<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('close')">
|
||||
批量结题<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('activate')">
|
||||
批量开通<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button @click="openAssign">项目分配</el-button>
|
||||
<el-button @click="openBatch('score')">批量评分</el-button>
|
||||
<el-button @click="openBatch('close')">批量结题</el-button>
|
||||
<el-button @click="openBatch('activate')">批量开通</el-button>
|
||||
<el-button v-if="canDelete" class="action-btn secondary" type="danger" :disabled="!selection.length" @click="onBatchDelete">
|
||||
批量删除<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
@@ -96,9 +94,24 @@
|
||||
<el-link :underline="false" type="primary" @click="viewDetail(row)">{{ row.projectName }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalSessions" label="总场次/总期数" width="130" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column prop="totalSessions" label="总场次/总期数" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.totalSessions" :underline="false" type="primary" @click="goMeetings(row, 'all')">{{ row.totalSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.doneSessions" :underline="false" type="primary" @click="goMeetings(row, 'done')">{{ row.doneSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.todoSessions" :underline="false" type="primary" @click="goMeetings(row, 'todo')">{{ row.todoSessions }}</el-link>
|
||||
<span v-else style="color:#c0c4cc">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalAmount" label="总金额" width="130" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="availableAmount" label="可用金额" width="130" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidLaborAmount" label="已支付劳务费" width="140" align="right" :formatter="fmtMoney" />
|
||||
@@ -273,7 +286,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
|
||||
import { bizList, bizAdd, bizUpdate, bizDelete, exportProjectList, exportProjectRating, exportSignupExpert } from '@/api/public'
|
||||
import { listExecutorOrgs, listSponsorOrgs } from '@/api/system'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -379,9 +392,63 @@ function readQueryFromRoute() {
|
||||
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
|
||||
}
|
||||
|
||||
// 导出
|
||||
function exportProjects() { ElMessage.info('导出项目功能开发中') }
|
||||
function exportEval() { ElMessage.info('项目评价导出功能开发中') }
|
||||
// 仅 manager 可见「已报名专家导出」 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||||
const isManager = computed(() => userStore.role === 'manager')
|
||||
|
||||
// ========== 导出 (跟随当前筛选, 与 load() 同一口径) ==========
|
||||
function buildExportParams() {
|
||||
// 勾选了项目 → 只导出勾选项; 否则按当前筛选导出全部
|
||||
if (selection.value.length > 0) {
|
||||
return { projectIds: selection.value.map(r => r.projectId) }
|
||||
}
|
||||
const params = { ...q.value }
|
||||
if (params.startTime) params.startTime = params.startTime + ' 00:00:00'
|
||||
if (params.endTime) params.endTime = params.endTime + ' 23:59:59'
|
||||
return params
|
||||
}
|
||||
function downloadBlob(res, filename) {
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
function dateStamp() {
|
||||
const d = new Date()
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
|
||||
}
|
||||
async function doExport() {
|
||||
try {
|
||||
const res = await exportProjectList(buildExportParams())
|
||||
downloadBlob(res, `项目列表_${dateStamp()}.xlsx`)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导出失败')
|
||||
}
|
||||
}
|
||||
async function doExportRating() {
|
||||
try {
|
||||
const res = await exportProjectRating(buildExportParams())
|
||||
downloadBlob(res, `项目评价_${dateStamp()}.xlsx`)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导出失败')
|
||||
}
|
||||
}
|
||||
async function doExportSignupExpert() {
|
||||
try {
|
||||
const res = await exportSignupExpert(buildExportParams())
|
||||
downloadBlob(res, `报名专家_${dateStamp()}.xlsx`)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog) ==========
|
||||
function openClose(row) {
|
||||
@@ -481,6 +548,16 @@ function viewDetail(row) {
|
||||
router.push(`${base}/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
|
||||
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
|
||||
function goMeetings(row, scope) {
|
||||
const base = userStore.role === 'admin' ? '/admin' : '/manager'
|
||||
const q = { projectId: row.projectId }
|
||||
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
|
||||
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
|
||||
router.push({ path: `${base}/meetings`, query: q })
|
||||
}
|
||||
|
||||
// 点击列表「执行单位评分(合规/支持)」列 → 只读显示该角色 4 维度评分明细
|
||||
async function openScoreDetail(row, role) {
|
||||
scoreDetailTitle.value = role === 'sponsor' ? '执行单位评分详情(支持方)' : '执行单位评分详情(合规)'
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) -->
|
||||
<div class="attendee-section">
|
||||
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<el-button size="small" @click="openAttendeeImport">批量导入</el-button>
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
|
||||
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
|
||||
@@ -79,7 +79,7 @@
|
||||
让内容超出时容器内出现横向滚动条, 不影响外层 grid 布局.
|
||||
-->
|
||||
<div class="attendee-table-wrap">
|
||||
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange" class="attendee-table">
|
||||
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange" class="attendee-table" :row-class-name="attendeeRowClass">
|
||||
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
|
||||
<el-table-column type="index" label="序号" width="50" align="center" />
|
||||
<el-table-column label="医生" min-width="90">
|
||||
@@ -157,7 +157,7 @@
|
||||
<el-table-column v-if="!isSponsor && !isReadonly" label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-link :underline="false" type="primary" @click="openAttendeeDialog(row)">编辑</el-link>
|
||||
<el-link :underline="false" type="primary" :disabled="!attendeeEditable" @click="openAttendeeDialog(row)">编辑</el-link>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onAttendeeCommand(cmd, row)">
|
||||
<el-link :underline="false" type="primary" :disabled="inviteLoadingId === row.id || esignLoadingId === row.id">
|
||||
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
@@ -166,7 +166,7 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">邀请参会</el-dropdown-item>
|
||||
<el-dropdown-item command="esign" :disabled="esignLoadingId === row.id">推送电子签</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided><span style="color: #f56c6c;">删除</span></el-dropdown-item>
|
||||
<el-dropdown-item command="delete" :disabled="!attendeeEditable" divided><span style="color: #f56c6c;">删除</span></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -313,14 +313,14 @@
|
||||
<div :class="['timeline-item', eventStatus(ev)]">
|
||||
<div class="timeline-title">
|
||||
{{ stepLabel(ev.step) }}
|
||||
<span v-if="trackTag(ev)" class="track-tag">{{ trackTag(ev) }}</span>
|
||||
<span v-for="(l, i) in trackParts(ev)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
|
||||
</div>
|
||||
<div v-for="(e, i) in ev.entries" :key="i" class="timeline-meta">
|
||||
<span v-if="ev.entries.length > 1" class="track-mini">{{ e.label }}</span>
|
||||
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
|
||||
<span>{{ e.auditor || '—' }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ fmtDateTime(e.auditTime) }}</span>
|
||||
<el-tag v-if="e.auditResult" size="small" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
|
||||
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
|
||||
</div>
|
||||
<div v-for="(e, i) in ev.entries" :key="'op' + i">
|
||||
<div v-if="e.opinion" :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
|
||||
@@ -330,7 +330,7 @@
|
||||
<div v-if="pendingNode" class="timeline-item pending">
|
||||
<div class="timeline-title">
|
||||
{{ stepLabel(pendingNode.step) }}
|
||||
<span v-if="trackTag(pendingNode)" class="track-tag">{{ trackTag(pendingNode) }}</span>
|
||||
<span v-for="(l, i) in trackParts(pendingNode)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
|
||||
</div>
|
||||
<div class="timeline-desc pending-text">{{ pendingDesc(pendingNode.step) }}</div>
|
||||
</div>
|
||||
@@ -385,7 +385,7 @@
|
||||
|
||||
<!-- 参会人 dialog (新增/编辑通用, 2 列布局) -->
|
||||
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="760px" append-to-body :close-on-click-modal="false" @closed="resetAttendeeForm">
|
||||
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" :rules="attendeeRules" label-width="100px" class="attendee-form-2col">
|
||||
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" label-width="100px" class="attendee-form-2col">
|
||||
<!-- Col 1: 基础档案 (8 字段) -->
|
||||
<el-form-item label="联系方式" required>
|
||||
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11">
|
||||
@@ -443,8 +443,10 @@
|
||||
<el-form-item label="增值税附加">
|
||||
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="实发金额" prop="fee">
|
||||
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="实发金额">
|
||||
<div :class="{ 'fee-warn': feeDeviates }" style="width:100%">
|
||||
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="attendeeDialog.form.summary" placeholder="备注/说明" maxlength="500" show-word-limit />
|
||||
@@ -773,20 +775,9 @@ function stepLabel(step) {
|
||||
if (step === 'compliance') return '合规审核'
|
||||
return '监察意见'
|
||||
}
|
||||
/** 节点右侧轨标注: 提交节点带轮次 (劳务·第1次), 审核节点只标轨 (劳务·会务). */
|
||||
function trackTag(node) {
|
||||
const labels = node.entries.map(e => e.label).filter(Boolean)
|
||||
if (!labels.length) return ''
|
||||
if (node.step === 'submit') {
|
||||
const withRound = node.entries.filter(e => e.roundNo != null)
|
||||
if (withRound.length) {
|
||||
if (withRound.length === node.entries.length && withRound.every(e => e.roundNo === withRound[0].roundNo)) {
|
||||
return `${labels.join('·')} · 第${withRound[0].roundNo}次`
|
||||
}
|
||||
return node.entries.map(e => (e.roundNo != null ? `${e.label}·第${e.roundNo}次` : e.label)).filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
return labels.join('·')
|
||||
/** 节点右侧轨标注: 每轨一个徽标 (劳务=primary, 会务=warning). */
|
||||
function trackParts(node) {
|
||||
return (node.entries || []).map(e => e && e.label).filter(Boolean)
|
||||
}
|
||||
function eventStatus(ev) {
|
||||
return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done'
|
||||
@@ -836,6 +827,12 @@ const frozen = computed(() => isOne(row.value.isFrozen))
|
||||
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
|
||||
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
|
||||
const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
|
||||
// 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改
|
||||
const attendeeEditable = computed(() => {
|
||||
if (isSponsor.value || isReadonly.value) return false
|
||||
if (isExecutor.value) return ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage)
|
||||
return true
|
||||
})
|
||||
/** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
|
||||
function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
|
||||
function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
|
||||
@@ -1022,24 +1019,28 @@ function roleAmountSum(laborForm) {
|
||||
return result
|
||||
}
|
||||
|
||||
/** 实发金额校验: 不超所选角色的劳务金额合计 (未选角色时跳过) */
|
||||
function validateFee(rule, value, callback) {
|
||||
/** 实发金额是否偏离配置值 (所选角色劳务金额合计); 少于或多于都算, 未选/未匹配到配置不提示 */
|
||||
const feeDeviates = computed(() => {
|
||||
const laborForm = attendeeDialog.value.form.laborForm
|
||||
if (!laborForm || !String(laborForm).trim()) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
const { sum } = roleAmountSum(laborForm)
|
||||
const fee = Number(value) || 0
|
||||
if (fee > sum) {
|
||||
callback(new Error(`实发金额不能超过角色金额 ${sum.toFixed(2)} 元`))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
if (!laborForm || !String(laborForm).trim()) return false
|
||||
const { sum, matchedAny } = roleAmountSum(laborForm)
|
||||
if (!matchedAny) return false
|
||||
const fee = Number(attendeeDialog.value.form.fee) || 0
|
||||
return Number(fee.toFixed(2)) !== Number(sum.toFixed(2))
|
||||
})
|
||||
|
||||
/** 参会人列表行: 实发金额偏离角色劳务配置值 (少/多都算), 未选/未匹配到配置不提示 */
|
||||
function attendeeFeeDeviates(row) {
|
||||
if (!row || !row.laborForm || !String(row.laborForm).trim()) return false
|
||||
const { sum, matchedAny } = roleAmountSum(row.laborForm)
|
||||
if (!matchedAny) return false
|
||||
const fee = Number(row.fee) || 0
|
||||
return Number(fee.toFixed(2)) !== Number(sum.toFixed(2))
|
||||
}
|
||||
|
||||
const attendeeRules = {
|
||||
fee: [{ validator: validateFee, trigger: ['blur', 'change'] }]
|
||||
/** el-table row-class-name: 偏离行加 attendee-fee-warn 淡红背景 */
|
||||
function attendeeRowClass({ row }) {
|
||||
return attendeeFeeDeviates(row) ? 'attendee-fee-warn' : ''
|
||||
}
|
||||
|
||||
// ===================== 金额联动 (照搬 hwt guest.vue 单向链) =====================
|
||||
@@ -1824,6 +1825,16 @@ function submitOcrForMaterials(items) {
|
||||
/** 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表 */
|
||||
async function onSubmitMaterials(types) {
|
||||
if (!types || !types.length) { ElMessage.warning('无可提交的材料轨'); return }
|
||||
// 有「已上传未保存」的改动 → 先保存再提交 (与结算同款拦截)
|
||||
if (hasUnsavedChanges.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'检测到尚未保存的上传内容,是否先保存再提交?',
|
||||
'未保存的改动',
|
||||
{ type: 'warning', confirmButtonText: '保存并提交', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
}
|
||||
busy.value.submitMaterial = true
|
||||
try {
|
||||
await saveMaterials()
|
||||
@@ -2122,8 +2133,12 @@ onBeforeUnmount(stopFeePolling)
|
||||
.track-block:last-child { margin-bottom: 0; }
|
||||
.track-label-row { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
|
||||
.cycle-no { font-size: 11px; color: #909399; }
|
||||
.track-tag { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 400; color: #909399; background: #f5f7fa; border-radius: 3px; padding: 1px 6px; line-height: 1.6; vertical-align: middle; }
|
||||
.track-mini { display: inline-block; min-width: 34px; font-size: 11px; color: #909399; }
|
||||
.track-tag { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 400; border-radius: 3px; padding: 1px 6px; line-height: 1.6; vertical-align: middle; }
|
||||
.track-tag.track-labor { color: var(--brand-primary); background: var(--brand-primary-mix); }
|
||||
.track-tag.track-service { color: var(--el-color-warning); background: var(--el-color-warning-light-9); }
|
||||
.track-mini { display: inline-block; min-width: 34px; font-size: 11px; }
|
||||
.track-mini.track-labor { color: var(--brand-primary); }
|
||||
.track-mini.track-service { color: var(--el-color-warning); }
|
||||
.timeline-opinion { margin-top: 4px; font-size: 12px; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
|
||||
.timeline-opinion.opinion-reject { color: #f56c6c; background: #fef0f0; }
|
||||
.timeline-opinion.opinion-approve { color: #389e0d; background: #f6ffed; }
|
||||
@@ -2143,12 +2158,17 @@ onBeforeUnmount(stopFeePolling)
|
||||
.attendee-form-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; }
|
||||
.attendee-form-2col :deep(.el-form-item) { margin-bottom: 12px; }
|
||||
.attendee-form-2col :deep(.el-form-item__content) { min-width: 0; }
|
||||
/* 实发金额偏离配置值 → 输入框红框软提示 (不拦截录入). */
|
||||
.fee-warn :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #f56c6c inset !important; }
|
||||
/* 手机号放大镜 suffix: 可点 + hover 高亮 */
|
||||
.phone-lookup { cursor: pointer; color: var(--el-text-color-secondary); transition: color 0.2s; }
|
||||
.phone-lookup:hover { color: var(--brand-primary, #409eff); }
|
||||
/* 表格外层横向滚动容器: 内容总宽 ~1680px 超出 left-col 宽度时, 容器内出现横向滚动条, 不撑爆外层 grid */
|
||||
.attendee-table-wrap { overflow-x: auto; max-width: 100%; min-width: 0; }
|
||||
.row-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
/* 参会人实发金额偏离角色配置值 → 整行淡红背景 (软提示, 不拦截); hover 时加深保持反馈 */
|
||||
.attendee-table :deep(tr.attendee-fee-warn > td.el-table__cell) { background-color: #fef0f0 !important; }
|
||||
.attendee-table :deep(tr.attendee-fee-warn:hover > td.el-table__cell) { background-color: #fde2e2 !important; }
|
||||
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -110,7 +110,9 @@ const snap = reactive({
|
||||
totalSessions: '',
|
||||
sponsorOrgName: '',
|
||||
createUserName: '',
|
||||
createTime: ''
|
||||
createTime: '',
|
||||
projectStartTime: '',
|
||||
projectEndTime: ''
|
||||
})
|
||||
|
||||
// 期数上限 = 总场次/总期数 (executor 快照 totalSessions 已换成分配给本机构的场次口径)
|
||||
@@ -151,6 +153,8 @@ async function loadProjectSnapshot(pid) {
|
||||
snap.sponsorOrgName = d.sponsorOrgName || ''
|
||||
snap.createUserName = d.createUserName || ''
|
||||
snap.createTime = d.createTime || ''
|
||||
snap.projectStartTime = d.startTime || ''
|
||||
snap.projectEndTime = d.endTime || ''
|
||||
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
|
||||
if (roleSegment.value === 'executor') {
|
||||
try {
|
||||
@@ -196,6 +200,17 @@ async function onSave() {
|
||||
ElMessage.error('会议开始时间不能晚于会议结束时间')
|
||||
return
|
||||
}
|
||||
// 会议时间必须在项目起止时间区间内 (闭区间; 项目未设起止则跳过对应边界)
|
||||
if (snap.projectStartTime && form.startTime
|
||||
&& new Date(form.startTime.replace(' ', 'T')) < new Date(snap.projectStartTime.replace(' ', 'T'))) {
|
||||
ElMessage.error('会议开始时间不能早于项目开始时间')
|
||||
return
|
||||
}
|
||||
if (snap.projectEndTime && form.endTime
|
||||
&& new Date(form.endTime.replace(' ', 'T')) > new Date(snap.projectEndTime.replace(' ', 'T'))) {
|
||||
ElMessage.error('会议结束时间不能晚于项目结束时间')
|
||||
return
|
||||
}
|
||||
// 期数不得高于总场次/总期数 (executor 快照 totalSessions 已换成分配给本机构的场次口径)
|
||||
const pn = Number(form.periodNo)
|
||||
const total = Number(snap.totalSessions)
|
||||
|
||||
@@ -176,7 +176,7 @@ const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'mana
|
||||
|
||||
// ========== 筛选 (按实际 8 项: 项目编号/会议ID/会议名称/第?期/会议时间/项目形式/当前阶段/备注) ==========
|
||||
const q = ref({
|
||||
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
|
||||
projectNo: '', projectId: '', meetingId: '', meetingName: '', periodNo: null,
|
||||
projectForm: '', currentStage: '', currentStageNotIn: '', remark: '',
|
||||
startTime: '', endTime: ''
|
||||
})
|
||||
@@ -214,7 +214,7 @@ async function load() {
|
||||
}
|
||||
function reset() {
|
||||
q.value = {
|
||||
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
|
||||
projectNo:'', projectId:'', meetingId:'', meetingName:'', periodNo:null,
|
||||
projectForm:'', currentStage:'', currentStageNotIn:'', remark:'',
|
||||
startTime:'', endTime:''
|
||||
}
|
||||
@@ -226,6 +226,7 @@ function reset() {
|
||||
// 只读不改 URL — KPI 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.projectId != null && q2.projectId !== '') q.value.projectId = String(q2.projectId)
|
||||
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
|
||||
if (q2.currentStageNotIn != null && q2.currentStageNotIn !== '') q.value.currentStageNotIn = String(q2.currentStageNotIn)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user