Files
guoju0808/ry-vue3/src/views/sponsor/Projects.vue
T
郭庆泰 b5b3e3a213 feat: 会议批量审核 + 公示按发布时间倒序 + 现场照片 dialog 预览 + 重复报错精确
- 会议列表 manager 批量合规审核 (批量通过/退回, best-effort 逐条回执)
- 公示列表按 publish_time 倒序, 发布/开通写本地 publish_time (修 toISOString UTC 时区 bug)
- 签到表/前全景/后全景「查看照片」改 dialog 预览 (复用 Preview.vue)
- 手机号/用户名重复报错精确提示 (后端 fieldMap + 前端 duplicateTip)
- 医生首页欢迎语改「欢迎使用合规系统」
2026-08-26 00:41:33 +08:00

470 lines
23 KiB
Vue

<template>
<!--
1:1 doctor/Projects.vue + doctor/Submissions.vue 容器
SIDEBAR: 项目管理
列头 (按原型 sponsor-项目管理.html left 顺序):
复选框 / 项目编号 / 项目名称 / 总场次 / 已执行 / 未执行 /
总金额 / 可用金额 / 已支付劳务费 / 已支付会务费 /
执行单位得分 / 执行单位评价 / 支持单位 / 服务机构 / 项目形式 / 是否结题 /
开始时间 / 结束时间 / 操作
操作: 建会 / 分配 / 结题 / 查看 / 编辑 / 开通 / 删除公告 / 支持单位 / 执行单位评分
业务保留: 批量评分/批量结题/批量开通/项目分配 逻辑
-->
<div class="page-card">
<div class="breadcrumb">首页 / 项目管理</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号">
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="项目名称">
<el-input v-model="q.projectName" placeholder="输入项目名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="项目形式">
<el-select v-model="q.projectForm" placeholder="全部" clearable style="width: 140px">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width: 120px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
</el-select>
</el-form-item>
<el-form-item label="是否结算">
<el-select v-model="q.isSettled" placeholder="全部" clearable style="width: 120px">
<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>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新建项目</el-button>
<el-button :disabled="!selected.length" @click="onAssignSelected">项目分配</el-button>
<el-button :disabled="!selected.length" @click="onBatchRate">批量评分</el-button>
<el-button :disabled="!selected.length" @click="onBatchFinish">批量结题</el-button>
<el-button :disabled="!selected.length" @click="onBatchOpen">批量开通</el-button>
<span class="hint">已选 {{ selected.length }} </span>
</div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelect">
<el-table-column type="selection" width="48" />
<el-table-column prop="projectNo" label="项目编号" min-width="140" fixed>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.projectNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="totalSessions" label="总场次/总期数" width="120" 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 label="总金额" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.totalAmount) }}</template></el-table-column>
<el-table-column label="可用金额" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.availableAmount) }}</template></el-table-column>
<el-table-column label="已支付劳务费" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.paidLaborAmount) }}</template></el-table-column>
<el-table-column label="已支付会务费" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.paidMeetingAmount) }}</template></el-table-column>
<el-table-column prop="managerScore" label="执行单位得分(合规)" width="150" align="center">
<template #default="{ row }">
<span v-if="row.managerScore != null">{{ row.managerScore }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="sponsorScore" label="执行单位评价(支持方)" width="160" align="center">
<template #default="{ row }">
<span v-if="row.sponsorScore != null">{{ row.sponsorScore }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="orgName" label="支持公司" min-width="140" show-overflow-tooltip />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column label="是否结题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.isFinished === '1' ? 'success' : 'info'" size="small" disable-transitions>
{{ row.isFinished === '1' ? '已结题' : '未结题' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="项目开始时间" min-width="160" />
<el-table-column prop="endTime" label="项目结束时间" min-width="160" />
<el-table-column label="操作" width="480" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onCreateMeeting(row)">建会</el-button>
<el-button link type="primary" @click="openAssign(row)">分配</el-button>
<el-button link type="primary" @click="onFinish(row)">结题</el-button>
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" @click="onOpen(row)">开通</el-button>
<el-button link type="danger" @click="onDeleteNotice(row)">删除公告</el-button>
<el-button link type="primary" @click="onSupportUnit(row)">支持单位</el-button>
<el-button link type="primary" @click="openRate(row)">执行单位评分</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
<!-- 新建/编辑项目 dialog ( doctor/Projects.vue 风格统一) -->
<el-dialog v-model="formOpen" :title="formMode === 'create' ? '新建项目' : '编辑项目'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="项目编号"><el-input v-model="form.projectNo" /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="form.projectName" /></el-form-item>
<el-form-item label="项目形式">
<el-select v-model="form.projectForm" placeholder="请选择" clearable>
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="总场次"><el-input-number v-model="form.totalSessions" :min="0" :max="9999" /></el-form-item>
<el-form-item label="管理费"><el-input-number v-model="form.manageFee" :min="0" :precision="2" /></el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="formOpen = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<!-- 评分 dialog -->
<el-dialog v-model="rateOpen" title="执行单位评分" width="520px">
<el-form :model="rateForm" label-width="100px">
<el-form-item label="项目编号 / 名称">
<span>{{ rateForm.projectNo }} / {{ rateForm.projectName }}</span>
</el-form-item>
<el-form-item label="评价维度">
<div class="score-row"><span>履约质量:</span><el-rate v-model="rateForm.q1" :max="5" /></div>
<div class="score-row"><span>时效响应:</span><el-rate v-model="rateForm.q2" :max="5" /></div>
<div class="score-row"><span>配合度:</span><el-rate v-model="rateForm.q3" :max="5" /></div>
<div class="score-row"><span>合规安全:</span><el-rate v-model="rateForm.q4" :max="5" /></div>
</el-form-item>
<el-form-item label="评价备注"><el-input v-model="rateForm.remark" type="textarea" rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="rateOpen = false">取消</el-button>
<el-button type="primary" @click="submitRate">提交评分</el-button>
</template>
</el-dialog>
<!-- 分配 dialog -->
<el-dialog v-model="assignOpen" title="项目分配" width="520px">
<el-form :model="assignForm" label-width="100px">
<el-form-item label="项目编号 / 名称">
<span>{{ assignForm.projectNo }} / {{ assignForm.projectName }}</span>
</el-form-item>
<el-form-item label="分配给监察员">
<el-select v-model="assignForm.supervisor" filterable placeholder="请选择监察员" style="width:100%">
<el-option v-for="p in personList" :key="p.personId" :label="p.name" :value="p.personId" />
</el-select>
</el-form-item>
<el-form-item label="监察要点"><el-input v-model="assignForm.supervisionPoint" type="textarea" rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="assignOpen = false">取消</el-button>
<el-button type="primary" @click="submitAssign">确认分配</el-button>
</template>
</el-dialog>
<!-- 详情 dialog ( doctor/Projects.vue el-descriptions 同款) -->
<el-dialog v-model="detailOpen" title="项目详情" width="640px">
<el-descriptions :column="2" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="支持方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司类型" :span="2">支持方</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行/未执行">{{ detail.doneSessions || 0 }} / {{ detail.todoSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ formatMoney(detail.totalAmount) }}</el-descriptions-item>
<el-descriptions-item label="可用金额">¥{{ formatMoney(detail.availableAmount) }}</el-descriptions-item>
<el-descriptions-item label="已支付劳务费">¥{{ formatMoney(detail.paidLaborAmount) }}</el-descriptions-item>
<el-descriptions-item label="已支付会务费">¥{{ formatMoney(detail.paidMeetingAmount) }}</el-descriptions-item>
<el-descriptions-item label="执行单位得分">{{ detail.managerScore || '-' }}</el-descriptions-item>
<el-descriptions-item label="是否结题">
<el-tag :type="detail.isFinished === '1' ? 'success' : 'info'" disable-transitions>
{{ detail.isFinished === '1' ? '已结题' : '未结题' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">{{ detail.endTime || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate } from '@/api/public'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
const rows = ref([])
const loading = ref(false)
const q = reactive({ projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const selected = ref([])
const formOpen = ref(false)
const formMode = ref('create')
const form = reactive({
projectId: null, projectNo: '', projectName: '', projectForm: '',
totalSessions: 0, manageFee: 0, startTime: '', endTime: ''
})
const rateOpen = ref(false)
const rateForm = reactive({ projectId: null, projectNo: '', projectName: '', q1: 0, q2: 0, q3: 0, q4: 0, remark: '' })
const assignOpen = ref(false)
const assignForm = reactive({ projectId: '', projectNo: '', projectName: '', supervisor: '', supervisionPoint: '' })
const personList = ref([])
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
try {
// sponsor 专属: GET /business/project/sponsorList (后端按 mainUid 团队可见性过滤)
const { data } = await request.get('/business/project/sponsorList', {
params: { ...q, pageNum: page.pageNum, pageSize: page.pageSize }
})
const payload = data?.data || data
rows.value = payload?.rows || []
page.total = payload?.total || 0
} catch (e) { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
async function loadPersons() {
try {
// sponsor 团队下"监察员" (SUB 子账号), 排除主账号
const r = await bizList('person', { pageNum: 1, pageSize: 100, unitType: 'sponsor' })
personList.value = ((r.data && r.data.rows) || r.rows || []).filter(p => p.userId && p.accountType === 'SUB')
} catch (e) { personList.value = [] }
}
// 读 URL query 写入 q (Home KPI 卡跳转时带 ?isSettled=Y / ?isFinished=1, 让列表页自动应用筛选)
// 只读不改 URL — Home 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
function readQueryFromRoute() {
const q2 = route.query
if (q2.isFinished != null && q2.isFinished !== '') q.isFinished = String(q2.isFinished)
if (q2.isSettled != null && q2.isSettled !== '') q.isSettled = String(q2.isSettled)
}
function reset() {
Object.assign(q, { projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
page.pageNum = 1
load()
}
function onSelect(arr) { selected.value = arr }
function openDetail(row) { detail.value = row; detailOpen.value = true }
function openCreate() {
formMode.value = 'create'
Object.assign(form, { projectId: null, projectNo: '', projectName: '', projectForm: '', totalSessions: 0, manageFee: 0, startTime: '', endTime: '' })
formOpen.value = true
}
function openEdit(row) {
formMode.value = 'edit'
Object.assign(form, {
projectId: row.projectId, projectNo: row.projectNo, projectName: row.projectName,
projectForm: row.projectForm, totalSessions: row.totalSessions, manageFee: row.manageFee,
startTime: row.startTime || '', endTime: row.endTime || ''
})
formOpen.value = true
}
function onCreateMeeting(row) {
ElMessage.info(`已为项目「${row.projectName}」启动建会流程`)
}
function onSupportUnit(row) {
if (!row.sponsorOrgId) { ElMessage.warning('该项目未关联支持方'); return }
ElMessage.info(`查看支持方: ${row.sponsorAdminUserName || row.sponsorOrgId}`)
}
async function onDeleteNotice(row) {
try { await ElMessageBox.confirm(`确定删除「${row.projectName}」的所有公告吗?删除后无法恢复`, '删除公告', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, noticeDeleted: '1' })
ElMessage.success('公告已删除')
load()
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
}
async function submitForm() {
if (!form.projectNo || !form.projectName) { ElMessage.warning('请填写项目编号与项目名称'); return }
try {
if (formMode.value === 'create') await bizAdd('project', { ...form })
else await bizUpdate('project', form)
ElMessage.success('保存成功')
formOpen.value = false
load()
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
}
function openRate(row) {
Object.assign(rateForm, { projectId: row.projectId, projectNo: row.projectNo, projectName: row.projectName, q1: 0, q2: 0, q3: 0, q4: 0, remark: '' })
rateOpen.value = true
}
async function submitRate() {
if (!rateForm.q1 && !rateForm.q2 && !rateForm.q3 && !rateForm.q4) {
ElMessage.warning('请至少完成一项评分'); return
}
// 计算 4 维平均 (任意维度填了就参与平均)
const scores = [rateForm.q1, rateForm.q2, rateForm.q3, rateForm.q4].map(v => Number(v))
const filled = scores.filter(v => v > 0)
const avg = filled.length ? (filled.reduce((s, v) => s + v, 0) / filled.length).toFixed(1) : null
try {
// 1. 写明细到 biz_project_rating (rater_role='sponsor', 后端自动填 raterId)
await request.post('/business/project/rate', {
projectId: rateForm.projectId,
projectNo: rateForm.projectNo,
projectName: rateForm.projectName,
raterRole: 'sponsor',
qualityScore: rateForm.q1,
responseScore: rateForm.q2,
cooperationScore: rateForm.q3,
complianceScore: rateForm.q4,
remark: rateForm.remark
})
// 2. 写聚合分到 biz_project.sponsor_score
if (avg != null) {
await bizUpdate('project', { projectId: rateForm.projectId, sponsorScore: Number(avg) })
}
ElMessage.success(`评分已提交, 平均分:${avg ?? '-'}`)
rateOpen.value = false
load()
} catch (e) { ElMessage.error(e?.msg || '提交失败') }
}
async function onBatchRate() {
if (!selected.value.length) return
let ok = 0
for (const r of selected.value) {
try {
await bizAdd('projectRating', { projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName, q1: 3, q2: 3, q3: 3, q4: 3, remark: '批量评分' })
ok++
} catch {}
}
ElMessage.success(`已批量评分 ${ok} 项`)
}
async function onBatchFinish() {
if (!selected.value.length) return
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, isFinished: '1' }); ok++ } catch {}
}
ElMessage.success(`已批量结题 ${ok} 项`)
load()
}
async function onBatchOpen() {
if (!selected.value.length) return
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', publishTime: nowLocalDatetime() }); ok++ } catch {}
}
ElMessage.success(`已批量开通 ${ok} 项`)
load()
}
async function onFinish(row) {
try { await ElMessageBox.confirm(`确定将「${row.projectName}」结题吗?`, '结题', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isFinished: '1' })
ElMessage.success('已结题')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
async function onOpen(row) {
try { await ElMessageBox.confirm(`确定开通「${row.projectName}」吗?`, '开通', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isPublished: '1', publishTime: nowLocalDatetime() })
ElMessage.success('已开通')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
function onAssignSelected() {
if (!selected.value.length) { ElMessage.warning('请先勾选项目'); return }
ElMessage.info(`已为 ${selected.value.length} 个项目启动批量分配流程,请逐个确认`)
}
function openAssign(row) {
assignForm.projectId = row.projectId
assignForm.projectNo = row.projectNo
assignForm.projectName = row.projectName
assignForm.supervisor = ''
assignForm.supervisionPoint = ''
assignOpen.value = true
}
async function submitAssign() {
if (!assignForm.supervisor) { ElMessage.warning('请选择监察员'); return }
try {
const person = personList.value.find(p => String(p.personId) === String(assignForm.supervisor))
if (!person || !person.userId) { ElMessage.error('未找到监察员账号'); return }
// sponsor 专属: POST /business/project/sponsorAssign (BizProjectSponsorAssign 字段)
await request.post('/business/project/sponsorAssign', {
projectId: assignForm.projectId,
projectNo: assignForm.projectNo,
projectName: assignForm.projectName,
monitorUserId: person.userId,
assignPoints: assignForm.supervisionPoint || '',
assignDesc: ''
})
ElMessage.success('分配成功')
assignOpen.value = false
} catch (e) { ElMessage.error(e?.msg || '分配失败') }
}
onMounted(() => { readQueryFromRoute(); load(); loadPersons() })
</script>
<style scoped>
/* 1:1 抄 doctor/Projects.vue + doctor/Submissions.vue */
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.hint { color: #909399; font-size: 12px; margin-left: 8px; }
.score-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
:deep(.el-table .el-button) { border-radius: 4px; }
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
</style>