feat(front): 项目/会议管理 admin 端补齐 + 删除按钮 + 级联 UI + 角色劳务组件
admin 会议管理: - 新建 admin/AdminMeetings.vue (查看/删除/批量删除, 面包屑 首页/项目管理/会议管理) - AdminLayout.vue 加 /admin/meetings 菜单 - router/index.js 加 admin-meetings + admin-meetings-detail 路由 - MeetingDetail.vue admin 删除按钮改 admin+manager 双角色可触发 (onAdminDelete 跳回按角色) manager 会议管理: - Meetings.vue 加批量删除 + 删除按钮 + 删除确认文案 (6 张表级联) - MeetingDetail.vue 删除按钮 v-if 改 canDeleteMeeting = isAdmin || isManager manager 项目管理: - Projects.vue 加批量删除 + 删除项 + doDelete 改走 cascade (5 张项目表 + 会议链) - ProjectsNew.vue style-alignment: page-card 居左 (max-width 1200px, 无 margin auto), form-actions 包在 el-form 内 - Projects.vue + Meetings.vue + AdminMeetings.vue 删页内冗余删除提示 store + 组件: - store/user.js 加 isAdmin getter - components/ProjectRoleSelect.vue 新建 (项目级角色劳务下拉, 接 roleLabor)
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<!--
|
||||
项目级角色下拉控件 (带"其他"逃生口)
|
||||
|
||||
数据来源: 父组件传入 props.roles (来自 biz_project.role_labor JSON, 形如 [{role, customName, amount}])
|
||||
- role === '其他' → 显示 customName 作为 label
|
||||
- role !== '其他' → 显示 role 作为 label
|
||||
|
||||
行为契约 (前端控件自管理, 父组件无感知):
|
||||
- 标准项目角色: 用户从下拉选 → v-model = 该角色名
|
||||
- 选 "其他" (控件自己虚拟追加, 不来自项目定义): 下方出现 el-input → v-model = input 内容
|
||||
- 回填: modelValue 不在项目角色列表里 → 自动按 "其他 + input" 显示, input 预填原值
|
||||
|
||||
用法:
|
||||
<project-role-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
|
||||
-->
|
||||
<div class="project-role-select">
|
||||
<el-select
|
||||
v-model="selectedKey"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
style="width: 100%"
|
||||
@change="handleSelectChange"
|
||||
@clear="handleClear"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in displayOptions"
|
||||
:key="o.key"
|
||||
:label="o.label"
|
||||
:value="o.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="showOtherInput"
|
||||
v-model="otherText"
|
||||
:placeholder="otherPlaceholder"
|
||||
class="other-input"
|
||||
maxlength="50"
|
||||
@input="emitValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
|
||||
roles: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '请选择角色' },
|
||||
otherPlaceholder: { type: String, default: '请输入角色名' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
|
||||
const OTHER_KEY = '__other_role__'
|
||||
|
||||
// 把 props.roles 投影成 {key, label} 数组
|
||||
// - role === '其他' → label = customName (项目里实际填的"自定义角色名")
|
||||
// - role !== '其他' → label = role
|
||||
const projectOptions = computed(() => {
|
||||
return (props.roles || [])
|
||||
.filter(r => r && (r.role || r.customName))
|
||||
.map(r => {
|
||||
if (r.role === '其他') {
|
||||
const customName = (r.customName || '').trim()
|
||||
return { key: customName, label: customName || '其他' }
|
||||
}
|
||||
return { key: r.role, label: r.role }
|
||||
})
|
||||
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
|
||||
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
|
||||
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
|
||||
.filter(o => o.key !== OTHER_KEY)
|
||||
})
|
||||
|
||||
const selectedKey = ref(props.modelValue || null)
|
||||
const otherText = ref(props.modelValue || '')
|
||||
|
||||
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
|
||||
|
||||
// 给 el-select 渲染的 options = 项目角色 + 末尾虚拟"其他"
|
||||
const displayOptions = computed(() => [
|
||||
...projectOptions.value,
|
||||
{ key: OTHER_KEY, label: '其他' }
|
||||
])
|
||||
|
||||
function isValueInOptions(v) {
|
||||
if (v == null || v === '') return false
|
||||
return projectOptions.value.some(o => o.key === v)
|
||||
}
|
||||
|
||||
function syncFromModelValue(v) {
|
||||
if (v == null || v === '') {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
return
|
||||
}
|
||||
if (isValueInOptions(v)) {
|
||||
selectedKey.value = v
|
||||
otherText.value = ''
|
||||
} else {
|
||||
// 不在项目角色列表里 → 走 "其他" 路径
|
||||
selectedKey.value = OTHER_KEY
|
||||
otherText.value = v
|
||||
}
|
||||
}
|
||||
|
||||
function emitValue() {
|
||||
let v
|
||||
if (selectedKey.value === OTHER_KEY) {
|
||||
v = otherText.value || ''
|
||||
} else if (selectedKey.value == null) {
|
||||
v = ''
|
||||
} else {
|
||||
v = selectedKey.value
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
|
||||
function handleSelectChange(key) {
|
||||
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
|
||||
otherText.value = ''
|
||||
}
|
||||
selectedKey.value = key
|
||||
emitValue()
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
emitValue()
|
||||
}
|
||||
|
||||
// 初始化 + 响应外部变化
|
||||
syncFromModelValue(props.modelValue)
|
||||
watch(() => props.modelValue, v => syncFromModelValue(v))
|
||||
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
|
||||
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-role-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.other-input { width: 100%; }
|
||||
</style>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: '专家管理' } },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="page-card admin-meetings">
|
||||
<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:140px" /></el-form-item>
|
||||
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
|
||||
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
|
||||
<el-form-item label="期数"><el-input v-model="q.periodNo" placeholder="请输入期数" clearable style="width:140px" /></el-form-item>
|
||||
<el-form-item label="会议时间">
|
||||
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
|
||||
<span class="date-sep">至</span>
|
||||
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:170px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="已完结" value="已完结" />
|
||||
</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>
|
||||
|
||||
<!-- ========== 批量操作 (admin 专有: 批量软删) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
|
||||
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
|
||||
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="会议开始时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会议结束时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', 'stage-' + stageClass(row.currentStage)]">{{ row.currentStage }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList, bizDelete } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ========== 筛选 ==========
|
||||
const q = ref({
|
||||
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
|
||||
projectForm: '', currentStage: '',
|
||||
startTime: '', endTime: ''
|
||||
})
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// ========== 表格多选 ==========
|
||||
const selectedIds = ref([])
|
||||
function onSelectionChange(sel) {
|
||||
selectedIds.value = sel.map(r => r.meetingId)
|
||||
}
|
||||
|
||||
// ========== 工具: 时间格式 ==========
|
||||
function fmtTime(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
// ========== 工具: 当前阶段颜色 class ==========
|
||||
function stageClass(s) {
|
||||
if (s === '未执行') return 'pending'
|
||||
if (s === '执行中') return 'running'
|
||||
if (s === '已完结' || s === '已执行') return 'done'
|
||||
if (s === '待审核') return 'reviewing'
|
||||
if (s === '待结算') return 'waiting'
|
||||
if (s === '冻结中') return 'frozen'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// ========== 加载 ==========
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('meeting', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
q.value = {
|
||||
projectNo:'', meetingId:'', meetingName:'', periodNo:'',
|
||||
projectForm:'', currentStage:'',
|
||||
startTime:'', endTime:''
|
||||
}
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ========== 路由跳转 (查看走 admin 详情页) ==========
|
||||
const router = useRouter()
|
||||
function viewDetail(row) {
|
||||
router.push(`/admin/meetings/detail/${row.meetingId}`)
|
||||
}
|
||||
|
||||
// ========== 删除 (软删除) ==========
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除会议「${row.meetingName}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`,
|
||||
'删除确认', { type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await bizDelete('meeting', [row.meetingId])
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
|
||||
}
|
||||
|
||||
// ========== 批量删除 ==========
|
||||
async function onBatchDelete() {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要对选中的 ${selectedIds.value.length} 个会议执行批量删除吗?\n删除为软删除, 数据保留审计追溯.`,
|
||||
'批量删除确认', { type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await bizDelete('meeting', selectedIds.value, { __silentError: true })
|
||||
ElMessage.success(`批量删除成功 (${selectedIds.value.length} 条)`)
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-meetings { padding: 16px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
.batch-bar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.filter-tip { color: #909399; font-size: 12px; }
|
||||
.date-sep { margin: 0 4px; color: #606266; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
|
||||
/* 当前阶段 tag 颜色 */
|
||||
.stage-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.stage-pending { color: #909399; border-color: #dcdfe6; background: #f5f7fa; }
|
||||
.stage-running { color: var(--brand-primary); border-color: #b3c5e0; background: #eef1f8; }
|
||||
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
|
||||
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
|
||||
.stage-waiting { color: #ff4d4f; border-color: #fbc4c4; background: #fef0f0; }
|
||||
.stage-frozen { color: #909399; border-color: #dcdfe6; background: #f5f7fa; opacity: 0.6; }
|
||||
.stage-default { color: #606266; border-color: #dcdfe6; background: #fafafa; }
|
||||
</style>
|
||||
@@ -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 || '[]')
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 页标题 -->
|
||||
<div class="page-title">会议详情</div>
|
||||
<div class="page-title">
|
||||
<span>会议详情</span>
|
||||
<!-- admin / manager 角色专用: 删除会议 (软删, 后端强校验) -->
|
||||
<el-button v-if="canDeleteMeeting" type="danger" plain size="small" class="admin-delete-btn" :loading="deleting" @click="onAdminDelete">删除会议</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cols-row">
|
||||
<!-- 左列 -->
|
||||
@@ -365,7 +369,7 @@
|
||||
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-input v-model="attendeeDialog.form.laborForm" placeholder="如: 授课/主持/评审" />
|
||||
<project-role-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" placeholder="请选择角色 (来自项目设置)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="应发金额">
|
||||
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
|
||||
@@ -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; }
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<!-- ========== 批量提交按钮 (manager 角色专有, 不用 plain 否则 disabled 顺色) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchSubmit">批量提交</el-button>
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
|
||||
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -65,12 +66,13 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onSubmit(row)">提交</el-button>
|
||||
<el-button link type="primary" @click="onCopy(row)">复制</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -94,7 +96,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { bizList, bizUpdate, bizDelete } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ========== 筛选 (按实际 8 项: 项目编号/会议ID/会议名称/第?期/会议时间/项目形式/当前阶段/备注) ==========
|
||||
@@ -190,6 +192,45 @@ async function onBatchSubmit() {
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量提交失败') }
|
||||
}
|
||||
|
||||
// ========== 删除会议 (软删除, 后端强校验 admin/manager) ==========
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除会议「${row.meetingName}」?\n` +
|
||||
`会议ID: ${row.meetingId}\n项目编号: ${row.projectNo || '-'}\n\n` +
|
||||
`此操作将级联软删除:\n` +
|
||||
`· biz_meeting 主表 + 附件 + 参会人 + 监督员 + 执行人 + 审计日志 (6 张表)\n\n` +
|
||||
`数据保留在库, 不再展示给前台用户, 审计追溯可查。`,
|
||||
'删除确认',
|
||||
{ type: 'error', confirmButtonText: '确认软删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await bizDelete('meeting', [row.meetingId])
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
|
||||
}
|
||||
|
||||
// ========== 批量删除会议 ==========
|
||||
async function onBatchDelete() {
|
||||
if (!selectedIds.value.length) return ElMessage.warning('请先勾选会议')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认批量软删除选中的 ${selectedIds.value.length} 个会议?\n\n` +
|
||||
`每个会议将级联软删除 6 张表 (会议主表 + 附件 + 参会人 + 监督员 + 执行人 + 审计日志).\n\n` +
|
||||
`数据保留, 不再展示给前台用户。`,
|
||||
'批量删除确认',
|
||||
{ type: 'error', confirmButtonText: `确认删除 ${selectedIds.value.length} 个`, cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await bizDelete('meeting', selectedIds.value)
|
||||
ElMessage.success(`已批量软删除 ${selectedIds.value.length} 个会议`)
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('activate')">
|
||||
批量开通<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button v-if="canDelete" class="action-btn secondary" type="danger" :disabled="!selection.length" @click="onBatchDelete">
|
||||
批量删除<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
@@ -131,6 +134,7 @@
|
||||
<el-dropdown-item command="exportExperts" divided>导出报名专家</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="activate" divided>开通</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="delAnn" divided>删除公告</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDelete" command="delete" divided>删除项目</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -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() }
|
||||
|
||||
// 导出
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="page-card manager-projects-new">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
首页 / 项目管理 / <span class="current">{{ isEdit ? '编辑项目' : '新建项目' }}</span>
|
||||
@@ -121,14 +121,14 @@
|
||||
/>
|
||||
</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>
|
||||
<!-- 底部按钮 (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>
|
||||
|
||||
@@ -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') {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整页面板 (与 People.vue 风格一致, 1 层) */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
/* 整页面板 (与 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; }
|
||||
|
||||
@@ -399,6 +419,19 @@ async function submit(mode = 'save') {
|
||||
.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; }
|
||||
/* 底部按钮 (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>
|
||||
Reference in New Issue
Block a user