diff --git a/ry-vue3/src/components/ProjectRoleSelect.vue b/ry-vue3/src/components/ProjectRoleSelect.vue new file mode 100644 index 0000000..4cdc7a7 --- /dev/null +++ b/ry-vue3/src/components/ProjectRoleSelect.vue @@ -0,0 +1,153 @@ + + + + + \ No newline at end of file diff --git a/ry-vue3/src/layout/AdminLayout.vue b/ry-vue3/src/layout/AdminLayout.vue index 3960952..01c1393 100644 --- a/ry-vue3/src/layout/AdminLayout.vue +++ b/ry-vue3/src/layout/AdminLayout.vue @@ -163,6 +163,7 @@ const MENU = { { path: '/admin/users', title: '用户管理', icon: User }, { path: '/admin/roles', title: '角色管理', icon: Setting }, { path: '/admin/projects', title: '项目管理', icon: Document }, + { path: '/admin/meetings', title: '会议管理', icon: Calendar }, { path: '/admin/library', title: '资料库管理', icon: Box, children: [ { path: '/admin/experts', title: '专家管理', icon: UserFilled }, { path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Star }, diff --git a/ry-vue3/src/router/index.js b/ry-vue3/src/router/index.js index b09a8e2..57fe73c 100644 --- a/ry-vue3/src/router/index.js +++ b/ry-vue3/src/router/index.js @@ -26,6 +26,8 @@ const routes = [ { path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue'), meta: { title: '用户管理' } }, { path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } }, { path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } }, + { path: 'meetings', name: 'admin-meetings', component: () => import('@/views/admin/AdminMeetings.vue'), meta: { title: '会议管理' } }, + { path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/manager/MeetingDetail.vue'), meta: { title: '会议详情' } }, { path: 'department', name: 'admin-department', component: () => import('@/views/admin/Department.vue'), meta: { title: '科室管理' } }, { path: 'title', name: 'admin-title', component: () => import('@/views/admin/Title.vue'), meta: { title: '职称管理' } }, { path: 'experts', name: 'admin-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家管理' } }, diff --git a/ry-vue3/src/store/user.js b/ry-vue3/src/store/user.js index 9f193a7..ed478c5 100644 --- a/ry-vue3/src/store/user.js +++ b/ry-vue3/src/store/user.js @@ -18,7 +18,8 @@ export const useUserStore = defineStore('user', { accountType: (s) => s.user?.accountType || 'MAIN', parentUserId: (s) => s.user?.parentUserId || null, isMain: (s) => (s.user?.accountType || 'MAIN') === 'MAIN', - isSub: (s) => s.user?.accountType === 'SUB' + isSub: (s) => s.user?.accountType === 'SUB', + isAdmin: (s) => s.user?.role === 'admin' }, actions: { setLogin(token, user) { diff --git a/ry-vue3/src/views/admin/AdminMeetings.vue b/ry-vue3/src/views/admin/AdminMeetings.vue new file mode 100644 index 0000000..8aae464 --- /dev/null +++ b/ry-vue3/src/views/admin/AdminMeetings.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/ry-vue3/src/views/manager/ManagerProjectDetail.vue b/ry-vue3/src/views/manager/ManagerProjectDetail.vue index 5a103c2..afc66b2 100644 --- a/ry-vue3/src/views/manager/ManagerProjectDetail.vue +++ b/ry-vue3/src/views/manager/ManagerProjectDetail.vue @@ -100,12 +100,9 @@ const projectId = ref('') const row = ref({}) const loading = ref(false) -// 角色劳务 (从 biz_project.role_labor JSON 字段读) +// 角色劳务 (从 biz_project.role_labor JSON 字段读; 空数组 → 模板 "暂无角色" 行兜底) const form = reactive({ - roleRows: [ - { role: '主席', customName: '', amount: 5000 }, - { role: '主持', customName: '', amount: 3000 } - ] + roleRows: [] }) // 监督员 (从 biz_project.monitors JSON 字段读) const monitors = ref([]) @@ -147,11 +144,11 @@ async function loadProject() { const resp = await bizGet('project', projectId.value) const data = (resp && resp.data) || resp || {} row.value = data || {} - // 角色劳务 (JSON 字段, 缺则用默认) + // 角色劳务 (JSON 字段, 缺/空 → form.roleRows 保持空数组, 模板"暂无角色"兜底) try { const rl = JSON.parse(row.value.roleLabor || '[]') if (Array.isArray(rl) && rl.length) form.roleRows = rl - } catch (e) { /* keep default */ } + } catch (e) { /* keep empty */ } // 监督员 (JSON 字段) try { const mn = JSON.parse(row.value.monitors || '[]') diff --git a/ry-vue3/src/views/manager/MeetingDetail.vue b/ry-vue3/src/views/manager/MeetingDetail.vue index dce58d3..2213182 100644 --- a/ry-vue3/src/views/manager/MeetingDetail.vue +++ b/ry-vue3/src/views/manager/MeetingDetail.vue @@ -6,7 +6,11 @@ -
会议详情
+
+ 会议详情 + + 删除会议 +
@@ -365,7 +369,7 @@ - + @@ -440,6 +444,7 @@ import OssFileUploader from '@/components/OssFileUploader.vue' import ImportResultDialog from '@/components/ImportResultDialog.vue' import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue' import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue' +import ProjectRoleSelect from '@/components/ProjectRoleSelect.vue' import { ElMessageBox } from 'element-plus' import { Upload } from '@element-plus/icons-vue' @@ -461,6 +466,10 @@ const busy = ref({ submitMaterial: false, submitVoucher: false }) const currentUserId = computed(() => userStore.user?.userId) const currentRole = computed(() => userStore.role) const isManager = computed(() => currentRole.value === 'manager') +const isAdmin = computed(() => currentRole.value === 'admin') +/** 软删除会议: admin / manager 可调 (后端 BizMeetingController.DELETE 强校验) */ +const canDeleteMeeting = computed(() => isAdmin.value || isManager.value) +const deleting = ref(false) // ===================== 工具 ===================== function pad(n) { return String(n).padStart(2, '0') } @@ -612,6 +621,9 @@ async function loadMaterials() { const attendeeRows = ref([]) const attendeeLoading = ref(false) +/** 项目级角色 (来自 biz_project.role_labor JSON 解析), 用于参会人 dialog 角色下拉 */ +const projectRoles = ref([]) + /** 参会人 dialog 状态 (新增/编辑共用) */ const attendeeDialog = ref({ show: false, @@ -832,13 +844,37 @@ async function load() { try { const resp = await bizGet('meeting', meetingId.value) row.value = (resp && (resp.data || resp)) || {} - await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees()]) + await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()]) } catch (e) { console.error('[meeting-detail] load failed', e) row.value = {} } finally { loading.value = false } } +/** + * 加载项目级角色 (biz_project.role_labor JSON) + * 失败/为空 → projectRoles 留空, ProjectRoleSelect 会显示空下拉 + 虚拟"其他"逃生口 + */ +async function loadProjectRoles() { + const pid = row.value.projectId + if (!pid) { projectRoles.value = []; return } + try { + const resp = await bizGet('project', pid) + const p = (resp && (resp.data?.data || resp.data)) || resp + const rl = p && p.roleLabor + if (typeof rl === 'string') { + try { projectRoles.value = JSON.parse(rl) || [] } catch (e) { projectRoles.value = [] } + } else if (Array.isArray(rl)) { + projectRoles.value = rl + } else { + projectRoles.value = [] + } + } catch (e) { + console.warn('[meeting-detail] loadProjectRoles failed', e) + projectRoles.value = [] + } +} + // ===================== 保存 (材料) ===================== async function onSave() { if (saving.value) return @@ -1062,8 +1098,36 @@ async function confirmAudit() { function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') } function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') } +// ===================== admin / manager 删除会议 (软删) ===================== +/** + * 后端 BizMeetingController.DELETE 强校验 role in (admin, manager). + * 详情页删除后跳回当前角色对应的会议列表 (避免停在已删会议的空页). + */ +async function onAdminDelete() { + try { + await ElMessageBox.confirm( + `确定要删除会议「${row.value.meetingName || ''}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`, + '删除确认', { type: 'warning' } + ) + } catch { return } + deleting.value = true + try { + await request.delete(`/business/meeting/${meetingId.value}`) + ElMessage.success('删除成功') + router.push(isAdmin.value ? '/admin/meetings' : '/manager/meetings') + } catch (e) { + ElMessage.error(e?.msg || '删除失败') + } finally { + deleting.value = false + } +} + // ===================== 导航 ===================== -function goBack() { router.push('/manager/meetings') } +function goBack() { + // 角色感知返回: admin 跳 admin 列表, manager 跳 manager 列表 + const target = isAdmin.value ? '/admin/meetings' : '/manager/meetings' + router.push(target) +} // ===================== 初始化 ===================== onMounted(load) @@ -1073,7 +1137,8 @@ onMounted(load) .page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; width: 100%; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb .current { color: #262626; font-weight: 500; } -.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; } +.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; } +.admin-delete-btn { font-size: 13px; } .card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; } .section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); } .text-muted { color: #8c8c8c; font-size: 13px; } diff --git a/ry-vue3/src/views/manager/Meetings.vue b/ry-vue3/src/views/manager/Meetings.vue index 1f83425..3e7c411 100644 --- a/ry-vue3/src/views/manager/Meetings.vue +++ b/ry-vue3/src/views/manager/Meetings.vue @@ -39,6 +39,7 @@
批量提交 + 批量删除 已选 {{ selectedIds.length }} 条
@@ -65,12 +66,13 @@ - + @@ -94,7 +96,7 @@ diff --git a/ry-vue3/src/views/manager/Projects.vue b/ry-vue3/src/views/manager/Projects.vue index 2777a5a..8aa8064 100644 --- a/ry-vue3/src/views/manager/Projects.vue +++ b/ry-vue3/src/views/manager/Projects.vue @@ -78,6 +78,9 @@ 批量开通{{ selection.length }} + + 批量删除{{ selection.length }} +
@@ -131,6 +134,7 @@ 导出报名专家 开通 删除公告 + 删除项目 @@ -269,7 +273,13 @@ function onAction(cmd, row) { else if (cmd === 'exportExperts') exportExperts(row) else if (cmd === 'activate') openActivate(row) else if (cmd === 'delAnn') openDeleteAnnouncement(row) + else if (cmd === 'delete') doDelete(row) } +// 仅 admin / manager 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验) +const canDelete = computed(() => { + const r = userStore.role + return r === 'admin' || r === 'manager' +}) function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) } function doAssign(row) { // 跳转独立子页面 /manager/projects/assign?projectId=X @@ -277,18 +287,54 @@ function doAssign(row) { } function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) } -// 删除项目(按需补充,原型无但需要) +// 删除项目 (软删除 + 级联置 5 张项目表 + 项目下所有会议 is_deleted=1) +// 后端 BizProjectController.remove 已强制 role in (admin, manager), 前端只做可见性 v-if async function doDelete(row) { try { - await ElMessageBox.confirm(`确认删除项目"${row.projectName}"?\n项目编号: ${row.projectNo}\n\n删除后无法恢复!`, '删除确认', { type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' }) + 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 = { sponsorAdminUserIds:[], execAdminUserIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', managerScore:'' }; page.pageNum = 1; load() } // 导出 diff --git a/ry-vue3/src/views/manager/ProjectsNew.vue b/ry-vue3/src/views/manager/ProjectsNew.vue index b78d235..ac6271f 100644 --- a/ry-vue3/src/views/manager/ProjectsNew.vue +++ b/ry-vue3/src/views/manager/ProjectsNew.vue @@ -1,5 +1,5 @@ @@ -226,6 +226,12 @@ async function loadProject() { 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) @@ -305,6 +311,16 @@ async function submit(mode = 'save') { 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, @@ -339,8 +355,6 @@ async function submit(mode = 'save') { // 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' ? '保存并发布公告成功' : '保存成功') @@ -355,8 +369,14 @@ async function submit(mode = 'save') { \ No newline at end of file