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:
@@ -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 },
|
||||
|
||||
@@ -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: '编辑劳务协议模板' } },
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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('请选择开通截止时间')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user