后端 (ruoyi-business) - BizProject: +is_bid_project, +execOrgNames(GROUP_CONCAT派生), +leadUserName - BizProjectMapper.xml: 上述字段全部贯通 selectFields/insert/update, 列表加 exec_org_names 子查询 + lead_user_name JOIN - BizSysUserQueryMapper (新): 干净查 sys_user by role_type, 避开 @DataScope 切面污染, 用于项目负责人下拉 - BizOrgMapper.selectExecutorOrgOptions (新): JOIN sys_user (parent_user_id IS NULL) 限定 MAIN 账号, 排除同公司普通员工子账号 - BizOrgController: +/business/org/executorOptions 端点 - BizExecutionIntentController: +exportSignupExperts 端点 (POST /business/executionIntent/export) - BizSignupExpertExportVo (新): 中文列头 Excel 导出 VO (专家姓名/科室/医院/职称/报名时间/手机号) - BizProjectAssign / SponsorAssign 重命名 + Service 实现调整 - 删除 sql/ 下所有迁移脚本 (按用户要求不再保存 SQL 文件) 前端 (ry-vue3) - api/system.js: +listExecutorOrgs 走新接口, listExecutor/listSupporters/listManagers 保持 listByRole 兼容 - views/manager/Projects.vue: 执行方下拉改 label=u.orgName / value=u.userId, 数据源走 listExecutorOrgs +execOrgNames 列展示 +导出报名专家 dropdown 项 +is_bid_project 表单字段 - views/manager/ProjectsNew.vue + ManagerProjectDetail.vue: +is_bid_project 表单/展示 - views/auth/Login.vue: +手机号验证码登录 tab (复用 SysSmsService, dev 模式 10 开头手机号固定码 1234) - 路由/布局/角色视角多页面整理 (SponsorOrgs/People/SponsorPersonNew 等) Co-Authored-By: Claude <noreply@anthropic.com>
408 lines
17 KiB
Vue
408 lines
17 KiB
Vue
<template>
|
||
<div class="page-card">
|
||
<!-- 面包屑 -->
|
||
<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 class="hint-text">
|
||
<p>*角色劳务设置针对该项目所有会议生效</p>
|
||
<p>*专家劳务明细当会议劳务金额超过角色劳务设置时,该劳务金额为税后金额,则系统进行提示</p>
|
||
</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="primary" @click="addRoleRow">增加角色</el-button>
|
||
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除角色</el-button>
|
||
</div>
|
||
</div>
|
||
<p class="hint-text">*当选择其他时,需手动填写角色名称,显示填写框</p>
|
||
|
||
<!-- ========== 第三区块:公告文件 ========== -->
|
||
<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>
|
||
</el-form>
|
||
|
||
<!-- 底部按钮 -->
|
||
<div 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>
|
||
</div>
|
||
</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'
|
||
// 公告文件反显: 优先 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 {
|
||
// 兼容老数据
|
||
const noticeMap = {
|
||
invitation: p.invitationUrl,
|
||
supportLetter: p.supportLetterUrl,
|
||
notice: p.noticeUrl,
|
||
schedule: p.scheduleUrl,
|
||
publish: p.publishUrl
|
||
}
|
||
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
|
||
}
|
||
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl)
|
||
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.noticeUrl = 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 roleText = form.roleRows.filter(r => r.role).map(r => `${r.role === '其他' ? (r.customName || '其他') : r.role}=${r.amount}`).join(', ')
|
||
if (roleText) console.info('[new-project.roleLabor]', form.projectNo, roleText)
|
||
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>
|
||
/* 整页面板 (与 People.vue 风格一致, 1 层) */
|
||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||
.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; }
|
||
|
||
/* 底部按钮 */
|
||
.form-actions { display: flex; justify-content: center; gap: 16px; padding: 20px 0 4px; border-top: 1px solid #f0f0f0; margin-top: 8px; }
|
||
</style> |