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

435 lines
18 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 manager-projects-new">
<!-- 面包屑 -->
<div class="breadcrumb">
首页 / 项目管理 / <span class="current">{{ isEdit ? '编辑项目' : '新建项目' }}</span>
</div>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" class="project-form">
<!-- ========== 第一区块项目信息 ========== -->
<div class="new-card-title">项目信息</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="form.projectName" placeholder="请输入项目名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目编号" prop="projectNo">
<el-input v-model="form.projectNo" placeholder="请输入项目编号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目负责人">
<el-select v-model="form.leadUserId" placeholder="请选择合规管理员" filterable clearable :filter-method="searchManagers" style="width:100%">
<el-option v-for="u in managerOptions" :key="u.userId"
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`" :value="u.userId" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否招标项目">
<el-select v-model="form.isBidProject" placeholder="请选择" style="width:100%" clearable>
<el-option label="是" value="Y" />
<el-option label="否" value="N" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目形式" prop="projectForm">
<el-select v-model="form.projectForm" placeholder="请选择" style="width:100%">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总场次/总期数" prop="totalSessions">
<el-input-number v-model="form.totalSessions" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="总金额(元)" prop="totalAmount">
<el-input-number v-model="form.totalAmount" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="管理费(元)">
<el-input-number v-model="form.manageFee" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 第二区块角色劳务设置 ========== -->
<div class="new-card-title">角色劳务设置</div>
<div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
<el-select v-model="r.role" placeholder="请选择" style="width:160px" @change="onRoleSelectChange(r)">
<el-option label="主席" value="主席" />
<el-option label="主持" value="主持" />
<el-option label="讲者" value="讲者" />
<el-option label="点评/评审" value="点评/评审" />
<el-option label="讨论" value="讨论" />
<el-option label="其他" value="其他" />
</el-select>
<el-input v-if="r.role === '其他'" v-model="r.customName" placeholder="角色名" class="role-custom" />
<el-input-number v-model="r.amount" :min="0" :precision="2" placeholder="劳务金额" class="role-amount" />
<div class="row-actions">
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除角色</el-button>
</div>
</div>
<el-button link type="primary" @click="addRoleRow" style="margin-top: 8px;">+ 增加角色</el-button>
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<p class="hint-text">*支持 PDF / 图片, 单文件 10MB; 名称规则: 公告文件类型 + 项目编号后两段 + 项目名称</p>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
<oss-file-uploader
class="notice-uploader"
v-model="n.url"
:dir="`ry8080/project/${n.key}/`"
accept=".pdf,.png,.jpg,.jpeg,.gif,.webp"
:placeholder="`点击上传${n.label}`"
hint="支持 PDF / 图片"
block
/>
</div>
</div>
<!-- 底部按钮 (skill: el-form-item 包裹, 不另起 div.form-actions) -->
<el-form-item class="form-actions">
<el-button @click="confirmCancel">取消</el-button>
<el-button type="warning" :loading="submitting" @click="submit('publish')">保存并发布公告</el-button>
<el-button type="primary" :loading="submitting" @click="submit('save')">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { listExecutor, listManagers } from '@/api/system'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import OssFileUploader from '@/components/OssFileUploader.vue'
const router = useRouter()
const route = useRoute()
const isEdit = !!route.params.projectId
const projectId = route.params.projectId || null
const formRef = ref(null)
const submitting = ref(false)
const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 项目负责人候选 (合规管理员, sys_user.role_type='manager')
const managerOptions = ref([])
let _managerTimer = null
const form = reactive({
projectName: '', projectNo: '', projectForm: '',
// 项目负责人 (sys_user.role_type='manager' 合规管理员, 名称由后端 JOIN 查, 不缓存)
leadUserId: null,
// 是否招标项目 Y/N (默认 N)
isBidProject: 'N',
totalSessions: 0, totalAmount: 0, manageFee: 0,
startTime: '', endTime: '',
roleRows: [
{ role: '主席', customName: '', amount: 0 },
{ role: '主持', customName: '', amount: 0 },
{ role: '讲者', customName: '', amount: 0 },
{ role: '点评/评审', customName: '', amount: 0 },
{ role: '讨论', customName: '', amount: 0 }
],
// 公告附件 (key/label/url/name, 没有 url 即为空, 不参与提交)
notices: [
{ key: 'invitation', label: '邀请函', url: '', name: '' },
{ key: 'supportLetter', label: '支持函', url: '', name: '' },
{ key: 'notice', label: '通知', url: '', name: '' },
{ key: 'schedule', label: '日程', url: '', name: '' }
]
})
const rules = {
projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
projectNo: [{ required: true, message: '请输入项目编号', trigger: 'blur' }],
projectForm: [{ required: true, message: '请选择项目形式', trigger: 'change' }],
totalSessions: [{ required: true, message: '请输入总场次', trigger: 'blur' }],
totalAmount: [{ required: true, message: '请输入总金额', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
}
function onRoleSelectChange(r) {
if (r.role !== '其他') r.customName = ''
}
function addRoleRow() {
if (form.roleRows.length >= 20) { ElMessage.warning('最多 20 行角色'); return }
form.roleRows.push(defaultRoleRow())
}
function removeRoleRowAt(idx) {
if (form.roleRows.length <= 1) {
ElMessage.warning('至少保留 1 行角色')
return
}
form.roleRows.splice(idx, 1)
}
function syncProjectData() {
ElMessage.info('同步数据:从外部数据源拉取最新项目信息 (后端支持时启用)')
}
// 编辑模式:加载已有项目数据并反显到表单
async function loadProject() {
if (!isEdit || !projectId) return
try {
const res = await bizGet('project', projectId)
// res 结构: {code:200, msg, data: BizProject} (单条 success 包了一层)
const p = res?.data?.data || res?.data || res
if (!p) return
form.projectName = p.projectName || ''
form.projectNo = p.projectNo || ''
form.projectForm = p.projectForm || ''
form.totalSessions = p.totalSessions || 0
form.totalAmount = p.totalAmount || 0
form.manageFee = p.manageFee || 0
form.startTime = p.startTime || ''
form.endTime = p.endTime || ''
form.leadUserId = p.leadUserId || null
form.isBidProject = p.isBidProject || 'N'
// 角色劳务反显: biz_project.role_labor JSON 数组 [{role, customName, amount}]
// 空数组/解析失败 → 保留 form 默认 5 行 (主席/主持/讲者/点评/评审/讨论)
try {
const rl = (typeof p.roleLabor === 'string') ? JSON.parse(p.roleLabor || '[]') : p.roleLabor
if (Array.isArray(rl) && rl.length) form.roleRows = rl
} catch (e) { /* keep default */ }
// 公告文件反显: 优先 publicityFiles JSON 数组, 否则兼容旧字段
const files = []
if (Array.isArray(p.publicityFiles)) files.push(...p.publicityFiles)
else if (typeof p.publicityFiles === 'string' && p.publicityFiles) {
try { files.push(...JSON.parse(p.publicityFiles)) } catch (e) { /* ignore */ }
}
if (files.length) {
// 按 type 反显到对应 notice 行
for (const n of form.notices) {
const m = files.find(f => f.type === n.key)
if (m) { n.url = m.url || ''; n.name = m.name || '' }
}
} else {
// 兼容老数据 (独立列: invitationUrl/supportLetterUrl/publishUrl/scheduleUrl)
const noticeMap = {
invitation: p.invitationUrl,
supportLetter: p.supportLetterUrl,
notice: p.publishUrl,
schedule: p.scheduleUrl
}
for (const n of form.notices) {
n.url = noticeMap[n.key] || ''
n.name = n.url ? (n.url.split('/').pop() || '') : ''
}
}
// 加载已有执行方分配 (暂不实现, 留 hook)
// await loadAssigns(projectId)
} catch (e) {
ElMessage.error('加载项目数据失败: ' + (e?.msg || e?.message || e))
}
}
// 加载项目负责人 (合规管理员, sys_user.role_type='manager')
async function loadManagers(query) {
if (_managerTimer) clearTimeout(_managerTimer)
_managerTimer = setTimeout(async () => {
try {
const params = { pageNum: 1, pageSize: 50 }
if (query) params.userName = query
const r = await listManagers(params)
managerOptions.value = (r.data && r.data.rows) || r.rows || []
// 已选项不在结果里时按 userId 单独拉取并加首位, 避免 el-select 选中后 label 空白
if (form.leadUserId && !managerOptions.value.find(u => u.userId === form.leadUserId)) {
const r2 = await listManagers({ userId: form.leadUserId })
const sel = (r2.data && r2.data.rows) || r2.rows || []
if (sel[0]) managerOptions.value = [sel[0], ...managerOptions.value]
}
} catch (e) { managerOptions.value = [] }
}, 200)
}
function searchManagers(q) { loadManagers(q) }
onMounted(() => {
loadProject()
loadManagers('')
})
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
}
function confirmCancel() {
ElMessageBox.confirm(`确定取消${isEdit ? '编辑' : '新建'}项目?未保存的内容将丢失`, '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
async function submit(mode = 'save') {
await formRef.value.validate()
const uploaded = form.notices.filter(n => n.url)
const payload = {
projectName: form.projectName,
projectNo: form.projectNo,
projectForm: form.projectForm,
totalSessions: form.totalSessions,
totalAmount: form.totalAmount,
manageFee: form.manageFee,
startTime: form.startTime,
endTime: form.endTime,
leadUserId: form.leadUserId,
isBidProject: form.isBidProject
}
// 角色劳务: 过滤空 role, 序列化为 JSON 字符串存到 biz_project.role_labor (TEXT 列)
// "其他" 角色把 customName 作为 role 显示名 (与 ManagerProjectDetail 渲染一致)
const roleRowsPayload = form.roleRows
.filter(r => r.role && String(r.role).trim() !== '')
.map(r => ({
role: r.role,
customName: r.role === '其他' ? (r.customName || '') : '',
amount: Number(r.amount) || 0
}))
payload.roleLabor = JSON.stringify(roleRowsPayload)
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/publishUrl 独立列)
payload.publicityFiles = JSON.stringify(uploaded.map(n => ({
type: n.key,
label: n.label,
url: n.url,
name: n.name || (n.url ? n.url.split('/').pop() || '' : '')
})))
// 兼容旧字段: 同步写一份 (保持后端字段兼容, 新逻辑以 publicityFiles 为准)
for (const n of uploaded) {
if (n.key === 'invitation') payload.invitationUrl = n.url
else if (n.key === 'supportLetter') payload.supportLetterUrl = n.url
else if (n.key === 'notice') payload.publishUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url
}
// 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
// announcement_type 取第一个已上传的文件类型
const firstUploaded = uploaded[0]
if (firstUploaded) {
payload.announcementType = firstUploaded.key
}
}
// 多执行方分配已移至"项目分配"页面单独管理
try {
submitting.value = true
let savedProjectId = projectId
if (isEdit) {
payload.projectId = projectId
await bizUpdate('project', payload)
} else {
const r = await bizAdd('project', payload)
// bizAdd 返回 { code, msg, data: { projectId: '...' } } 或 直接 data
savedProjectId = r?.data?.projectId || r?.projectId || r?.data
}
const successText = isEdit
? (mode === 'publish' ? '更新并发布公告成功' : '更新成功')
: (mode === 'publish' ? '保存并发布公告成功' : '保存成功')
ElMessage.success(successText)
goBack()
} catch (e) {
ElMessage.error(e?.msg || (isEdit ? '更新失败' : '新建失败'))
} finally {
submitting.value = false
}
}
</script>
<style scoped>
/* 整页面板 (与 ManagerProjectDetail.vue 风格一致: 新建/编辑页 max-width 1200px, 居左, 不居中) */
.manager-projects-new {
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; }
/* 表单 (整页一个 form) */
.project-form { max-width: 100%; }
/* 章节标题 (替换原 .new-card 容器, 用分隔线分割 3 块) */
.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;
}
.new-card-title:first-child { margin-top: 0; }
.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; }
.role-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
.role-custom { flex: 1; max-width: 180px; }
.role-amount { width: 180px; }
.row-actions { display: flex; gap: 8px; }
.notice-list { display: flex; flex-direction: column; gap: 10px; }
.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;
}
.notice-uploader { flex: 1; }
/* 紧凑版 (覆盖 OssFileUploader 默认尺寸) */
.notice-uploader :deep(.ht-file-upload) { padding: 6px 12px; min-height: 40px; gap: 8px; }
.notice-uploader :deep(.placeholder-text) { font-size: 13px; }
.notice-uploader :deep(.placeholder-hint) { font-size: 11px; }
.notice-uploader :deep(.file-name) { font-size: 13px; }
.notice-uploader :deep(.file-type) { font-size: 11px; }
.notice-uploader :deep(.file-icon) { font-size: 18px; }
.notice-uploader :deep(.file-icon svg) { width: 18px; height: 18px; }
/* 底部按钮 (skill: 用 el-form-item 包裹, 不用 div.form-actions) */
.form-actions {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
/* 按钮所在 form-item 不显示 label 列 (skill form-actions 包裹的标准做法) */
.form-actions :deep(.el-form-item__label) {
display: none;
}
.form-actions :deep(.el-form-item__content) {
margin-left: 0 !important;
display: flex;
gap: 12px;
}
</style>