Files
guoju0808/ry-vue3/src/views/manager/Projects.vue
T
郭庆泰 b52a334184 chore: biz_org 合并 / BizProject 重构 / 各角色页面调整 (上次重构遗留)
涵盖改动范围:
- 后端 BizOrg / BizProject / BizProjectRating / BizSpecialPlan controller + domain + mapper + service
- 前端 admin (BizSpecialPlanAdmin, ProjectCategory) / executor / leader / manager / sponsor 多页调整
- api/dict.js + AdminLayout.vue 同步
2026-08-18 00:30:34 +08:00

948 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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.sponsorOrgName" placeholder="输入支持单位名称" clearable /></el-form-item>
<el-form-item label="服务机构:"><el-input v-model="q.execOrgName" placeholder="输入服务机构" clearable /></el-form-item>
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称:"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></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-date-picker v-model="q.startTime" type="date" placeholder="开始日期" value-format="YYYY-MM-DD HH:mm:ss" />
<span style="margin:0 4px;"></span>
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD HH:mm:ss" />
</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 label="评价得分:"><el-input v-model="q.managerScore" placeholder="输入评分" clearable /></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 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>
</div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
<el-table-column type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="140" fixed="left" />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="totalSessions" label="总场次/总期数" width="100" 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="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" />
<el-table-column prop="paidMeetingAmount" label="已支付会务费" width="140" align="right" :formatter="fmtMoney" />
<el-table-column prop="managerScore" label="执行单位得分(合规)" width="140" 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="140" 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="sponsorOrgName" label="支持单位" width="160" show-overflow-tooltip />
<el-table-column prop="execOrgNames" label="服务机构" min-width="180" show-overflow-tooltip />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.isFinished === '1' || row.isFinished === 1" type="success">已结题</el-tag>
<el-tag v-else type="warning">未结题</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="项目开始时间" width="120" align="center" :formatter="fmtDate" />
<el-table-column prop="endTime" label="项目结束时间" width="120" align="center" :formatter="fmtDate" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="op-cell">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="primary" @click="doEdit(row)">编辑</el-button>
<el-dropdown trigger="click" @command="(cmd) => onAction(cmd, row)">
<el-button link type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="createMeeting">建会</el-dropdown-item>
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item command="assign">项目分配</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item command="exportExperts" divided>导出报名专家</el-dropdown-item>
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="activate" divided>开通</el-dropdown-item>
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="delAnn" divided>删除公告</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</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" />
</div>
<!-- 项目详情已迁到独立页面 /manager/projects/detail/:projectId -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<!-- ============= 新建项目 dialog已迁移到独立页面 /manager/projects/new ============= -->
<!-- ============= 项目分配 dialog按原型 project-assign.html 1:1 ============= -->
<el-dialog v-model="assignOpen" :title="assignBatchMode ? `批量项目分配 (${assignBatchProjects.length} 个项目)` : '项目分配'" width="780px" @closed="resetAssignForm">
<el-form :model="assignForm" label-width="120px">
<!-- 单选模式: 选项目 -->
<el-form-item v-if="!assignBatchMode" label="项目">
<el-select v-model="assignForm.projectId" placeholder="请选择项目" filterable style="width:100%" @change="onAssignProjectChange">
<el-option v-for="r in rows" :key="r.projectId" :label="`${r.projectNo} - ${r.projectName}`" :value="r.projectId" />
</el-select>
</el-form-item>
<!-- 批量模式: 显示已选项目数 -->
<el-form-item v-else label="已选项目">
<div style="color:#606266;font-size:13px"> <b>{{ assignBatchProjects.length }}</b> 个项目</div>
</el-form-item>
<el-form-item label="支持方">
<el-select v-model="assignForm.sponsorAdminUserId" placeholder="请选择支持单位" filterable :filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
<el-option v-for="u in sponsorOrgOptions" :key="u.userId" :label="u.orgName" :value="u.userId" />
</el-select>
</el-form-item>
<el-divider>执行方分配</el-divider>
<!-- 单选模式: 显示项目总场次 / 总金额() vs 已分配 (实时) -->
<div v-if="!assignBatchMode" class="assign-sessions-bar">
<span>项目总场次: <b>{{ assignForm.totalSessions || 0 }}</b></span>
<span :class="{ 'is-over': singleSessionOver }">已分配场次: <b>{{ assignedSessions }}</b></span>
<span v-if="singleSessionOver" class="over-warn"> 已超出 {{ assignedSessions - assignForm.totalSessions }} </span>
<span class="sep">|</span>
<span>总金额(): <b>{{ assignForm.totalAmount || 0 }}</b></span>
<span :class="{ 'is-over': singleAmountOver }">已分配金额: <b>¥ {{ assignedAmount }}</b></span>
<span v-if="singleAmountOver" class="over-warn"> 已超出 ¥ {{ Math.round((assignedAmount - assignForm.totalAmount) * 100) / 100 }}</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身总场次 / 总金额逐项校验 -->
<div v-else class="assign-sessions-bar">
<span>本次分配: <b>{{ assignedSessions }}</b> / <b>¥ {{ assignedAmount }}</b></span>
<span class="hint">(保存时按各项目自身的总场次和总金额逐项校验)</span>
</div>
<el-table :data="assignForm.execRows" border>
<el-table-column type="index" label="#" width="50" />
<el-table-column label="执行方名称" min-width="220">
<template #default="{ row }">
<el-select v-model="row.execUserId" filterable
:filter-method="q => searchExecutors(q, row)"
:loading="row._loading"
placeholder="搜索执行方用户"
style="width:100%"
@change="v => onExecUserPick(row, v)">
<el-option v-for="u in (row._options || [])" :key="u.userId"
:label="u.orgName" :value="u.userId" />
</el-select>
</template>
</el-table-column>
<el-table-column label="场次" width="110">
<template #default="{ row }">
<el-input-number v-model="row.sessions" :min="0" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="金额" width="140">
<template #default="{ row }">
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button link type="danger" @click="assignForm.execRows.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:8px">
<el-button type="primary" link @click="addExecRow">+ 增加执行方</el-button>
</div>
<el-form-item label="提交截止()" style="margin-top:12px">
<el-input-number v-model="assignForm.deadlineDays" :min="0" controls-position="right" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignOpen=false">取消</el-button>
<el-button type="primary" :loading="assignSubmitting" @click="submitAssign">保存</el-button>
</template>
</el-dialog>
<!-- ============= 批量评分 dialog(按原型 4 维度评分) ============= -->
<el-dialog v-model="batchOpen" :title="batchMap[batchKind]" width="626px" :show-close="false">
<!-- 评分专用字段: 共享一份评分 (与 singleScoreForm 同样的三列 table 布局) -->
<template v-if="batchKind==='score'">
<p style="color:#606266;margin-bottom:8px">将对以下 <b>{{ selection.length }}</b> 个项目共用同一份评分:</p>
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="background:#fafafa">
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
</tr></thead>
<tbody>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.qualityScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.responseScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.cooperationScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.complianceScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
</tbody>
</table>
</template>
<!-- 结题专用字段 -->
<template v-else-if="batchKind==='close'">
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
<p style="color:#606266;margin-top:8px">确定要将这些项目标记为结题吗?</p>
</template>
<!-- 开通专用字段 -->
<template v-else-if="batchKind==='activate'">
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
<el-form-item label="开通截止时间"><el-date-picker v-model="activateDeadline" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
</template>
<template #footer>
<el-button @click="batchOpen=false">取消</el-button>
<el-button type="primary" :loading="batchSubmitting" @click="confirmBatch">确定</el-button>
</template>
</el-dialog>
<!-- ============= 单行结题 modal ============= -->
<el-dialog v-model="closeModalOpen" title="您确定要结题吗?" width="420px" :show-close="false">
<template #footer>
<el-button type="primary" @click="confirmClose">确定</el-button>
<el-button @click="closeModalOpen=false">取消</el-button>
</template>
</el-dialog>
<!-- ============= 单行开通 modal ============= -->
<el-dialog v-model="activateModalOpen" title="您确定要开通吗?" width="420px" :show-close="false">
<template #footer>
<el-button type="primary" @click="confirmActivate">确定</el-button>
<el-button @click="activateModalOpen=false">取消</el-button>
</template>
</el-dialog>
<!-- ============= 单行删除公告 modal ============= -->
<el-dialog v-model="delAnnModalOpen" title="您确定要删除公告吗?删除后无法恢复" width="420px" :show-close="false">
<template #footer>
<el-button type="primary" @click="confirmDeleteAnn">确定</el-button>
<el-button @click="delAnnModalOpen=false">取消</el-button>
</template>
</el-dialog>
<!-- ============= 单行执行单位评分 modal4 维度) ============= -->
<el-dialog v-model="singleScoreModalOpen" title="执行单位评分" width="626px" :show-close="false">
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="background:#fafafa">
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
</tr></thead>
<tbody>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.qualityScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.responseScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.cooperationScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.complianceScore" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
</tbody>
</table>
<template #footer>
<el-button type="primary" @click="confirmSingleScore">确定</el-button>
<el-button @click="singleScoreModalOpen=false">取消</el-button>
</template>
</el-dialog>
</template>
<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 { listExecutorOrgs, listSponsorOrgs } from '@/api/system'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import Preview from '@/components/Preview.vue'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
const q = ref({ sponsorOrgName: '', execOrgName: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', managerScore: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
async function load() {
loading.value = true
try {
console.info('[manager-projects.load] START, q=', JSON.stringify(q.value), 'page=', JSON.stringify(page))
// axios 拦截器已将 TableDataInfo 归一化为 {code, msg, data: {total, rows}}
const { data } = await bizList('project', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
console.info('[manager-projects.load] OK, total=', data?.total, 'rows.length=', data?.rows?.length, 'firstRow=', JSON.stringify(data?.rows?.[0] || null))
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) {
console.error('[manager-projects.load] FAILED', e)
rows.value = []; page.total = 0
} finally { loading.value = false }
}
// 操作列按钮 handlers
function onAction(cmd, row) {
if (cmd === 'createMeeting') doCreateMeeting(row)
else if (cmd === 'close') openClose(row)
else if (cmd === 'assign') doAssign(row)
else if (cmd === 'rate') openSingleScore(row)
else if (cmd === 'exportExperts') exportExperts(row)
else if (cmd === 'activate') openActivate(row)
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
}
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
function doAssign(row) {
resetAssignForm()
assignForm.projectId = row.projectId
// 先打开 dialog, 再异步加载 (loadAssigns 是 async)
assignOpen.value = true
onAssignProjectChange(row.projectId)
// 预加载 sponsor 候选
setTimeout(() => loadSponsorOrgs(''), 100)
}
function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) }
// 删除项目(按需补充,原型无但需要)
async function doDelete(row) {
try {
await ElMessageBox.confirm(`确认删除项目"${row.projectName}"\n项目编号: ${row.projectNo}\n\n删除后无法恢复!`, '删除确认', { type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' })
await bizDelete('project', row.projectId)
ElMessage.success('已删除')
load()
} catch (e) {
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '删除失败')
}
}
function reset() { q.value = { sponsorOrgName:'', execOrgName:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', managerScore:'' }; page.pageNum = 1; load() }
// 导出
function exportProjects() { ElMessage.info('导出项目功能开发中') }
function exportEval() { ElMessage.info('项目评价导出功能开发中') }
// 导出某项目的报名专家 (biz_execution_intent)
// 字段: 专家姓名 科室 医院 职称 报名时间 手机号
async function exportExperts(row) {
if (!row || !row.projectNo) { ElMessage.warning('缺少项目编号'); return }
try {
// 后端 BizExecutionIntent 参数没 @RequestBody, 必须走 URL query string
const res = await request({
url: '/business/executionIntent/export',
method: 'post',
params: { projectNo: row.projectNo },
responseType: 'blob'
})
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `报名专家_${row.projectNo}.xlsx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
const msg = e?.msg || e?.message || '导出失败'
ElMessage.error(msg)
}
}
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog ==========
function openClose(row) {
if (row.isFinished === '1' || row.isFinished === 1) {
return ElMessage.warning('该项目已结题,无需重复操作')
}
closeTargetRow.value = row
closeModalOpen.value = true
}
function confirmClose() {
if (!closeTargetRow.value) return
bizUpdate('project', { projectId: closeTargetRow.value.projectId, isFinished: '1' })
.then(() => { ElMessage.success('结题成功'); closeModalOpen.value = false; load() })
.catch(() => ElMessage.error('结题失败'))
}
function openActivate(row) {
activateTargetRow.value = row
activateModalOpen.value = true
}
function confirmActivate() {
if (!activateTargetRow.value) return
bizUpdate('project', { projectId: activateTargetRow.value.projectId, isFinished: '0' })
.then(() => { ElMessage.success('已开通'); activateModalOpen.value = false; load() })
.catch(() => ElMessage.error('开通失败'))
}
function openDeleteAnnouncement(row) {
delAnnTargetRow.value = row
delAnnModalOpen.value = true
}
function confirmDeleteAnn() {
if (!delAnnTargetRow.value) return
// 清空全部公示 URL + 取消发布状态
// (mapper.updateByPrimaryKey 的 trim prefix="SET" 必须有字段, 不能只传空字符串)
bizUpdate('project', {
projectId: delAnnTargetRow.value.projectId,
publishUrl: '',
invitationUrl: '',
supportLetterUrl: '',
noticeUrl: '',
scheduleUrl: '',
isPublished: '0'
})
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
.catch(() => ElMessage.error('删除失败'))
}
async function openSingleScore(row) {
singleScoreTargetRow.value = row
singleScoreForm.qualityScore = 0
singleScoreForm.responseScore = 0
singleScoreForm.cooperationScore = 0
singleScoreForm.complianceScore = 0
singleScoreModalOpen.value = true
// 反显当前登录管理员的历史明细
const uid = userStore.user?.userId
if (!uid) return
try {
const r = await request({ url: '/business/project/ratings', method: 'get', params: { projectId: row.projectId } })
const list = (r.data && (Array.isArray(r.data) ? r.data : r.data.rows)) || r.rows || []
const mine = list.find(x =>
String(x.projectId) === String(row.projectId) &&
String(x.raterId) === String(uid) &&
x.raterRole === 'manager'
)
if (mine) {
singleScoreForm.qualityScore = Number(mine.qualityScore) || 0
singleScoreForm.responseScore = Number(mine.responseScore) || 0
singleScoreForm.cooperationScore = Number(mine.cooperationScore) || 0
singleScoreForm.complianceScore = Number(mine.complianceScore) || 0
}
} catch (e) { /* 拉取失败不影响打开, 维持默认 0 */ }
}
async function confirmSingleScore() {
if (!singleScoreTargetRow.value) return
const f = singleScoreForm
if (!f.qualityScore || f.qualityScore < 1 || f.qualityScore > 5) return ElMessage.error('履约质量必须是 1~5 的数字')
if (!f.responseScore || f.responseScore < 1 || f.responseScore > 5) return ElMessage.error('时效响应必须是 1~5 的数字')
if (!f.cooperationScore || f.cooperationScore < 1 || f.cooperationScore > 5) return ElMessage.error('配合度必须是 1~5 的数字')
if (!f.complianceScore || f.complianceScore < 1 || f.complianceScore > 5) return ElMessage.error('合规安全必须是 1~5 的数字')
const avg = ((f.qualityScore + f.responseScore + f.cooperationScore + f.complianceScore) / 4).toFixed(1)
const projectId = singleScoreTargetRow.value.projectId
try {
// 1. 写聚合分到 biz_project.manager_score
await bizUpdate('project', { projectId, managerScore: Number(avg) })
// 2. 写 4 维度明细到 biz_project_rating (rater_role='manager', 后端自动填 raterId)
await request.post('/business/project/rate', {
projectId,
raterRole: 'manager',
qualityScore: f.qualityScore,
responseScore: f.responseScore,
cooperationScore: f.cooperationScore,
complianceScore: f.complianceScore
})
ElMessage.success(`评分完成,平均分:${avg}`)
singleScoreModalOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '评分失败')
}
}
function viewDetail(row) { router.push(`/manager/projects/detail/${row.projectId}`) }
// 金额格式化: 1234567.5 -> "1,234,567.50"
function fmtMoney(row, col, v) {
if (v === null || v === undefined || v === '') return ''
const n = Number(v)
if (isNaN(n)) return String(v)
return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
// 日期格式化: "2026-08-09" 或 "2026-08-09 10:30:00" -> "2026.08.09"
function fmtDate(row, col, v) {
if (!v) return ''
const s = String(v)
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
return m ? `${m[1]}.${m[2]}.${m[3]}` : s
}
const selection = ref([])
function onSel(rs) { selection.value = rs }
const batchOpen = ref(false)
const batchKind = ref('')
const batchSubmitting = ref(false)
const batchMap = { score: '批量评分', close: '批量结题', activate: '批量开通' }
const batchScoreRows = ref([]) // 保留兼容旧引用 (实际不再使用)
const batchScoreForm = reactive({ qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 }) // 批量评分: 共享一份 4 维度
const activateDeadline = ref('')
// 5 个单行 modal 状态
const closeModalOpen = ref(false)
const closeTargetRow = ref(null)
const activateModalOpen = ref(false)
const activateTargetRow = ref(null)
const delAnnModalOpen = ref(false)
const delAnnTargetRow = ref(null)
const singleScoreModalOpen = ref(false)
const singleScoreTargetRow = ref(null)
const singleScoreForm = reactive({ qualityScore: 0, responseScore: 0, cooperationScore: 0, complianceScore: 0 })
// 新建项目已迁到独立页面 /manager/projects/new (ManagerProjectsNew.vue)
function openNewProject() {
router.push('/manager/projects/new')
}
function openAssign() {
if (!selection.value.length) {
ElMessage.warning('请先勾选项目')
return
}
resetAssignForm()
if (selection.value.length >= 2) {
assignBatchMode.value = true
assignBatchProjects.value = selection.value.map(r => ({ projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName }))
} else {
assignBatchMode.value = false
assignBatchProjects.value = []
assignForm.projectId = selection.value[0].projectId
onAssignProjectChange(assignForm.projectId)
}
assignOpen.value = true
setTimeout(() => {
assignForm.execRows.forEach(r => searchExecutors('', r))
loadSponsorOrgs('')
}, 100)
}
function openBatch(kind) {
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
batchKind.value = kind
if (kind === 'score') {
// 共享评分: 重置 form 为默认值 (5 分)
Object.assign(batchScoreForm, { qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 })
}
if (kind === 'activate') {
activateDeadline.value = ''
}
batchOpen.value = true
}
async function confirmBatch() {
if (batchKind.value === 'close') {
// 过滤掉已结题项目, 避免无效请求
const targets = selection.value.filter(r => r.isFinished !== '1' && r.isFinished !== 1)
const skipCount = selection.value.length - targets.length
try {
batchSubmitting.value = true
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: '1' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
} catch { ElMessage.error('批量结题失败') }
finally { batchSubmitting.value = false }
} else if (batchKind.value === 'activate') {
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
try {
batchSubmitting.value = true
await Promise.all(selection.value.map(r => bizUpdate('project', { projectId: r.projectId, endTime: activateDeadline.value })))
ElMessage.success(`已批量开通 ${selection.value.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
} catch { ElMessage.error('批量开通失败') }
finally { batchSubmitting.value = false }
} else if (batchKind.value === 'score') {
const f = batchScoreForm
if (!f.qualityScore || f.qualityScore < 1 || f.qualityScore > 5) return ElMessage.error('履约质量必须是 1~5 的数字')
if (!f.responseScore || f.responseScore < 1 || f.responseScore > 5) return ElMessage.error('时效响应必须是 1~5 的数字')
if (!f.cooperationScore || f.cooperationScore < 1 || f.cooperationScore > 5) return ElMessage.error('配合度必须是 1~5 的数字')
if (!f.complianceScore || f.complianceScore < 1 || f.complianceScore > 5) return ElMessage.error('合规安全必须是 1~5 的数字')
const total = ((f.qualityScore + f.responseScore + f.cooperationScore + f.complianceScore) / 4).toFixed(1)
try {
batchSubmitting.value = true
let ok = 0
// 共享评分: 所有项目用同一组 4 维度
for (const r of selection.value) {
try {
await bizUpdate('project', {
projectId: r.projectId,
managerScore: Number(total)
})
await request.post('/business/project/rate', {
projectId: r.projectId,
raterRole: 'manager',
qualityScore: f.qualityScore,
responseScore: f.responseScore,
cooperationScore: f.cooperationScore,
complianceScore: f.complianceScore
})
ok++
} catch {}
}
ElMessage.success(`已批量评分 ${ok}/${selection.value.length} 个项目 (共用评分 ${total})`)
batchOpen.value = false
selection.value = []
load()
} finally { batchSubmitting.value = false }
}
}
// ========== 新建项目 ==========
const newOpen = ref(false)
// 新建项目独立页面: /manager/projects/new (ManagerProjectsNew.vue)
// ========== 项目分配 ==========
const assignOpen = ref(false)
const assignBatchMode = ref(false) // 项目分配批量模式开关
const assignBatchProjects = ref([]) // 批量模式下的项目列表 [{projectId, projectNo, projectName}]
const assignSubmitting = ref(false)
const assignForm = reactive({
projectId: '',
sponsorAdminUserId: null,
sponsorAdminUserName: '',
totalSessions: 0, // 项目总场次 (来自项目本身, 不可被分配覆盖)
totalAmount: 0, // 项目总金额(元) (来自项目本身, 不可被分配覆盖)
execRows: [], // 动态加
deadlineDays: 30
})
const sponsorOrgOptions = ref([])
let _supporterTimer = null
// 已分配场次合计 (实时)
const assignedSessions = computed(() =>
assignForm.execRows.reduce((s, r) => s + Number(r.sessions || 0), 0)
)
const singleSessionOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalSessions || 0) > 0 && assignedSessions.value > Number(assignForm.totalSessions)
)
// 已分配金额合计 (实时, 保留 2 位小数避免浮点漂移)
const assignedAmount = computed(() => {
const sum = assignForm.execRows.reduce((s, r) => s + Number(r.amount || 0), 0)
return Math.round(sum * 100) / 100
})
const singleAmountOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalAmount || 0) > 0 && assignedAmount.value > Number(assignForm.totalAmount)
)
function makeEmptyExecRow() {
return { _loading: false, _timer: null, _options: [], execUserId: null, sessions: 0, amount: 0, remark: '' }
}
function resetAssignForm() {
Object.assign(assignForm, { projectId:'', sponsorAdminUserId:null, sponsorAdminUserName:'', totalSessions:0, totalAmount:0, execRows:[makeEmptyExecRow()], deadlineDays:30 })
sponsorOrgOptions.value = []
assignBatchMode.value = false
assignBatchProjects.value = []
}
function addExecRow() {
const row = makeEmptyExecRow()
assignForm.execRows.push(row)
// 新行立即加载默认列表
searchExecutors('', row)
}
function onAssignProjectChange(v) {
const proj = rows.value.find(r => r.projectId === v)
if (proj) {
assignForm.sponsorAdminUserId = proj.sponsorAdminUserId || null
assignForm.sponsorAdminUserName = proj.sponsorAdminUserName || ''
assignForm.totalSessions = Number(proj.totalSessions || 0)
assignForm.totalAmount = Number(proj.totalAmount || 0)
}
// 切换项目时重新拉已分配记录 (回显)
loadAssigns(v)
// 重新拉支持方候选
loadSponsorOrgs('')
}
// 加载支持方 (sponsor 角色) - 按公司名查, JOIN biz_org + sys_user
async function loadSponsorOrgs(query) {
if (_supporterTimer) clearTimeout(_supporterTimer)
_supporterTimer = setTimeout(async () => {
try {
// 主搜索: 按 org_name 模糊匹配
const params = {}
if (query) params.orgName = query
const r = await listSponsorOrgs(params)
sponsorOrgOptions.value = r.data || []
// 已选项不在结果里时按 userId 单独拉取并加首位
if (assignForm.sponsorAdminUserId && !sponsorOrgOptions.value.find(u => u.userId === assignForm.sponsorAdminUserId)) {
const r2 = await listSponsorOrgs({ userId: assignForm.sponsorAdminUserId })
const sel = (r2.data || []).find(u => u.userId === assignForm.sponsorAdminUserId)
if (sel) sponsorOrgOptions.value = [sel, ...sponsorOrgOptions.value]
}
} catch (e) {
sponsorOrgOptions.value = []
}
}, 200)
}
function searchSponsorOrgs(q) { loadSponsorOrgs(q) }
// 选中支持方后回填 user_name (缓存到 biz_project.sponsor_admin_user_name)
function onSponsorOrgPick(userId) {
const u = sponsorOrgOptions.value.find(x => x.userId === userId)
if (u) assignForm.sponsorAdminUserName = u.userName
}
// 拉取项目已分配的执行方
async function loadAssigns(projectId) {
if (!projectId) { assignForm.execRows = [makeEmptyExecRow()]; return }
try {
const r = await request({ url: `/business/project/${projectId}/assigns`, method: 'get' })
const list = r.data || []
if (list.length) {
assignForm.execRows = list.map(a => ({
...makeEmptyExecRow(),
execUserId: a.execUserId,
sessions: a.sessions || 0,
amount: Number(a.amount || 0),
remark: a.remark || ''
}))
} else {
assignForm.execRows = [makeEmptyExecRow()]
}
// 每一行立即触发远程搜索, 让 el-select 拿到 _options 才能正确显示已选 label
assignForm.execRows.forEach(r => searchExecutors('', r))
} catch (e) {
console.error('加载已分配执行方失败', e)
assignForm.execRows = [makeEmptyExecRow()]
assignForm.execRows.forEach(r => searchExecutors('', r))
}
}
// 加载执行方 (按执行单位名 org_name 模糊匹配)
// 关键: 数据源是 biz_org (org_type='executor') JOIN sys_user (MAIN 账号 only)
// label=orgName (执行单位名称), value=MAIN user_id (直接写 biz_project_assign.exec_user_id)
// 走 /business/org/executorOptions (不在走 sys_user.role_type='executor' 查询, 避免子账号混入)
async function searchExecutors(query, row) {
if (row._timer) clearTimeout(row._timer)
row._loading = true
row._timer = setTimeout(async () => {
try {
const params = {}
if (query) params.orgName = query
const r = await listExecutorOrgs(params)
row._options = r.data || []
// 选中的项如果不在结果里, 按 userId 单独拉取并加到首位 (保证已选项在 el-option 中能找到 label)
if (row.execUserId && !row._options.find(u => u.userId === row.execUserId)) {
const r2 = await listExecutorOrgs({ userId: row.execUserId })
const sel = (r2.data || []).find(u => u.userId === row.execUserId)
if (sel) row._options = [sel, ...row._options]
}
} catch (e) {
row._options = []
} finally {
row._loading = false
}
}, 300)
}
// 选中执行方后只保留 userId (后端 service 层会反查 biz_org 写 execution_unit_id;
// 名称/nickName/deptName 不再缓存, 由前端从 _options 里实时取)
function onExecUserPick(row, userId) {
const u = (row._options || []).find(x => x.userId === userId)
if (u) {
// 仅清掉旧的非持久化字段 (兼容旧 _options 残留), 业务字段不写
}
}
async function submitAssign() {
// 批量模式不需要选项目, 单选模式必选
if (!assignBatchMode.value && !assignForm.projectId) return ElMessage.warning('请选择项目')
const valid = assignForm.execRows.filter(r => r.execUserId && Number(r.sessions) > 0)
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
const amount = Math.round(valid.reduce((s, r) => s + Number(r.amount || 0), 0) * 100) / 100
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额
// (项目自身的 total_sessions / total_amount 是创建时定的, 不再被分配覆盖)
if (assignBatchMode.value) {
for (const p of assignBatchProjects.value) {
const proj = rows.value.find(r => r.projectId === p.projectId)
const capS = proj ? Number(proj.totalSessions || 0) : 0
const capA = proj ? Number(proj.totalAmount || 0) : 0
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ${sessions} 场, 超过总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ¥${amount}, 超过总金额 ¥${capA}, 请调整`)
}
}
} else {
const capS = Number(assignForm.totalSessions || 0)
const capA = Number(assignForm.totalAmount || 0)
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`已分配 ${sessions} 场, 超过项目总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`已分配 ¥${amount}, 超过项目总金额 ¥${capA}, 请调整`)
}
}
const assignBody = valid.map(r => ({
execUserId: r.execUserId,
sessions: r.sessions,
amount: r.amount,
remark: r.remark
}))
assignSubmitting.value = true
try {
if (assignBatchMode.value) {
// 批量: 每个项目单独更新 + 单独写分配 (后端先删后插策略)
let ok = 0, errs = []
for (const p of assignBatchProjects.value) {
try {
await bizUpdate('project', {
projectId: p.projectId,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
url: `/business/project/${p.projectId}/assigns`,
method: 'post',
data: assignBody
})
ok++
} catch (e) {
errs.push(`${p.projectNo}: ${e?.msg || e.message}`)
}
}
if (errs.length) ElMessageBox.alert(`批量分配: 成功 ${ok}/${assignBatchProjects.value.length}\n\n失败:\n${errs.join('\n')}`, '分配结果', { type: 'warning' })
else ElMessage.success(`批量分配成功 (${ok}/${assignBatchProjects.value.length})`)
} else {
// 单条模式
await bizUpdate('project', {
projectId: assignForm.projectId,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
url: `/business/project/${assignForm.projectId}/assigns`,
method: 'post',
data: assignBody
})
ElMessage.success('分配成功')
}
assignOpen.value = false
load()
} catch (e) {
console.error(e)
ElMessage.error(e?.msg || '分配失败')
}
finally { assignSubmitting.value = false }
}
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('附件预览')
function openPreview(url, title) { previewUrl.value = url; previewTitle.value = title || '附件预览'; previewOpen.value = true }
onMounted(load)
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载)
watch(() => route.fullPath, (newPath, oldPath) => {
if (newPath === '/manager/projects') {
console.info('[manager-projects] route watch reload, from=', oldPath, 'to=', newPath)
load()
}
})
// 兜底: 即便路由完全相同,从列表跳到详情再回来也触发
watch(() => route.path + (route.query._t || ''), () => load())
</script>
<style scoped>
/* 1:1 抄 sponsor/SponsorProjects.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; }
/* 操作列下拉 */
.op-cell { display: inline-flex; align-items: center; gap: 12px; }
.op-cell :deep(.el-button) { margin-left: 0 !important; padding: 0 4px !important; }
.op-dropdown { padding-right: 0; }
.op-caret { margin-left: 2px; font-size: 10px; }
/* 工具按钮 (与 sponsor 一致) */
.toolbar :deep(.action-btn) {
height: 32px; padding: 0 16px; font-size: 13px; border-radius: 4px;
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
transition: opacity .15s;
}
.toolbar :deep(.action-btn:hover) { opacity: .85; }
.toolbar :deep(.action-btn.secondary) {
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
}
.toolbar :deep(.action-btn:disabled) {
background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; cursor: not-allowed;
}
.toolbar :deep(.badge) {
margin-left: 4px; padding: 0 6px; background: var(--brand-primary); color: #fff;
border-radius: 8px; font-size: 11px; line-height: 16px;
}
.toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
.page-toolbar :deep(.action-btn.is-disabled),
.page-toolbar :deep(.action-btn:disabled) { background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; opacity: 1; cursor: not-allowed; }
.page-toolbar :deep(.action-btn.secondary.is-disabled),
.page-toolbar :deep(.action-btn.secondary:disabled) { background: #fff; color: #c0c4cc; border-color: #e4e7ed; }
.page-toolbar :deep(.action-btn .badge) {
display: inline-block; margin-left: 6px; padding: 0 6px; min-width: 18px; height: 18px;
line-height: 18px; font-size: 11px; text-align: center;
background: #fff; color: var(--brand-primary); border-radius: 9px;
}
.page-toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
.score-row { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.score-row:last-child { border-bottom: none; }
.score-title { font-weight: 600; color: #303133; margin-bottom: 6px; }
.score-no { color: #909399; font-weight: normal; font-size: 12px; }
.score-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.score-item { display: flex; align-items: center; gap: 8px; }
.score-item span { width: 70px; color: #606266; font-size: 13px; }
/* 分配 dialog 总场次条 */
.assign-sessions-bar {
display: flex; align-items: center; gap: 18px;
margin: -4px 0 12px; padding: 10px 14px;
background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
font-size: 13px; color: #606266;
}
.assign-sessions-bar b { color: #303133; font-size: 14px; margin: 0 2px; }
.assign-sessions-bar .is-over b { color: #f56c6c; }
.assign-sessions-bar .over-warn { color: #f56c6c; font-weight: 600; }
.assign-sessions-bar .hint { color: #909399; font-size: 12px; }
.assign-sessions-bar .sep { color: #dcdfe6; margin: 0 4px; }
</style>