feat: 项目分配通知支持方主账号 + 结题前置检查未结算会议

#8 项目分配 → 支持方主账号待办通知
- BizProjectController.edit 检测 sponsor_org_id 变更 (null→org / A→B)
  调 BizNotifyService.projectAssignedToSupportOrg 发待办给支持方主账号
  (biz_org.user_id, MAIN sponsor). 去掉支持方 (置 null) 不发.
- BizNotifyService 新增 projectAssignedToSupportOrg, 与既有的
  projectAssignedToSponsor (通知 SUB 监察员) 配套, 共 2 条.

结题前置检查 (manager)
- BizProjectController 新增 GET /business/project/meetingSettlement
  入参 projectIds 逗号分隔, 查还有未结算会议 (is_settled<>1)
  的项目, 仅返回 unsettledCount>0 项.
- BizProjectMapper.checkMeetingSettled (LinkedHashMap, HAVING > 0).
- manager/Projects.vue openClose 先调该端点, 有未结算会议时弹警告
  ElMessageBox.confirm (允许强制结题). 后端失败兜底空数组不阻塞.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-09-14 22:37:18 +08:00
co-authored by Claude Code
parent 91bf0fa3b9
commit 0fb98311ce
18 changed files with 288 additions and 55 deletions
+3 -2
View File
@@ -109,7 +109,7 @@ import { usePageTitle } from '@/utils/pageTitle'
import { getMyExpertProfile } from '@/api/business/expert'
import { ElNotification } from 'element-plus'
import ForcePasswordDialog from '@/components/ForcePasswordDialog.vue'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, FirstAidKit, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -244,7 +244,8 @@ const MENU = {
{ path: '/admin/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
{ path: '/admin/department', title: '科室管理', icon: Grid },
{ path: '/admin/title', title: '职称管理', icon: Medal },
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection }
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection },
{ path: '/admin/hospital', title: '医院管理', icon: FirstAidKit }
]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files },
+1
View File
@@ -57,6 +57,7 @@ const routes = [
{ path: 'special-plan/new', name: 'admin-special-plan-new', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '新建专项计划' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } },
{ path: 'hospital', name: 'admin-hospital', component: () => import('@/views/admin/Hospital.vue'), meta: { title: '医院管理' } },
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
{ path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } },
{ path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } },
+52 -3
View File
@@ -17,7 +17,21 @@
<el-input v-model="form.realName" placeholder="请输入您的真实姓名" />
</el-form-item>
<el-form-item label="工作单位" prop="workUnit">
<el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" />
<el-select
v-model="form.workUnit"
filterable remote clearable
:remote-method="searchHospital"
:loading="hospitalLoading"
placeholder="请输入医院关键字, 必须从下拉列表选择"
style="width:100%"
>
<el-option
v-for="h in hospitalOptions"
:key="h.hospitalId"
:label="hospitalLabel(h)"
:value="h.hospital"
/>
</el-select>
</el-form-item>
<el-form-item label="科室" prop="department">
<DoctorDeptSelect v-model="form.department" value-field="label" placeholder="请输入所在科室" />
@@ -32,7 +46,7 @@
<div class="sms-row">
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -111,9 +125,44 @@ const form = reactive({
licenseCertUrl: '',
titleCertUrl: ''
})
// ===== 医院远程搜索 (边输入边查询, 初始 options 为空) =====
const hospitalOptions = ref([])
const hospitalLoading = ref(false)
let hospitalTimer = null
function hospitalLabel(h) {
const region = [h.province, h.city].filter(Boolean).join('/')
return region ? `${h.hospital} (${region})` : h.hospital
}
function searchHospital(keyword) {
if (hospitalTimer) clearTimeout(hospitalTimer)
const kw = (keyword || '').trim()
if (!kw) { hospitalOptions.value = []; return }
hospitalLoading.value = true
hospitalTimer = setTimeout(async () => {
try {
const r = await request.get('/business/hospital/search', { params: { keyword: kw } })
hospitalOptions.value = r?.data || []
} finally { hospitalLoading.value = false }
}, 200)
}
const rules = {
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
workUnit: [
{ required: true, message: '请选择医院', trigger: 'change' },
{
validator: (rule, value, cb) => {
if (value && !hospitalOptions.value.some(h => h.hospital === value)) {
cb(new Error('请从下拉列表选择医院'))
} else {
cb()
}
},
trigger: 'change'
}
],
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
doctorTitle: [{ required: true, message: '请选择职称', trigger: 'change' }],
phone: [
+61 -15
View File
@@ -451,10 +451,35 @@ async function doExportSignupExpert() {
}
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog ==========
function openClose(row) {
/** 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
* 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0.
* 0 会议或全部已结算 → 不返回该项; 后端失败兜底 [] (不阻塞结题). */
async function checkMeetingSettled(projectIds) {
if (!projectIds || !projectIds.length) return []
try {
const r = await request.get('/business/project/meetingSettlement', { params: { projectIds: projectIds.join(',') } })
return r?.data || []
} catch { return [] }
}
async function openClose(row) {
if (row.isFinished === 'Y') {
return ElMessage.warning('该项目已结题,无需重复操作')
}
// 有未结算会议 → 警告 (允许强制结题); 否则走原确认框
const unsettledList = await checkMeetingSettled([row.projectId])
const item = unsettledList.find(x => x.projectId === row.projectId)
if (item && item.unsettledCount > 0) {
try {
await ElMessageBox.confirm(
`项目 ${row.projectNo} 还有 ${item.unsettledCount} 场会议未结算,仍要结题吗?`,
'结题提示',
{ type: 'warning', confirmButtonText: '仍要结题', cancelButtonText: '取消' }
)
} catch { return }
closeTargetRow.value = row
return confirmClose()
}
closeTargetRow.value = row
closeModalOpen.value = true
}
@@ -638,7 +663,7 @@ function openAssign() {
}
if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
}
function openBatch(kind) {
async function openBatch(kind) {
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
batchKind.value = kind
if (kind === 'score') {
@@ -648,24 +673,45 @@ function openBatch(kind) {
if (kind === 'activate') {
activateDeadline.value = ''
}
// 结题: 先查未结算会议数, 有未结算 → 警告 (确认后跳过 batch modal 直接结题); 否则走原 batch modal
if (kind === 'close') {
const ids = selection.value.map(r => r.projectId).filter(Boolean)
const unsettledList = await checkMeetingSettled(ids)
if (unsettledList.length > 0) {
try {
await ElMessageBox.confirm(
`其中 ${unsettledList.length} 个项目还有未结算会议,仍要批量结题吗?`,
'结题提示',
{ type: 'warning', confirmButtonText: '仍要批量结题', cancelButtonText: '取消' }
)
} catch { return }
batchSubmitting.value = true
try { await runBatchClose() } catch { ElMessage.error('批量结题失败') }
finally { batchSubmitting.value = false }
return
}
}
batchOpen.value = true
}
/** 批量结题实际动作: 过滤已结题 + Promise.all + 提示 + 重载.
* 供 batch modal confirm (全部已结算) 与 openBatch unsettled 直结 (有未结算) 两路复用. */
async function runBatchClose() {
const targets = selection.value.filter(r => r.isFinished !== 'Y')
const skipCount = selection.value.length - targets.length
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
}
async function confirmBatch() {
if (batchKind.value === 'close') {
// 过滤掉已结题项目, 避免无效请求
const targets = selection.value.filter(r => r.isFinished !== 'Y')
const skipCount = selection.value.length - targets.length
try {
batchSubmitting.value = true
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
} catch { ElMessage.error('批量结题失败') }
batchSubmitting.value = true
try { await runBatchClose() } catch { ElMessage.error('批量结题失败') }
finally { batchSubmitting.value = false }
} else if (batchKind.value === 'activate') {
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
+24 -10
View File
@@ -203,7 +203,7 @@
<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" :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" />
<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>
@@ -355,12 +355,14 @@
{{ stepLabel(ev.step) }}
<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 === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<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>
<template v-for="(e, i) in ev.entries" :key="i">
<div v-if="!isExecutor || e.auditResult" class="timeline-meta">
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
</template>
<div v-for="(e, i) in dedupOpinions(ev)" :key="'op' + i">
<div :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
</div>
</div>
</template>
@@ -721,7 +723,7 @@ const ROW_CONFIG = [
{ 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: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
{ 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: '会议现场签到表' },
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
@@ -836,6 +838,18 @@ function stepLabel(step) {
function trackParts(node) {
return (node.entries || []).map(e => e && e.label).filter(Boolean)
}
/** 同一节点下按意见文本去重: 劳务/会务同时审核会写两条相同意见, 只显示一遍; 内容不同则都保留. */
function dedupOpinions(node) {
const seen = new Set()
const out = []
for (const e of (node.entries || [])) {
if (!e || !e.opinion) continue
if (seen.has(e.opinion)) continue
seen.add(e.opinion)
out.push(e)
}
return out
}
function eventStatus(ev) {
return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done'
}
@@ -2546,7 +2560,7 @@ onBeforeUnmount(() => {
.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, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.
@@ -2593,7 +2607,7 @@ onBeforeUnmount(() => {
.timeline-item.done::before { background: #67c23a; }
.timeline-item.pending::before { background: #c0c4cc; }
.timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; white-space: nowrap; }
.timeline-desc { font-size: 12px; color: #8c8c8c; line-height: 1.6; }
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; }
.timeline-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }