Files
guoju0808/ry-vue3/src/views/manager/Projects.vue
T

759 lines
39 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-select
v-model="q.sponsorOrgIds"
multiple
filterable
remote
:remote-method="searchSponsorOrgsForFilter"
:loading="sponsorSearching"
clearable
collapse-tags
collapse-tags-tooltip
placeholder="输入支持单位名称搜索"
style="width:260px"
>
<el-option v-for="o in sponsorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
</el-select>
</el-form-item>
<el-form-item label="服务机构:">
<el-select
v-model="q.execOrgIds"
multiple
filterable
remote
:remote-method="searchExecutorOrgsForFilter"
:loading="execSearching"
clearable
collapse-tags
collapse-tags-tooltip
placeholder="输入服务机构名称搜索"
style="width:260px"
>
<el-option v-for="o in executorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
</el-select>
</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" />
<span style="margin:0 4px;"></span>
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" />
</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 class="action-btn" @click="openNewProject">新建项目</el-button>
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openAssign">
项目分配<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('score')">
批量评分<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('close')">
批量结题<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('activate')">
批量开通<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
<el-button v-if="canDelete" class="action-btn secondary" type="danger" :disabled="!selection.length" @click="onBatchDelete">
批量删除<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
</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="doCreateMeeting(row)">建会</el-button>
<el-button link type="primary" @click="doAssign(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 v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="activate">开通</el-dropdown-item>
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item command="exportExperts">导出报名专家</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</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 已迁移到独立子页面 /manager/projects/assign (单条) /assign-batch (批量) -->
<!-- ============= 批量评分 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="date" value-format="YYYY-MM-DD" 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 ( open_status=Y + 开通截止时间 open_deadline) ============= -->
<el-dialog v-model="activateModalOpen" title="开通项目" width="420px" :show-close="false">
<el-form-item label="开通截止时间">
<el-date-picker v-model="activateDeadlineSingle" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<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({ sponsorOrgIds: [], execOrgIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
async function load() {
loading.value = true
try {
// axios 拦截器已将 TableDataInfo 归一化为 {code, msg, data: {total, rows}}
const { data } = await bizList('project', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) {
rows.value = []; page.total = 0
} finally { loading.value = false }
}
// 操作列按钮 handlers
function onAction(cmd, row) {
if (cmd === 'close') openClose(row)
else if (cmd === 'view') viewDetail(row)
else if (cmd === 'edit') doEdit(row)
else if (cmd === 'activate') openActivate(row)
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
else if (cmd === 'rate') openSingleScore(row)
else if (cmd === 'exportExperts') exportExperts(row)
else if (cmd === 'delete') doDelete(row)
}
// 仅 admin 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验)
const canDelete = computed(() => {
const r = userStore.role
return r === 'admin'
})
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
function doAssign(row) {
// 跳转独立子页面 /manager/projects/assign?projectId=X
router.push({ path: '/manager/projects/assign', query: { projectId: row.projectId } })
}
function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) }
// 删除项目 (软删除 + 级联置 5 张项目表 + 项目下所有会议 is_deleted=1)
// 后端 BizProjectController.remove 已强制仅 admin, 前端只做可见性 v-if
async function doDelete(row) {
try {
await ElMessageBox.confirm(
`确认删除项目"${row.projectName}"\n项目编号: ${row.projectNo}\n\n` +
`此操作将级联软删除:\n` +
`· 项目主表 + 方案 + 执行方分配 + 支持方分配 + 评分 (5 张表)\n` +
`· 项目下所有会议 + 会议附件 + 参会人 + 监督员 + 执行人 + 审计日志 (6 张表)\n\n` +
`数据保留在库, 不再展示给前台用户, 审计追溯可查。`,
'删除确认',
{ type: 'error', confirmButtonText: '确认软删除', cancelButtonText: '取消' }
)
await bizDelete('project', row.projectId)
ElMessage.success('已删除')
// 从 selection 里移除已删行, 避免批量按钮残留
selection.value = selection.value.filter(r => r.projectId !== row.projectId)
load()
} catch (e) {
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '删除失败')
}
}
// 批量软删除 (admin/manager)
async function onBatchDelete() {
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
try {
await ElMessageBox.confirm(
`确认批量软删除选中的 ${selection.value.length} 个项目?\n\n` +
`此操作将级联软删除每个项目下的:\n` +
`· 5 张项目表 (主表/方案/分配/支持方/评分)\n` +
`· 6 张会议表 (会议本身 + 附件 + 参会人 + 监督员 + 执行人 + 审计日志)\n\n` +
`数据保留, 不再展示给前台用户。`,
'批量删除确认',
{ type: 'error', confirmButtonText: `确认删除 ${selection.value.length} 个`, cancelButtonText: '取消' }
)
} catch { return }
const ids = selection.value.map(r => r.projectId)
try {
await bizDelete('project', ids)
ElMessage.success(`已批量软删除 ${ids.length} 个项目`)
selection.value = []
load()
} catch (e) {
ElMessage.error(e?.msg || '批量删除失败')
}
}
function reset() { q.value = { sponsorOrgIds:[], execOrgIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'' }; page.pageNum = 1; load() }
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=1 / ?isSettled=Y 等, 让列表页自动应用筛选)
// 注意: 只读不改 URL — 列表页内 reset()/search 不反向写 URL, Workbench 是 source of truth
function readQueryFromRoute() {
const q2 = route.query
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
}
// 导出
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
activateDeadlineSingle.value = row.openDeadline || ''
activateModalOpen.value = true
}
function confirmActivate() {
if (!activateTargetRow.value) return
if (!activateDeadlineSingle.value) return ElMessage.warning('请选择开通截止时间')
bizUpdate('project', { projectId: activateTargetRow.value.projectId, openStatus: 'Y', openDeadline: activateDeadlineSingle.value })
.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
// 删除公告: 后端专用端点, 将 3 个公示 URL 置 NULL (未发布由 URL 为空判定, 无独立 is_published 列)
request.delete(`/business/project/${delAnnTargetRow.value.projectId}/announcement`)
.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 batchScoreForm = reactive({ qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 }) // 批量评分: 共享一份 4 维度
const activateDeadline = ref('')
const activateDeadlineSingle = 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
}
// 跳转独立子页面 /manager/projects/assign, 单条 vs 批量通过 query 区分:
// 单条: ?projectId=X → 子页面 v-if 单条分支
// 批量: ?projectIds=1,2,3 → 子页面 v-else 批量分支
if (selection.value.length === 1) {
router.push({ path: '/manager/projects/assign', query: { projectId: selection.value[0].projectId } })
} else {
const ids = selection.value.map(r => r.projectId).join(',')
router.push({ path: '/manager/projects/assign', query: { projectIds: ids } })
}
}
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, openStatus: 'Y', openDeadline: 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)
// ========== 项目分配 dialog 已迁到独立子页面 /manager/projects/assign (单条) 和 /assign-batch (批量) ==========
// 仅保留顶部筛选区使用的远程搜索状态 (sponsorOrgOptions / executorOrgOptions), 不再保留 dialog 相关 state 和 functions
const sponsorOrgOptions = ref([])
const sponsorSearching = ref(false)
let _supporterFilterTimer = null
// 顶部筛选区专用
const executorOrgOptions = ref([])
const execSearching = ref(false)
let _executorFilterTimer = null
// 顶部筛选区远程搜索 (多选 el-select, 复用 sponsorOrgOptions, 加 loading + 已选项重拉回)
function searchSponsorOrgsForFilter(query) {
if (_supporterFilterTimer) clearTimeout(_supporterFilterTimer)
sponsorSearching.value = true
_supporterFilterTimer = setTimeout(async () => {
try {
const params = {}
if (query) params.orgName = query
const r = await listSponsorOrgs(params)
sponsorOrgOptions.value = r.data || []
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
const ids = q.value.sponsorOrgIds || []
for (const id of ids) {
if (!sponsorOrgOptions.value.find(u => u.orgId === id)) {
const r2 = await listSponsorOrgs({ orgId: id })
const sel = (r2.data || []).find(u => u.orgId === id)
if (sel) sponsorOrgOptions.value = [sel, ...sponsorOrgOptions.value]
}
}
} catch (e) {
sponsorOrgOptions.value = []
} finally {
sponsorSearching.value = false
}
}, 200)
}
// 顶部筛选区远程搜索 (服务机构多选 el-select, 加 loading + 已选项重拉回)
function searchExecutorOrgsForFilter(query) {
if (_executorFilterTimer) clearTimeout(_executorFilterTimer)
execSearching.value = true
_executorFilterTimer = setTimeout(async () => {
try {
const params = {}
if (query) params.orgName = query
const r = await listExecutorOrgs(params)
executorOrgOptions.value = r.data || []
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
const ids = q.value.execOrgIds || []
for (const id of ids) {
if (!executorOrgOptions.value.find(u => u.orgId === id)) {
const r2 = await listExecutorOrgs({ orgId: id })
const sel = (r2.data || []).find(u => u.orgId === id)
if (sel) executorOrgOptions.value = [sel, ...executorOrgOptions.value]
}
}
} catch (e) {
executorOrgOptions.value = []
} finally {
execSearching.value = false
}
}, 200)
}
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('附件预览')
function openPreview(url, title) { previewUrl.value = url; previewTitle.value = title || '附件预览'; previewOpen.value = true }
onMounted(() => { readQueryFromRoute(); load() })
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载, query 变了也重新应用筛选)
watch(() => route.fullPath, (newPath, oldPath) => {
if (newPath === '/manager/projects') {
readQueryFromRoute()
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>