Files
guoju0808/ry-vue3/src/views/manager/ProjectsNew.vue
T
郭庆泰 56615f9806 feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology:
- BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档
- ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配
- application-prod/test.yml: ecology 只读库数据源

会议邀请函/分享:
- InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享
- PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页

参会人/执行意向:
- BizMeetingAttendee*: 参会人模块增强
- BizExecutionIntent*: 执行意向增强

签到/劳务/导入:
- BizSignServiceImpl + SignFill.vue: 签到劳务
- FieldMismatch/ImportMismatch: 导入校验 VO

公示/会议详情:
- PublicityDetail.vue / MeetingDetail.vue 等前端增强

配置:
- test 短信 mock + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00

587 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-card manager-projects-new">
<!-- 面包屑 -->
<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="projectNo">
<el-select v-model="form.projectNo" placeholder="输入项目编号搜索" filterable allow-create default-first-option
:filter-method="searchProjectNos" :loading="projectNoLoading" clearable style="width:100%"
@change="onProjectNoChange">
<el-option v-for="p in projectNoOptions" :key="p.projectNo"
:label="p.projectName ? `${p.projectNo}${p.projectName}` : p.projectNo" :value="p.projectNo" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="form.projectName" 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="searchLeaders" style="width:100%">
<el-option v-for="u in leaderOptions" :key="u.userId"
:label="`${u.nickName || u.userName}${u.nickName && u.userName ? ' (' + u.userName + ')' : ''}`" :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">
<div class="num-input"><el-input-number v-model="form.totalSessions" :min="1" /></div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="总金额(元)" prop="totalAmount">
<div class="num-input"><el-input-number v-model="form.totalAmount" :min="0" :precision="2" /></div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="管理费(元)">
<div class="num-input"><el-input-number v-model="form.manageFee" :min="0" :precision="2" /></div>
</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 v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
<div class="role-select">
<el-select v-model="r.role" placeholder="请选择" @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>
</div>
<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-link :underline="false" type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除角色</el-link>
</div>
</div>
<el-link :underline="false" type="primary" @click="addRoleRow" style="margin-top: 8px;">+ 增加角色</el-link>
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
<div class="notice-uploader">
<oss-file-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>
</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>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet, searchEcologyProjects, pullEcologyProject } from '@/api/public'
import { listExecutor, listLeaders } 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: '' } }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与 el-date-picker 的本地时间口径一致; 不能用 toISOString 的 UTC)
function nowLocalDatetime() {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 项目负责人候选 (sys_user.role_type='leader')
const leaderOptions = ref([])
let _managerTimer = null
const form = reactive({
projectName: '', projectNo: '', projectForm: '',
// 项目负责人 (sys_user.role_type='leader', 名称由后端 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('同步数据:从外部数据源拉取最新项目信息 (后端支持时启用)')
}
// ===== ecology 项目编号远程搜索 + 自动拉取 =====
const projectNoOptions = ref([])
const projectNoLoading = ref(false)
let _projectNoTimer = null
// 项目编号边输入边搜索 (空关键词清空, 不发请求)
function searchProjectNos(query) {
if (_projectNoTimer) clearTimeout(_projectNoTimer)
_projectNoTimer = setTimeout(async () => {
const kw = (query || '').trim()
if (!kw) {
projectNoOptions.value = []
projectNoLoading.value = false
return
}
projectNoLoading.value = true
try {
const r = await searchEcologyProjects({ xmbh: kw })
projectNoOptions.value = (r?.data) || []
} catch (e) {
projectNoOptions.value = []
} finally {
projectNoLoading.value = false
}
}, 200)
}
// 选中项目编号 → 拉取完整信息并覆盖表单 (负责人静默建档由后端完成)
async function onProjectNoChange(val) {
if (!val) return
// 仅当所选值来自 ecology 下拉时才拉取 (allow-create 自由输入不拉, 避免无谓请求)
const matched = projectNoOptions.value.find(p => p.projectNo === val)
if (!matched) return
try {
const res = await pullEcologyProject(val, { __silentError: true })
const d = res?.data || res
if (!d) return
form.projectName = d.projectName || ''
form.projectForm = d.projectForm || ''
form.totalAmount = d.totalAmount != null ? Number(d.totalAmount) : 0
form.manageFee = d.manageFee != null ? Number(d.manageFee) : 0
form.startTime = d.startTime || ''
form.endTime = d.endTime || ''
if (d.leaderUserId != null) {
form.leadUserId = d.leaderUserId
// 把自动建档的负责人塞进 leaderOptions, 避免 el-select 选中后 label 空白
if (!leaderOptions.value.find(u => u.userId === d.leaderUserId)) {
leaderOptions.value = [{ userId: d.leaderUserId, nickName: d.leaderName, userName: d.leaderPhone }, ...leaderOptions.value]
}
}
} catch (e) {
// ecology 未找到/不可达: 静默, 保留用户手填内容
}
}
// 编辑模式:加载已有项目数据并反显到表单
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 || ''
// 编辑模式: 项目编号 select 预置当前项, 避免远程搜索 select 无选项时 label 空白
if (form.projectNo) projectNoOptions.value = [{ projectNo: form.projectNo, projectName: form.projectName }]
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'
// 角色劳务反显: 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)
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 {
// 兼容老数据 (独立列: invitationUrl/supportLetterUrl/publishUrl/scheduleUrl)
const noticeMap = {
invitation: p.invitationUrl,
supportLetter: p.supportLetterUrl,
notice: p.publishUrl,
schedule: p.scheduleUrl
}
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='leader')
async function loadLeaders(query) {
if (_managerTimer) clearTimeout(_managerTimer)
_managerTimer = setTimeout(async () => {
try {
const params = { pageNum: 1, pageSize: 50 }
if (query) params.userName = query
const r = await listLeaders(params)
leaderOptions.value = (r.data && r.data.rows) || r.rows || []
// 已选项不在结果里时按 userId 单独拉取并加首位, 避免 el-select 选中后 label 空白
if (form.leadUserId && !leaderOptions.value.find(u => u.userId === form.leadUserId)) {
const r2 = await listLeaders({ userId: form.leadUserId })
const sel = (r2.data && r2.data.rows) || r2.rows || []
if (sel[0]) leaderOptions.value = [sel[0], ...leaderOptions.value]
}
} catch (e) { leaderOptions.value = [] }
}, 200)
}
function searchLeaders(q) { loadLeaders(q) }
onMounted(() => {
loadProject()
loadLeaders('')
})
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
// 本页被 admin/manager 复用, 按当前路由前缀回各自项目列表 (否则 admin 会被角色守卫拦回工作台)
const base = '/' + route.path.split('/')[1]
router.push({ path: `${base}/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
}
// 角色劳务: 过滤空 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,
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.publishUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url
}
// 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = nowLocalDatetime()
}
// 多执行方分配已移至"项目分配"页面单独管理
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 successText = isEdit
? (mode === 'publish' ? '更新并发布公告成功' : '更新成功')
: (mode === 'publish' ? '保存并发布公告成功' : '保存成功')
ElMessage.success(successText)
goBack()
} catch (e) {
ElMessage.error(e?.msg || (isEdit ? '更新失败' : '新建失败'))
} finally {
submitting.value = false
}
}
</script>
<style scoped>
/* 整页面板 (与 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; }
/* 表单 (整页一个 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-select { width: 160px; flex-shrink: 0; display: flex; align-items: center; }
.role-custom { flex: 1; max-width: 180px; }
.role-amount { width: 180px; }
.row-actions { display: flex; gap: 8px; }
/* 项目信息数字输入框: 桌面限制宽度, 不撑满双列 */
.num-input { width: 200px; max-width: 100%; }
/* 公告文件上传: 限制最大宽度, 上传按钮不要太长 */
.notice-uploader { flex: 1; min-width: 0; max-width: 480px; }
.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; }
/* 底部按钮 (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)
- 卡片 padding 收窄
- el-row 双列 → 单列, form-item 横向 (label-width=120px 桌面默认沿用, 让 Element Plus 自然对齐)
- 角色劳务行 / 公告文件行 / 底部按钮: 紧凑布局
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.manager-projects-new { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 章节标题间距收紧 */
.new-card-title { font-size: 13px !important; margin: 12px 0 8px !important; }
/* 双列 → 单列: el-row 强制 block, el-col 100% 宽 */
.project-form :deep(.el-row) { display: block !important; }
.project-form :deep(.el-col) {
width: 100% !important;
max-width: 100% !important;
flex: 0 0 100% !important;
display: block !important;
}
/* form-item: 横向 label + content, flex-start 让 error message 占独立行
不动 __label 的 float/width/height/line-height — Element Plus 自带 label-width=120px
自然右对齐 + 跟随 content 行高, 不会参差不齐 */
.project-form :deep(.el-form-item) {
display: flex !important;
align-items: flex-start !important;
margin-right: 0 !important;
}
.project-form :deep(.el-form-item__content) {
flex: 1 !important;
min-width: 0 !important;
}
/* 表单控件全宽, 强制拉伸 (项目信息 section 的输入控件全宽)
注意: .role-row 内的控件有 .role-select/.role-amount 容器保护, 不会吃这条规则 */
.project-form :deep(.el-form-item .el-select),
.project-form :deep(.el-form-item .el-input),
.project-form :deep(.el-form-item .el-textarea),
.project-form :deep(.el-form-item .el-date-editor),
.project-form :deep(.el-form-item .el-input-number),
.project-form :deep(.el-form-item .el-cascader) {
width: 100% !important;
min-width: 0 !important;
display: block !important;
}
/* 数字输入框: 移动端占满 (有 .num-input 容器保护, 不会挤压其它数字输入) */
.project-form .num-input { width: 100% !important; }
/* 角色劳务行: 保持桌面原有横向布局 (角色/金额/删除) 一行, 不被表单规则拉宽 */
.role-row { flex-wrap: wrap !important; gap: 6px !important; }
.role-row .role-select { width: 110px !important; flex-shrink: 0; display: flex !important; align-items: center !important; }
.role-row .role-amount { width: 130px !important; flex-shrink: 0; }
.role-row .row-actions { margin-left: auto; }
/* 公告文件行: 横向 (label + uploader), label 顶部对齐 */
.notice-row { gap: 8px !important; margin-bottom: 10px !important; }
.notice-label {
width: 70px !important;
font-size: 12px !important;
}
/* 移动端: uploader 限制固定宽度 280px (不撑满不溢出) */
.notice-uploader { width: 280px !important; max-width: 100% !important; min-width: 0 !important; flex: none !important; }
/* 底部按钮: 等宽并排 */
.form-actions :deep(.el-form-item__content) {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.form-actions :deep(.el-button) {
flex: 1 1 0 !important;
width: 0 !important;
}
}
</style>