Files
guoju0808/ry-vue3/src/views/sponsor/SponsorProjects.vue
T
2026-08-26 23:14:46 +08:00

488 lines
22 KiB
Vue

<template>
<!--
我的项目 (sponsor 专属, 团队模式: 主账号+子账号关联的项目; sponsor 角色可见, router meta.role='sponsor' + beforeEach 守卫拦截)
容器: <div class="page-card"> ( People.vue / NewPerson.vue 同款)
面包屑: <div class="breadcrumb">首页 / 我的项目</div>
1:1 抄原型: proto/html/components/sponsor-projects.html
15 : checkbox + 项目编号 + 项目名称 + 总场次/已执行/未执行 + 4 金额 + 开始/结束时间 + 是否结题 + 执行单位评价 + 操作
Modal 1: 项目评价 (4 维度星级 1-5 + 备注) -> POST /business/project/rate [hmr fix]
Modal 2: 项目分配 (监察员 chip + 说明 + 要点) -> POST /business/project/sponsorAssign
-->
<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-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</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>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- SUB 子账号: 仅查看被分配的项目, 不参与重新分配, 隐藏"项目分配"按钮 -->
<div v-if="!isSub" class="toolbar">
<el-button type="primary" @click="onAssignBatch">项目分配</el-button>
</div>
<GrTable :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange"
:main-cols="['projectNo', 'projectName']"
>
<!-- SUB 子账号无需勾选 (不参与分配) -->
<el-table-column v-if="!isSub" type="selection" width="40" />
<el-table-column label="项目编号" width="170">
<template #default="{ row }">
<a class="link">{{ row.projectNo }}</a>
</template>
</el-table-column>
<el-table-column label="项目名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<a class="link-bold">{{ row.projectName }}</a>
</template>
</el-table-column>
<el-table-column prop="totalSessions" label="总场次" width="80" 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="120" align="right" :formatter="fmtMoney" />
<el-table-column prop="availableAmount" label="可用金额" width="120" align="right" :formatter="fmtMoney" />
<el-table-column prop="paidLaborAmount" label="劳务费" width="120" align="right" :formatter="fmtMoney" />
<el-table-column prop="paidMeetingAmount" label="会务费" width="120" align="right" :formatter="fmtMoney" />
<el-table-column label="项目开始时间" width="170" align="center">
<template #default="{ row }">
<span class="mono">{{ fmtDate(row.startTime) }}</span>
</template>
</el-table-column>
<el-table-column label="项目结束时间" width="170" align="center">
<template #default="{ row }">
<span class="mono">{{ fmtDate(row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column label="是否结题" width="90" align="center">
<template #default="{ row }">
<span>{{ row.isFinished === '1' ? '已结题' : '未结题' }}</span>
</template>
</el-table-column>
<el-table-column label="执行单位评价(支持方)" width="150" align="center">
<template #default="{ row }">
<a v-if="row.sponsorScore" class="link">{{ row.sponsorScore }}</a>
<span v-else style="color:#909399"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="openSingleScore(row)">项目评价</el-link>
<!-- SUB 子账号仅查看, 不参与分配 -->
<el-link :underline="false" v-if="!isSub" type="primary" @click="openAssign(row)">分配</el-link>
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
</div></template>
</el-table-column>
</GrTable>
<el-pagination
class="pagination"
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:page-sizes="[10, 20, 50]"
:total="page.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="load"
@current-change="load"
/>
<!-- ============ Modal 1: 项目评价 ( manager/Projects.vue 完全相同的样式) ============ -->
<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>
<!-- ============ Modal 2: 项目分配 (单选 / 批量复用) ============ -->
<el-dialog v-model="assignVisible" :title="assignBatchMode ? `批量项目分配 (${assignBatchRows.length} 个项目)` : '项目分配'" width="600px" append-to-body destroy-on-close>
<!-- 单选模式: 显示项目编号 / 名称 -->
<div v-if="!assignBatchMode" class="modal-project-info">
<div><span class="lbl">项目编号</span><span>{{ assignForm.projectNo }}</span></div>
<div><span class="lbl">项目名称</span><span>{{ assignForm.projectName }}</span></div>
</div>
<!-- 批量模式: 显示已选项目摘要 -->
<div v-else class="modal-project-info">
<div><span class="lbl">已选项目数</span><span>{{ assignBatchRows.length }}</span></div>
<div><span class="lbl">项目编号预览</span><span style="color:#606266;font-size:12px">{{ assignBatchRows.slice(0,5).map(r=>r.projectNo).join('、') }}{{ assignBatchRows.length > 5 ? ' 等' : '' }}</span></div>
</div>
<el-form :model="assignForm" label-width="100px" class="modal-form">
<el-form-item label="分配给监察员">
<el-select v-model="assignForm.monitorUserIds" multiple collapse-tags collapse-tags-tooltip filterable placeholder="请选择监察员 (姓名 / 手机号, 可多选)" style="width: 100%">
<el-option
v-for="m in allMonitors"
:key="m.userId"
:label="m.name + ' (' + m.phone + ')'"
:value="m.userId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="primary" :loading="assignSubmitting" :disabled="!assignForm.monitorUserIds.length" @click="saveAssign">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import GrTable from '@/components/GrTable.vue'
import request from '@/utils/request'
import { listSponsorProjects, rateProject, sponsorAssignProject, sponsorAssignBatch, getSponsorAssigns } from '@/api/business/project'
import { bizUpdate } from '@/api/public'
import { listSponsorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const router = useRouter()
// SUB 子账号: 只能看被分配的项目, 不能修改分配
const isSub = computed(() => userStore.isSub)
const loading = ref(false)
const rows = ref([])
const selected = ref([])
const dateRange = ref([])
const q = reactive({
projectNo: '',
projectName: '',
isFinished: ''
})
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
function fmtMoney(_row, _col, val) {
if (val === null || val === undefined || val === '') return '—'
return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function fmtDate(val) {
if (!val) return '—'
return String(val).substring(0, 10)
}
async function load() {
loading.value = true
try {
const params = {
pageNum: page.pageNum,
pageSize: page.pageSize,
projectNo: q.projectNo || undefined,
projectName: q.projectName || undefined,
isFinished: q.isFinished || undefined,
startTime: dateRange.value?.[0] ? dateRange.value[0] + ' 00:00:00' : undefined,
endTime: dateRange.value?.[1] ? dateRange.value[1] + ' 23:59:59' : undefined
}
const res = await listSponsorProjects(params)
rows.value = res.rows || []
page.total = res.total || 0
} finally {
loading.value = false
}
}
function reset() {
q.projectNo = ''
q.projectName = ''
q.isFinished = ''
dateRange.value = []
page.pageNum = 1
load()
}
function onSelectionChange(val) { selected.value = val }
function onView(row) {
// 跳项目详情页 (复用 manager/ManagerProjectDetail.vue, goBack 自动按角色回退)
router.push(`/sponsor/projects/detail/${row.projectId}`)
}
// ========== 执行单位评分 (与 manager/Projects.vue 完全相同的样式和逻辑) ==========
const singleScoreModalOpen = ref(false)
const singleScoreTargetRow = ref(null)
const singleScoreForm = reactive({ qualityScore: 0, responseScore: 0, cooperationScore: 0, complianceScore: 0 })
async function openSingleScore(row) {
singleScoreTargetRow.value = row
singleScoreForm.qualityScore = 0
singleScoreForm.responseScore = 0
singleScoreForm.cooperationScore = 0
singleScoreForm.complianceScore = 0
singleScoreModalOpen.value = true
// 回显当前 sponsor 在该项目上的历史评分
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 === 'sponsor'
)
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.sponsor_score
await bizUpdate('project', { projectId, sponsorScore: Number(avg) })
// 2. 写 4 维度明细到 biz_project_rating (rater_role='sponsor', 后端自动填 raterId)
await rateProject({
projectId,
raterRole: 'sponsor',
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 || '评分失败')
}
}
// ========== Modal 2: 项目分配 (单选 / 批量) ==========
const assignVisible = ref(false)
const assignBatchMode = ref(false) // 批量模式开关
const assignBatchRows = ref([]) // 批量模式下被分配的项目列表
const assignSubmitting = ref(false)
const assignForm = reactive({
projectId: null, projectNo: '', projectName: '',
monitorUserIds: []
})
const allMonitors = ref([])
async function loadMonitors() {
// 跟 sponsor/people (人员管理) 完全一致: 走 sponsorList, SQL 硬编码 unit_type='sponsor'
// + (u.parent_user_id = mainUid OR u.user_id = mainUid), 自动取当前主账号
// 仅取 SUB 子账号 (监察员候选人), 排除主账号
const res = await listSponsorPerson({ pageNum: 1, pageSize: 100 })
allMonitors.value = (res.rows || []).filter(p => p.accountType === 'SUB')
}
/** 拉项目当前已分配监察员 → 回显 monitorUserIds (单选模式) */
async function loadCurrentAssigns(projectId) {
try {
const resp = await getSponsorAssigns(projectId)
const list = (resp && resp.data) || []
assignForm.monitorUserIds = (Array.isArray(list) ? list : []).map(x => x.monitorUserId).filter(Boolean)
} catch (e) {
assignForm.monitorUserIds = []
}
}
function openAssign(row) {
assignBatchMode.value = false
assignBatchRows.value = []
assignForm.projectId = row.projectId
assignForm.projectNo = row.projectNo
assignForm.projectName = row.projectName
assignForm.monitorUserIds = []
assignVisible.value = true
loadMonitors()
// 异步回显当前已分配 (等候选加载好, 让 el-select 找得到 option)
loadCurrentAssigns(row.projectId)
}
function openAssignBatch(rows) {
assignBatchMode.value = true
assignBatchRows.value = rows.map(r => ({ projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName }))
// 批量模式: 不同项目原监察员可能不同, 不回显 (提交时按当前多选覆盖全部)
assignForm.monitorUserIds = []
assignVisible.value = true
loadMonitors()
}
async function saveAssign() {
if (!assignForm.monitorUserIds.length) return ElMessage.warning('请至少选择一位监察员')
assignSubmitting.value = true
try {
if (assignBatchMode.value) {
// 批量模式: 每个项目都按当前多选覆盖 (后端先删后插 N 条)
let ok = 0, errs = []
for (const r of assignBatchRows.value) {
try {
await sponsorAssignProject({
projectId: r.projectId,
monitorUserIds: [...assignForm.monitorUserIds]
})
ok++
} catch (e) { errs.push(`${r.projectNo}: ${e?.msg || e.message}`) }
}
if (errs.length) ElMessageBox.alert(`批量分配: 成功 ${ok}/${assignBatchRows.value.length}\n\n失败:\n${errs.join('\n')}`, '分配结果', { type: 'warning' })
else ElMessage.success(`批量分配成功 (${ok}/${assignBatchRows.value.length})`)
} else {
// 单条模式: 多选一次提交
await sponsorAssignProject({
projectId: assignForm.projectId,
monitorUserIds: [...assignForm.monitorUserIds]
})
ElMessage.success('分配成功')
}
assignVisible.value = false
load()
} finally {
assignSubmitting.value = false
}
}
function onAssignBatch() {
if (!selected.value.length) return ElMessage.warning('请先选择项目')
// 单选走 openAssign, 多选走批量分配
if (selected.value.length === 1) openAssign(selected.value[0])
else openAssignBatch(selected.value)
}
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 6px; padding: 16px 20px; }
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.link { color: var(--brand-primary); }
.link-bold { color: #303133; font-weight: 600; }
.mono { font-family: monospace; color: #606266; font-size: 12px; }
.pagination { margin-top: 12px; justify-content: flex-end; display: flex; }
.modal-project-info { background: #f5f7fa; padding: 12px 16px; border-radius: 4px; margin-bottom: 12px; }
.modal-project-info > div { display: flex; line-height: 22px; }
.modal-project-info .lbl { color: #909399; width: 80px; }
.modal-form .el-form-item { margin-bottom: 16px; }
.hint-inline { color: #909399; font-size: 12px; margin-top: 4px; }
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { cursor: pointer; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动 (6 个按钮铺不下, 用 overflow-x)
- filter-form: 保持 label 左 + input 右横向布局
- 表格字号收紧
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 (按钮多时不换行) */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: label 与输入框横向 (label 左, input 右) */
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽, 覆盖 inline 260px / 140px / 120px */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
}
</style>