Files
guoju0808/ry-vue3/src/views/manager/ManagerProjectsAssign.vue
T
郭庆泰andClaude 79d7849a27 feat(projects-assign): P1+P2+P3 修复 (含服务端超额校验)
P1:
- 截止天数 max=100 + min=1 (前端拦超界输入)
- 合同文件区实装 (support/execute_contract_url 走 Preview.vue, 单条模式显示)

P2:
- 可用金额公式实时显示 (总金额×(1-管理费/总金额)-累计劳务-累计会务)
- 服务端二次校验: BizProjectAssignServiceImpl.validateSum 防前端绕过

P3:
- 命名统一: 支持方→支持单位 / 执行方→执行单位
- 行内删除按钮: 仅剩1 行时禁用第一个
- loading 态: el-descriptions + 批量 info-bar 加 v-loading
- 批量模式加载失败提示: failed 数组 + ElMessageBox.alert

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 23:31:37 +08:00

539 lines
22 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">
首页 / 项目管理 / <span class="current">项目分配</span>
<span v-if="batchMode" class="mode-tag batch">批量模式</span>
</div>
<!-- 已选项目 (单条: 显示编号+名称 / 批量: 显示项目数) -->
<div v-if="batchMode" v-loading="batchLoading" class="info-bar">
已选 <b>{{ assignBatchProjects.length }}</b> 个项目 (本次分配将按各项目自身的总场次 / 总金额逐项校验)
</div>
<div v-else class="info-bar">
当前项目: <b>{{ currentProject?.projectNo || '-' }}</b> - {{ currentProject?.projectName || '-' }}
</div>
<!-- 项目核心属性 (单条模式: 只读展示, 字段未加载完时显示 -) -->
<el-descriptions v-if="!batchMode" v-loading="singleLoading" class="project-meta" :column="3" border size="small">
<el-descriptions-item label="项目编号">{{ currentProject?.projectNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称">{{ currentProject?.projectName || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ currentProject?.projectForm || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ currentProject?.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额(元)">{{ currentProject?.totalAmount || 0 }}</el-descriptions-item>
<el-descriptions-item label="管理费及税金">{{ currentProject?.manageFee || 0 }}</el-descriptions-item>
</el-descriptions>
<el-form :model="assignForm" label-width="120px">
<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>
<!-- 单选模式: 实时校验栏 -->
<div v-if="!batchMode" 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>
<span class="sep">|</span>
<span>可用金额: <b>¥ {{ availableAmount }}</b></span>
<span class="hint">(公式: 总金额×(1-管理费/总金额)-累计劳务-累计会务)</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" :disabled="$index === 0 && assignForm.execRows.length === 1" @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="1" :max="100" controls-position="right" />
<span class="hint-text" style="margin-left:12px">自然日, 1~100 天 (超出报错)</span>
</el-form-item>
</el-form>
<!-- 第 5 区块: 合同文件 (单条模式显示, 批量隐藏) -->
<div v-if="!batchMode && currentProject" class="contract-section">
<div class="new-card-title">合同文件</div>
<div class="notice-row">
<span class="notice-label">支持合同:</span>
<template v-if="currentProject.supportContractUrl">
<a class="file-link" @click.prevent="openPreview(currentProject.supportContractUrl, '支持合同')">合同文件</a>
</template>
<span v-else class="empty-tip">暂无</span>
</div>
<div class="notice-row">
<span class="notice-label">执行合同:</span>
<template v-if="currentProject.executeContractUrl">
<a class="file-link" @click.prevent="openPreview(currentProject.executeContractUrl, '执行合同')">合同文件</a>
</template>
<span v-else class="empty-tip">暂无</span>
</div>
<p class="hint-text">*根据项目编号,同步OA的合同文件, 可预览</p>
</div>
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<!-- 底部按钮 -->
<div class="form-actions">
<el-button @click="confirmCancel">取消</el-button>
<el-button type="primary" :loading="assignSubmitting" @click="submitAssign">保存</el-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizUpdate, bizGet } from '@/api/public'
import { listSponsorOrgs, listExecutorOrgs } from '@/api/system'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import Preview from '@/components/Preview.vue'
const router = useRouter()
const route = useRoute()
// 模式判断: 单条 ?projectId=xxx / 批量 ?projectIds=1,2,3
const batchMode = !!route.query.projectIds
const singleProjectId = route.query.projectId ? Number(route.query.projectId) : null
const batchProjectIds = batchMode ? String(route.query.projectIds).split(',').map(Number) : []
const currentProject = ref(null) // 单条模式下的项目 (用于顶部 info-bar)
const assignBatchProjects = ref([]) // 批量模式下的 [{projectId, projectNo, projectName, totalSessions, totalAmount}]
const assignForm = reactive({
sponsorAdminUserId: null,
sponsorAdminUserName: '',
totalSessions: 0, // 单条模式的项目总场次 (来自项目本身)
totalAmount: 0, // 单条模式的项目总金额
execRows: [],
deadlineDays: 30
})
const assignSubmitting = ref(false)
// 合同预览 (支持合同 / 执行合同) — 单条模式用
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
function openPreview(url, title) {
previewUrl.value = url
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
// 加载状态
const singleLoading = ref(false)
const batchLoading = ref(false)
const sponsorOrgOptions = ref([])
let _supporterTimer = null
// 已分配场次合计 (实时)
const assignedSessions = computed(() =>
assignForm.execRows.reduce((s, r) => s + Number(r.sessions || 0), 0)
)
const singleSessionOver = computed(() =>
!batchMode && 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(() =>
!batchMode && Number(assignForm.totalAmount || 0) > 0 && assignedAmount.value > Number(assignForm.totalAmount)
)
// 可用金额 (公式: 总金额 × (1 - 管理费/总金额) - 累计劳务 - 累计会务)
const availableAmount = computed(() => {
if (batchMode) return 0
const total = Number(assignForm.totalAmount || 0)
if (total <= 0) return 0
const manageFee = Number(currentProject.value?.manageFee || 0)
const paidLabor = Number(currentProject.value?.paidLaborAmount || 0)
const paidMeeting = Number(currentProject.value?.paidMeetingAmount || 0)
const avail = total * (1 - manageFee / total) - paidLabor - paidMeeting
return Math.round(avail * 100) / 100
})
function makeEmptyExecRow() {
return { _loading: false, _timer: null, _options: [], execUserId: null, sessions: 0, amount: 0, remark: '' }
}
function resetAssignForm() {
Object.assign(assignForm, {
sponsorAdminUserId: null,
sponsorAdminUserName: '',
totalSessions: 0,
totalAmount: 0,
execRows: [makeEmptyExecRow()],
deadlineDays: 30
})
sponsorOrgOptions.value = []
}
function addExecRow() {
const row = makeEmptyExecRow()
assignForm.execRows.push(row)
searchExecutors('', row)
}
// 加载支持方 (sponsor 角色) - 按公司名查, JOIN biz_org + sys_user
async function loadSponsorOrgs(query) {
if (_supporterTimer) clearTimeout(_supporterTimer)
_supporterTimer = setTimeout(async () => {
try {
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) {
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)
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 单独拉取并加到首位
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)
}
function onExecUserPick(row, userId) {
// 仅清掉旧的非持久化字段, 业务字段由前端从 _options 实时取
const u = (row._options || []).find(x => x.userId === userId)
if (u) { /* 兼容旧 _options 残留, 不写业务字段 */ }
}
// ========== 初始化 ==========
async function loadSingleProject() {
if (!singleProjectId) return
singleLoading.value = true
try {
const res = await bizGet('project', singleProjectId)
const p = res?.data?.data || res?.data || res
if (!p) return
currentProject.value = p
assignForm.sponsorAdminUserId = p.sponsorAdminUserId || null
assignForm.sponsorAdminUserName = p.sponsorAdminUserName || ''
assignForm.totalSessions = Number(p.totalSessions || 0)
assignForm.totalAmount = Number(p.totalAmount || 0)
// 拉已分配记录
await loadAssigns(singleProjectId)
// 拉支持方候选 (含已选项回拉)
loadSponsorOrgs('')
} catch (e) {
ElMessage.error('加载项目数据失败: ' + (e?.msg || e?.message || e))
} finally {
singleLoading.value = false
}
}
async function loadBatchProjects() {
batchLoading.value = true
if (!batchProjectIds.length) { batchLoading.value = false; return }
try {
const list = []
const failed = []
for (const pid of batchProjectIds) {
try {
const res = await bizGet('project', pid)
const p = res?.data?.data || res?.data || res
if (p) {
list.push({
projectId: p.projectId,
projectNo: p.projectNo,
projectName: p.projectName,
sponsorAdminUserId: p.sponsorAdminUserId,
sponsorAdminUserName: p.sponsorAdminUserName,
totalSessions: Number(p.totalSessions || 0),
totalAmount: Number(p.totalAmount || 0)
})
} else {
failed.push({ projectId: pid, reason: '返回数据为空' })
}
} catch (e) {
failed.push({ projectId: pid, reason: e?.msg || e?.message || String(e) })
}
}
assignBatchProjects.value = list
if (failed.length) {
ElMessageBox.alert(
`以下项目加载失败已跳过:\n${failed.map(f => `项目 ${f.projectId}: ${f.reason}`).join('\n')}`,
'加载提示',
{ type: 'warning' }
).catch(() => {})
}
// 批量模式: 取第一个项目的支持方作为默认 (用户可改)
if (list.length) {
assignForm.sponsorAdminUserId = list[0].sponsorAdminUserId || null
assignForm.sponsorAdminUserName = list[0].sponsorAdminUserName || ''
}
loadSponsorOrgs('')
// 批量模式: 行默认空 1 行 (各项目共用, 保存时按各项目循环)
assignForm.execRows = [makeEmptyExecRow()]
assignForm.execRows.forEach(r => searchExecutors('', r))
} catch (e) {
ElMessage.error('加载批量项目失败: ' + (e?.msg || e?.message || e))
} finally {
batchLoading.value = false
}
}
onMounted(() => {
resetAssignForm()
if (batchMode) loadBatchProjects()
else loadSingleProject()
})
// ========== 返回列表 ==========
function goBack() {
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
}
function confirmCancel() {
ElMessageBox.confirm('确定取消项目分配?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
// ========== 保存 ==========
async function submitAssign() {
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)
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额
if (batchMode) {
for (const p of assignBatchProjects.value) {
const capS = Number(p.totalSessions || 0)
const capA = Number(p.totalAmount || 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 (batchMode) {
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: singleProjectId,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
url: `/business/project/${singleProjectId}/assigns`,
method: 'post',
data: assignBody
})
ElMessage.success('分配成功')
}
goBack()
} catch (e) {
ElMessage.error(e?.msg || '分配失败')
}
finally { assignSubmitting.value = false }
}
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; }
.breadcrumb .mode-tag { margin-left: 8px; padding: 1px 8px; border-radius: 10px; font-size: 11px; }
.breadcrumb .mode-tag.batch { background: #fdf3e3; color: #b88230; }
.info-bar {
margin-bottom: 12px; padding: 8px 14px;
background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
font-size: 13px; color: #606266;
}
.info-bar b { color: #303133; margin: 0 2px; }
/* 项目核心属性只读区 */
.project-meta { margin-bottom: 16px; }
.project-meta :deep(.el-descriptions__label) { width: 110px; }
/* 分配 dialog 总场次条 (从 Projects.vue 1:1 搬) */
.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; }
.form-actions { display: flex; justify-content: center; gap: 16px; padding: 20px 0 4px; border-top: 1px solid #f0f0f0; margin-top: 16px; }
/* 章节标题 (复用 ProjectsNew 风格) */
.new-card-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin: 16px 0 12px;
padding-left: 8px;
border-left: 3px solid var(--brand-primary);
line-height: 1;
}
/* 合同行 (复用 ProjectsNew 风格) */
.contract-section { margin-top: 8px; }
.notice-row { display: flex; align-items: stretch; gap: 12px; margin-bottom: 12px; }
.notice-label {
width: 90px;
display: flex; align-items: center; justify-content: flex-end;
color: #606266; font-size: 14px;
}
.contract-section .file-link { color: #1890ff; cursor: pointer; font-size: 13px; display: flex; align-items: center; }
.contract-section .file-link:hover { text-decoration: underline; }
.contract-section .empty-tip { font-size: 13px; color: #c0c4cc; display: flex; align-items: center; }
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.hint-text p { margin-bottom: 4px; }
.hint-text p:last-child { margin-bottom: 0; }
</style>