Files
guoju0808/ry-vue3/src/views/meetings/MeetingNew.vue
T

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>