404 lines
15 KiB
Vue
404 lines
15 KiB
Vue
<template>
|
|
<div class="page-card manager-meeting-new">
|
|
<!-- 面包屑: 标题随 mode 变 (新建/修改/复制) -->
|
|
<div class="breadcrumb">首页 / 会议管理 / {{ pageTitle }}</div>
|
|
|
|
<!-- el-form 包裹 + label-width 110px (跟原型一致) -->
|
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
|
<!-- 只读快照 (直接显示, 放上面) -->
|
|
<div class="form-grid">
|
|
<el-form-item label="项目编号">
|
|
<span class="form-value code">{{ snap.projectNo }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="项目名称">
|
|
<span class="form-value">{{ snap.projectName }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="支持单位">
|
|
<span class="form-value">{{ snap.sponsorOrgName }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="总场次/总期数">
|
|
<span class="form-value">{{ snap.totalSessions }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="项目开始时间">
|
|
<span class="form-value">{{ snap.projectStartTime }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="项目结束时间">
|
|
<span class="form-value">{{ snap.projectEndTime }}</span>
|
|
</el-form-item>
|
|
</div>
|
|
|
|
<!-- 待填写 (放下面) -->
|
|
<div class="form-grid form-grid-inputs">
|
|
<el-form-item label="会议名称">
|
|
<el-input v-if="canEditName" v-model="form.meetingName" placeholder="请输入会议名称" />
|
|
<span v-else class="form-value">{{ form.meetingName }}</span>
|
|
</el-form-item>
|
|
<el-form-item label="期数" prop="periodNo">
|
|
<!--
|
|
不用 el-input-number: 移动端其 +/- 按钮 absolute 定位在容器最右,
|
|
容器 100% 后按钮飘到行尾. 改用 type="number" 原生 input, 浏览器自带步进按钮在 input 内右贴边
|
|
-->
|
|
<el-input v-model.number="form.periodNo" type="number" step="1" :min="1" :max="totalSessionsNum" placeholder="请输入期数" />
|
|
</el-form-item>
|
|
<el-form-item label="会议开始时间" prop="startTime">
|
|
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />
|
|
</el-form-item>
|
|
<el-form-item label="会议结束时间" prop="endTime">
|
|
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择结束时间" />
|
|
</el-form-item>
|
|
<el-form-item label="会议地点" prop="address">
|
|
<el-input v-model="form.address" placeholder="请输入会议地点" />
|
|
</el-form-item>
|
|
<el-form-item label="备注">
|
|
<el-input v-model="form.remark" placeholder="请输入备注" />
|
|
</el-form-item>
|
|
</div>
|
|
|
|
<!-- 底部按钮 (skill: 用 el-form-item 包裹, 不另起 div.form-actions) -->
|
|
<el-form-item class="form-actions">
|
|
<el-button type="primary" :loading="submitting" @click="onSave">保存</el-button>
|
|
<el-button @click="goBack">取消</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { bizAdd, bizGet, bizUpdate, getProjectAssignedSessions } from '@/api/public'
|
|
import { setPageTitle, clearPageTitle } from '@/utils/pageTitle'
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
// ========== 角色感知 (admin/manager/executor 共用此新建页, 按 URL 第 1 段识别) ==========
|
|
const roleSegment = computed(() => route.path.split('/')[1]) // 'admin' | 'manager' | 'executor'
|
|
const isAdmin = computed(() => roleSegment.value === 'admin')
|
|
// 会议名称可编辑: 仅 admin/manager (executor 保持只读, 自动用项目名)
|
|
const canEditName = computed(() => roleSegment.value === 'admin' || roleSegment.value === 'manager')
|
|
const meetingsBase = computed(() => `/${roleSegment.value}/meetings`)
|
|
const projectsBase = computed(() => `/${roleSegment.value}/projects`)
|
|
|
|
// ========== mode 判定 ==========
|
|
// 3 种模式:
|
|
// new: ?projectId=xxx → POST /business/meeting (新建)
|
|
// edit: ?meetingId=xxx → PUT /business/meeting/{id} (修改)
|
|
// copy: ?meetingId=xxx&mode=copy → POST /business/meeting (复制)
|
|
// 修改/复制 URL 同时带 projectId 让左列快照能立即加载 (不必先等 bizGet meeting)
|
|
const meetingId = route.query.meetingId || ''
|
|
const urlProjectId = route.query.projectId || ''
|
|
const mode = computed(() => {
|
|
if (meetingId && route.query.mode === 'copy') return 'copy'
|
|
if (meetingId) return 'edit'
|
|
return 'new'
|
|
})
|
|
const pageTitle = computed(() => ({ new: '新建会议', edit: '修改会议', copy: '复制会议' }[mode.value]))
|
|
|
|
// 同步标题到 navbar 顶栏 (route.meta 是 shallowRef 不响应嵌套改动, 改用共享 pageTitle util)
|
|
watchEffect(() => { setPageTitle(pageTitle.value) })
|
|
onBeforeUnmount(clearPageTitle)
|
|
|
|
const formRef = ref(null)
|
|
const submitting = ref(false)
|
|
|
|
// ========== 项目只读快照 (左列 4 项) ==========
|
|
const snap = reactive({
|
|
projectNo: '',
|
|
projectName: '',
|
|
totalSessions: '',
|
|
sponsorOrgName: '',
|
|
projectStartTime: '',
|
|
projectEndTime: ''
|
|
})
|
|
|
|
// 期数上限 = 总场次/总期数 (executor 快照 totalSessions 已换成分配给本机构的场次口径)
|
|
const totalSessionsNum = computed(() => {
|
|
const n = Number(snap.totalSessions)
|
|
return Number.isNaN(n) || n <= 0 ? undefined : n
|
|
})
|
|
|
|
// ========== 表单 (右列 5 项: meetingName 只读 + 4 输入) ==========
|
|
const form = reactive({
|
|
projectId: urlProjectId ? Number(urlProjectId) : null,
|
|
projectNo: '',
|
|
projectName: '',
|
|
meetingName: '',
|
|
periodNo: null,
|
|
startTime: '',
|
|
endTime: '',
|
|
address: '',
|
|
remark: ''
|
|
})
|
|
|
|
// 期数必须是正整数 (第几期), 小数/负数会触发后端 Long 反序列化报错
|
|
function validatePeriodNo(_rule, value, cb) {
|
|
if (value === null || value === undefined || value === '') { cb(); return }
|
|
const n = Number(value)
|
|
if (!Number.isInteger(n) || n < 1) { cb(new Error('期数必须为正整数')); return }
|
|
cb()
|
|
}
|
|
|
|
const rules = {
|
|
periodNo: [
|
|
{ required: true, message: '请填写期数', trigger: 'blur' },
|
|
{ validator: validatePeriodNo, trigger: 'blur' }
|
|
],
|
|
startTime: [{ required: true, message: '请选择会议开始时间', trigger: 'change' }],
|
|
endTime: [{ required: true, message: '请选择会议结束时间', trigger: 'change' }],
|
|
address: [{ required: true, message: '请填写会议地点', trigger: 'blur' }]
|
|
}
|
|
|
|
// ========== 加载项目快照 ==========
|
|
async function loadProjectSnapshot(pid) {
|
|
if (!pid) return
|
|
try {
|
|
const r = await bizGet('project', pid)
|
|
const d = (r && r.data) || {}
|
|
snap.projectNo = d.projectNo || ''
|
|
snap.projectName = d.projectName || ''
|
|
snap.totalSessions = d.totalSessions ?? ''
|
|
snap.sponsorOrgName = d.sponsorOrgName || ''
|
|
snap.projectStartTime = d.startTime || ''
|
|
snap.projectEndTime = d.endTime || ''
|
|
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
|
|
if (roleSegment.value === 'executor') {
|
|
try {
|
|
const ar = await getProjectAssignedSessions(pid)
|
|
const ad = (ar && ar.data) || {}
|
|
if (ad.assignedSessions != null) snap.totalSessions = ad.assignedSessions
|
|
} catch (e) { /* 静默, 不阻塞主流程 */ }
|
|
}
|
|
} catch (e) { /* 静默, 不阻塞主流程 */ }
|
|
}
|
|
|
|
// ========== 加载会议详情 (edit/copy 模式用) ==========
|
|
async function loadMeeting(mid) {
|
|
if (!mid) return
|
|
const r = await bizGet('meeting', mid)
|
|
const d = (r && r.data) || {}
|
|
form.meetingId = d.meetingId
|
|
form.projectId = d.projectId ?? null
|
|
form.projectNo = d.projectNo || ''
|
|
form.projectName = d.projectName || ''
|
|
form.meetingName = d.meetingName || ''
|
|
form.periodNo = d.periodNo ?? null
|
|
form.startTime = d.startTime || ''
|
|
form.endTime = d.endTime || ''
|
|
form.address = d.address || ''
|
|
form.remark = d.remark || ''
|
|
// 左列快照 projectNo/projectName 用 meeting 冗余字段 (即使 project_id 为 NULL 也能显示)
|
|
snap.projectNo = d.projectNo || ''
|
|
snap.projectName = d.projectName || ''
|
|
// 总场次/支持单位 优先用 URL projectId, 其次 meeting.projectId
|
|
const pid = urlProjectId || d.projectId
|
|
if (pid) {
|
|
await loadProjectSnapshot(pid)
|
|
}
|
|
}
|
|
|
|
// ========== 保存 ==========
|
|
async function onSave() {
|
|
try { await formRef.value.validate() } catch (e) { return }
|
|
// 会议开始时间不能晚于会议结束时间 (通用校验)
|
|
if (form.startTime && form.endTime
|
|
&& new Date(form.startTime.replace(' ', 'T')) > new Date(form.endTime.replace(' ', 'T'))) {
|
|
ElMessage.error('会议开始时间不能晚于会议结束时间')
|
|
return
|
|
}
|
|
// 会议时间必须在项目起止时间区间内 (闭区间; 项目未设起止则跳过对应边界)
|
|
if (snap.projectStartTime && form.startTime
|
|
&& new Date(form.startTime.replace(' ', 'T')) < new Date(snap.projectStartTime.replace(' ', 'T'))) {
|
|
ElMessage.error('会议开始时间不能早于项目开始时间')
|
|
return
|
|
}
|
|
if (snap.projectEndTime && form.endTime
|
|
&& new Date(form.endTime.replace(' ', 'T')) > new Date(snap.projectEndTime.replace(' ', 'T'))) {
|
|
ElMessage.error('会议结束时间不能晚于项目结束时间')
|
|
return
|
|
}
|
|
// 期数不得高于总场次/总期数 (executor 快照 totalSessions 已换成分配给本机构的场次口径)
|
|
const pn = Number(form.periodNo)
|
|
const total = Number(snap.totalSessions)
|
|
if (!Number.isNaN(pn) && !Number.isNaN(total) && pn > total) {
|
|
ElMessage.error(`期数不能高于总场次/总期数 (共 ${total} 场)`)
|
|
return
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const payload = {
|
|
projectId: form.projectId,
|
|
projectNo: form.projectNo,
|
|
projectName: form.projectName,
|
|
meetingName: form.meetingName,
|
|
periodNo: form.periodNo,
|
|
startTime: form.startTime,
|
|
endTime: form.endTime,
|
|
address: form.address,
|
|
remark: form.remark
|
|
}
|
|
if (mode.value === 'edit') {
|
|
// 修改: 走 bizUpdate, 带 meetingId (后端 WHERE 用, 不能 delete)
|
|
payload.meetingId = form.meetingId
|
|
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免双 toast)
|
|
await bizUpdate('meeting', payload, { __silentError: true })
|
|
ElMessage.success('修改成功')
|
|
} else {
|
|
// 新建 / 复制: 走 bizAdd
|
|
await bizAdd('meeting', payload, { __silentError: true })
|
|
ElMessage.success(mode.value === 'copy' ? '复制成功' : '会议创建成功')
|
|
}
|
|
// 建会/修改/复制成功 → 跳会议列表 (不再返回项目列表, executor/manager 一致)
|
|
router.push(meetingsBase.value)
|
|
} catch (e) {
|
|
ElMessage.error(e?.msg || (mode.value === 'edit' ? '修改失败' : '保存失败'))
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
// 跳回上游列表页 (按角色):
|
|
// new (从 Projects 来) → /{role}/projects
|
|
// edit/copy (从 Meetings 来) → /{role}/meetings
|
|
if (mode.value === 'new') {
|
|
router.push(projectsBase.value)
|
|
} else {
|
|
router.push(meetingsBase.value)
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (mode.value === 'edit') {
|
|
await loadMeeting(meetingId)
|
|
} else if (mode.value === 'copy') {
|
|
// 复制: 加载会议详情, 会议名用项目名称 (而非 "xx 副本"), 期数 reset 让用户重填
|
|
await loadMeeting(meetingId)
|
|
form.meetingName = snap.projectName || form.projectName || form.meetingName
|
|
form.periodNo = null // 复制后让用户重新填第几期
|
|
form.meetingId = null // 防御: 不带源 meetingId 走 bizAdd
|
|
} else {
|
|
// 新建: 加载项目快照, meetingName 默认 = 项目名
|
|
if (urlProjectId) {
|
|
await loadProjectSnapshot(urlProjectId)
|
|
form.projectNo = snap.projectNo
|
|
form.projectName = snap.projectName
|
|
form.meetingName = snap.projectName
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* === 整页面板 (跟 Accounts.vue 一致: max-width: 1200px, 不居中) === */
|
|
.manager-meeting-new {
|
|
padding: 16px;
|
|
max-width: 1200px;
|
|
}
|
|
|
|
/* 面包屑 (跟 executor/People.vue 一致) */
|
|
.breadcrumb {
|
|
font-size: 13px;
|
|
color: #8c8c8c;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
/* === 2 列布局 (只读区/填写区 各一个 grid, 行优先流) === */
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 20px 32px;
|
|
max-width: 900px;
|
|
}
|
|
/* 填写区: 与上方只读区分隔 */
|
|
.form-grid-inputs {
|
|
margin-top: 24px;
|
|
padding-top: 24px;
|
|
border-top: 1px solid #f0f0f0;
|
|
}
|
|
/* grid 内的 el-form-item 不再加默认 margin-bottom (由 grid 控制间距) */
|
|
.form-grid :deep(.el-form-item) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* 只读值样式 */
|
|
.form-value {
|
|
font-size: 14px;
|
|
color: #333;
|
|
line-height: 32px;
|
|
}
|
|
.form-value.code {
|
|
font-family: ui-monospace, "Courier New", monospace;
|
|
color: var(--brand-primary);
|
|
}
|
|
|
|
/* 底部按钮 (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;
|
|
}
|
|
|
|
|
|
/* ========================================
|
|
移动端适配 (≤768px)
|
|
- 只调整本页面布局 (form-grid / form-actions / form-value + form-item 容器),
|
|
不强制覆盖 Element Plus 控件内部样式 (.el-input / .el-date-editor / .el-input__wrapper)
|
|
======================================== */
|
|
@media (max-width: 768px) {
|
|
.page-card { padding: 12px !important; border-radius: 4px !important; }
|
|
.breadcrumb { font-size: 12px !important; margin-bottom: 12px !important; }
|
|
|
|
/* 双列 grid → 单列, gap 加大保证 form-item 之间有足够空间显示 error message */
|
|
.form-grid {
|
|
grid-template-columns: 1fr !important;
|
|
gap: 22px !important;
|
|
max-width: 100% !important;
|
|
}
|
|
.form-grid-inputs { margin-top: 16px !important; padding-top: 16px !important; }
|
|
|
|
/* form-item 容器: 让 label + content 横向并排 (label-width=110px 桌面默认, mobile 仍生效)
|
|
align-items: flex-start 让 .el-form-item__error (validate 错误提示) 能在 content 下方独立占行,
|
|
不被垂直居中挤掉 */
|
|
.manager-meeting-new :deep(.el-form-item) {
|
|
display: flex !important;
|
|
align-items: flex-start !important;
|
|
margin-right: 0 !important;
|
|
margin-bottom: 0 !important;
|
|
}
|
|
.manager-meeting-new :deep(.el-form-item__content) {
|
|
flex: 1 !important;
|
|
min-width: 0 !important;
|
|
}
|
|
|
|
/* 只读值容器 (form-value) 也走 flex:1 让其与 input 一致 */
|
|
.form-value { flex: 1; min-width: 0; line-height: 32px; }
|
|
|
|
/* 底部按钮: 左右并排等宽 */
|
|
.form-actions { margin-top: 16px !important; padding-top: 16px !important; }
|
|
.form-actions :deep(.el-form-item__content) {
|
|
display: flex !important;
|
|
flex-wrap: wrap !important;
|
|
gap: 0 !important;
|
|
}
|
|
.form-actions :deep(.el-button) {
|
|
flex: 1 1 0 !important;
|
|
width: 0 !important;
|
|
margin-left: 0 !important;
|
|
margin-right: 0 !important;
|
|
}
|
|
.form-actions :deep(.el-button + .el-button) {
|
|
margin-left: 8px !important;
|
|
}
|
|
}
|
|
</style> |