feat: 会议提交剩余时间+解冻 + 会务/劳务材料批量下载 + 多角色人员/账号模块完善
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,6 @@
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="hint-text">*点击数字,跳转到对应的列表页</p>
|
||||
|
||||
<!-- 各角色用户数分布 -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">各角色用户数</h2>
|
||||
@@ -122,8 +120,6 @@ onMounted(load)
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hint-text { font-size: 12px; color: #8c8c8c; margin: 12px 0 20px; text-align: center; }
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
|
||||
|
||||
<div class="login-tabs">
|
||||
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
|
||||
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
|
||||
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
|
||||
</div>
|
||||
|
||||
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
|
||||
@@ -44,7 +44,7 @@
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号" autocomplete="username">
|
||||
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号 / 邮箱" autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -197,7 +197,7 @@ const showRoleModal = ref(false)
|
||||
const registerUserType = ref('')
|
||||
|
||||
// 登录模式: 'password' | 'sms'
|
||||
const loginMode = ref('sms')
|
||||
const loginMode = ref('password')
|
||||
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
|
||||
const smsSmsCountdown = ref(0)
|
||||
let smsSmsTimer = null
|
||||
@@ -356,7 +356,7 @@ const roleHome = {
|
||||
admin: '/admin/workbench',
|
||||
manager: '/manager/workbench',
|
||||
doctor: '/doctor/home',
|
||||
executor: '/executor/overview',
|
||||
executor: '/executor/submissions',
|
||||
sponsor: '/sponsor/home',
|
||||
}
|
||||
|
||||
|
||||
@@ -16,17 +16,22 @@
|
||||
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="企业名称" prop="unitName">
|
||||
<el-input v-model="form.unitName" placeholder="请输入企业全称" maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="企业性质" prop="businessNature">
|
||||
<el-select v-model="form.businessNature" placeholder="请选择企业性质" style="width:100%">
|
||||
<el-option label="私营" value="私营" />
|
||||
<el-option label="国营" value="国营" />
|
||||
<el-option label="中外合资" value="中外合资" />
|
||||
<el-option label="外资" value="外资" />
|
||||
<el-option label="其他" value="其他" />
|
||||
<el-form-item label="企业名称" prop="orgId">
|
||||
<el-select
|
||||
v-model="form.orgId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchOrg"
|
||||
:loading="orgLoading"
|
||||
placeholder="输入企业名称搜索并选择"
|
||||
style="width:100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in orgOptions"
|
||||
:key="o.orgId"
|
||||
:label="o.orgName"
|
||||
:value="o.orgId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -86,10 +91,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onUnmounted } from 'vue'
|
||||
import { reactive, ref, onUnmounted, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { registerSponsor } from '@/api/public'
|
||||
import { registerSponsor, sponsorOrgOptions } from '@/api/public'
|
||||
import PortalShell from '@/components/PortalShell.vue'
|
||||
import { useAsyncLock } from '@/utils/useAsyncLock'
|
||||
|
||||
@@ -102,8 +107,7 @@ const agreed = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
unitName: '',
|
||||
businessNature: '',
|
||||
orgId: null,
|
||||
phone: '',
|
||||
smsCode: '',
|
||||
password: '',
|
||||
@@ -116,8 +120,7 @@ const rules = {
|
||||
{ min: 4, max: 20, message: '用户名长度 4-20 位', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' }
|
||||
],
|
||||
unitName: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
|
||||
businessNature: [{ required: true, message: '请选择企业性质', trigger: 'change' }],
|
||||
orgId: [{ required: true, message: '请选择企业', trigger: 'change' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
|
||||
@@ -139,6 +142,18 @@ const rules = {
|
||||
]
|
||||
}
|
||||
|
||||
// ===== 企业远程搜索 (biz_org sponsor 类型) =====
|
||||
const orgOptions = ref([])
|
||||
const orgLoading = ref(false)
|
||||
function searchOrg(query) {
|
||||
orgLoading.value = true
|
||||
sponsorOrgOptions({ orgName: query || '' })
|
||||
.then(res => { orgOptions.value = res.data || [] })
|
||||
.catch(() => { orgOptions.value = [] })
|
||||
.finally(() => { orgLoading.value = false })
|
||||
}
|
||||
onMounted(() => searchOrg(''))
|
||||
|
||||
// ===== 短信验证码 =====
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer = null
|
||||
|
||||
@@ -15,37 +15,25 @@
|
||||
<!-- 个人信息 -->
|
||||
<el-tab-pane label="个人信息" name="profile">
|
||||
<div class="card">
|
||||
<!-- 头像 + 用户信息 -->
|
||||
<div class="avatar-block">
|
||||
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
|
||||
<template v-else>{{ avatarChar }}</template>
|
||||
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
|
||||
</div>
|
||||
<div class="avatar-meta">
|
||||
<div class="name">{{ form.name || '专家账号' }}</div>
|
||||
<div class="role">专家账号</div>
|
||||
<div class="phone-row">
|
||||
<span class="phone-label">联系电话:</span>
|
||||
<span class="phone-value">{{ form.phone || '未设置' }}</span>
|
||||
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
|
||||
<el-icon><EditPen /></el-icon> 修改
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本资料 (2 列) -->
|
||||
<div class="section-title">基本资料</div>
|
||||
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
|
||||
<el-form-item label="专家姓名" :required="true">
|
||||
<el-input v-model="form.name" placeholder="请填写姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<div class="phone-row">
|
||||
<span class="phone-value">{{ form.phone || '未设置' }}</span>
|
||||
<el-button link type="primary" @click="openPhoneDialog">
|
||||
<el-icon><EditPen /></el-icon> 修改
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="科室" :required="true">
|
||||
<doctor-dept-select v-model="form.department" value-field="label" placeholder="输入关键词搜索" filterable />
|
||||
</el-form-item>
|
||||
<el-form-item label="地区" :required="true">
|
||||
<area-cascader v-model="form.region" format="string" join-sep="/" placeholder="请选择省/市/区" />
|
||||
<area-cascader v-model="form.region" format="string" join-sep="/" :max-level="2" placeholder="请选择省/市" />
|
||||
</el-form-item>
|
||||
<el-form-item label="证件号码" :required="true">
|
||||
<el-input v-model="form.idCardNo" placeholder="请填写证件号码" />
|
||||
@@ -131,9 +119,6 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 隐藏的 avatar 上传 input -->
|
||||
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
|
||||
|
||||
<!-- 修改手机号 dialog (独立于其它字段) -->
|
||||
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="phoneForm" label-width="100px">
|
||||
@@ -159,12 +144,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { reactive, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import request from '@/utils/request'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
|
||||
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
|
||||
@@ -209,50 +193,8 @@ const pwdError = ref('')
|
||||
const saveError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const avatarUrl = ref(store.user?.avatar || '')
|
||||
const avatarChar = computed(() => (form.name || '专').slice(0, 1))
|
||||
const avatarUploading = ref(false)
|
||||
const avatarInput = ref(null)
|
||||
|
||||
let snapshot = ref(null)
|
||||
|
||||
function onAvatarClick() {
|
||||
if (avatarUploading.value) return
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAvatarFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = '' // 重置以便可重选
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return ElMessage.warning('请选择图片文件')
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return ElMessage.warning('图片大小不能超过 5MB')
|
||||
}
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, 'ry8080/avatar/')
|
||||
await request({
|
||||
url: '/system/user/profile/avatarUrl',
|
||||
method: 'put',
|
||||
data: { avatarUrl: url }
|
||||
})
|
||||
avatarUrl.value = url
|
||||
if (store.user) {
|
||||
store.user.avatar = url
|
||||
// 同步 localStorage, 否则刷新后头像丢失
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
ElMessage.success('头像更新成功')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.msg || '头像上传失败')
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||
window.parent.goHome?.()
|
||||
@@ -296,7 +238,6 @@ async function loadUserProfile() {
|
||||
store.user.phonenumber = u.phonenumber || store.user.phonenumber
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
avatarUrl.value = u.avatar || avatarUrl.value
|
||||
} catch (e) {
|
||||
console.warn('loadUserProfile:', e?.msg)
|
||||
}
|
||||
@@ -305,16 +246,10 @@ async function loadUserProfile() {
|
||||
onMounted(async () => {
|
||||
form.name = store.user?.userName || ''
|
||||
form.phone = store.user?.phonenumber || ''
|
||||
avatarUrl.value = store.user?.avatar || ''
|
||||
await Promise.all([loadUserProfile(), loadExpertProfile()])
|
||||
snapshot = ref(JSON.parse(JSON.stringify(form)))
|
||||
})
|
||||
|
||||
function onAvatarChange(url) {
|
||||
avatarUrl.value = url
|
||||
if (store.user) store.user.avatar = url
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saveError.value = ''
|
||||
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankRegion', 'bankAddress']
|
||||
@@ -499,36 +434,9 @@ onUnmounted(() => {
|
||||
.form-grid :deep(.el-form-item) { margin-bottom: 14px; }
|
||||
.form-grid :deep(.el-form-item__label) { color: #606266; }
|
||||
|
||||
/* 头像区 (含电话显示) */
|
||||
.avatar-block { display: flex; align-items: center; gap: 24px; padding: 16px 0 24px; border-bottom: 1px dashed #ebeef5; margin-bottom: 24px; }
|
||||
.avatar-circle {
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 600;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.avatar-circle:hover { transform: scale(1.04); }
|
||||
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
|
||||
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.avatar-tip {
|
||||
font-size: 11px; opacity: 0.85; margin-top: 4px;
|
||||
position: absolute; bottom: 4px; left: 0; right: 0;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
|
||||
.avatar-meta .role { font-size: 13px; color: #909399; }
|
||||
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
|
||||
.phone-label { color: #909399; }
|
||||
/* 联系电话 (基本资料 form 内显示) */
|
||||
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; }
|
||||
.phone-value { color: #262626; font-weight: 500; }
|
||||
.btn-edit-phone { padding: 0 4px; }
|
||||
|
||||
/* 银行信息 + 密码 form 单列 */
|
||||
.password-form { max-width: 480px; }
|
||||
|
||||
@@ -21,10 +21,25 @@
|
||||
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<el-table-column label="日程" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-button>
|
||||
<el-button link :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邀请函" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-button>
|
||||
<el-button link :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签署状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.attendeeLaborProtocol ? 'success' : 'info'" size="small">{{ row.attendeeLaborProtocol ? '已签署' : '未签署' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
|
||||
<el-button v-if="!row.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
|
||||
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
|
||||
</template>
|
||||
@@ -42,6 +57,23 @@
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<!-- 日程 / 邀请函 预览 dialog (PDF iframe / 图片 img, 复用 publicity 的 OSS 代理预览技术) -->
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="780px" top="6vh" :close-on-click-modal="false" destroy-on-close>
|
||||
<div v-if="previewUrl" class="file-preview">
|
||||
<iframe v-if="isPdf(previewUrl)" :src="proxyUrl(previewUrl)" class="file-preview-iframe"></iframe>
|
||||
<el-image v-else-if="isImg(previewUrl)" :src="previewUrl" :preview-src-list="[previewUrl]" fit="contain" class="file-preview-img" />
|
||||
<div v-else class="file-preview-fallback">
|
||||
<p>该文件类型暂不支持页内预览, 请在新窗口打开查看。</p>
|
||||
<el-button type="primary" @click="openPreviewNew">在新窗口打开</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无附件" />
|
||||
<template #footer>
|
||||
<el-button @click="previewOpen = false">关闭</el-button>
|
||||
<el-button v-if="previewUrl" type="primary" @click="onDownload(previewUrl, previewTitle)">下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 签署劳务 dialog: 二维码 (带 token, 手机扫码可直接登录填写) -->
|
||||
<el-dialog v-model="signOpen" title="签署劳务" width="380px" align-center destroy-on-close>
|
||||
<div class="sign-qr-wrap">
|
||||
@@ -57,18 +89,12 @@
|
||||
|
||||
<!-- 查看劳务 dialog: 已签状态显示, 含 PDF iframe 预览 -->
|
||||
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称">{{ detail.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签署状态">已签署</el-descriptions-item>
|
||||
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="pdf-preview">
|
||||
<iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
|
||||
<iframe v-if="detail.attendeeLaborProtocol" :src="proxyUrl(detail.attendeeLaborProtocol)" style="width:100%;height:70vh;border:1px solid #ebeef5"></iframe>
|
||||
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="detail.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
|
||||
<el-button v-if="detail.attendeeLaborProtocol" type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
|
||||
<el-button @click="laborOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -77,7 +103,7 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList } from '@/api/public'
|
||||
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
|
||||
@@ -88,6 +114,9 @@ const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const detail = ref({})
|
||||
const laborOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
const signOpen = ref(false)
|
||||
const signForm = reactive({ meetingName: '' })
|
||||
const signLink = ref('')
|
||||
@@ -119,27 +148,47 @@ function onViewLabor(row) {
|
||||
laborOpen.value = true
|
||||
}
|
||||
|
||||
function onView(row) {
|
||||
detail.value = row
|
||||
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
|
||||
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
|
||||
ElMessageBox.alert(
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
|
||||
'会议详情',
|
||||
{ confirmButtonText: '关闭' }
|
||||
)
|
||||
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
|
||||
function isPdf(url) {
|
||||
if (!url) return false
|
||||
const path = url.split('?')[0].toLowerCase()
|
||||
return /\.pdf$/.test(path)
|
||||
}
|
||||
|
||||
function onDownloadFile(row) {
|
||||
if (!row.invitationUrl) { ElMessage.warning('该会议暂无邀请函附件'); return }
|
||||
function isImg(url) {
|
||||
if (!url) return false
|
||||
const path = url.split('?')[0].toLowerCase()
|
||||
return /\.(png|jpg|jpeg|gif|webp)$/.test(path)
|
||||
}
|
||||
// OSS bucket 设置 Content-Disposition: attachment, iframe 直接访问会被强制下载,
|
||||
// 走 /common/oss/proxy 后端代理重写为 inline (#toolbar=0 隐藏工具栏, #zoom=page-width 撑满宽度)
|
||||
function proxyUrl(url) {
|
||||
if (!url) return url
|
||||
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
}
|
||||
return url
|
||||
}
|
||||
function onPreview(url, title) {
|
||||
if (!url) { ElMessage.warning('暂无附件'); return }
|
||||
previewUrl.value = url
|
||||
previewTitle.value = title
|
||||
previewOpen.value = true
|
||||
}
|
||||
function onDownload(url, name) {
|
||||
if (!url) { ElMessage.warning('暂无附件'); return }
|
||||
const a = document.createElement('a')
|
||||
a.href = row.invitationUrl
|
||||
a.download = `${row.meetingName || '会议'}_邀请函.pdf`
|
||||
a.href = url
|
||||
a.download = name
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
function openPreviewNew() {
|
||||
const url = proxyUrl(previewUrl.value)
|
||||
if (!url) return
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
async function onSign(row) {
|
||||
signForm.meetingName = row.meetingName || ''
|
||||
@@ -206,6 +255,12 @@ load()
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.pdf-preview { margin-top: 12px; }
|
||||
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
||||
.file-preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.file-preview-img { width: 100%; }
|
||||
.file-preview-img :deep(.el-image__inner) { width: 100%; height: auto; max-height: 70vh; object-fit: contain; }
|
||||
.file-preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
||||
.file-preview-fallback p { margin-bottom: 16px; }
|
||||
.sign-link-tip { font-size: 13px; color: #606266; margin-bottom: 10px; }
|
||||
.sign-link-hint { font-size: 12px; color: #909399; margin-top: 10px; }
|
||||
.sign-qr-wrap { text-align: center; padding: 12px 0; }
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="filter-tip">*下载项目公示的红头文件</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
|
||||
@@ -139,7 +137,6 @@ load()
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.filter-tip { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
|
||||
.status { font-size: 13px; color: #595959; }
|
||||
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
|
||||
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
|
||||
|
||||
@@ -45,9 +45,12 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="planCategory" label="项目类别" width="120" />
|
||||
<el-table-column prop="projectForm" label="形式" width="100" />
|
||||
<el-table-column label="设计文件" width="100">
|
||||
<el-table-column label="设计文件" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.designFileUrl" type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
|
||||
<template v-if="row.designFileUrl">
|
||||
<el-button link type="primary" @click="openPreview(row.designFileUrl, '设计文件')">查看</el-button>
|
||||
<el-link type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -77,6 +80,7 @@
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -88,6 +92,7 @@ import request from '@/utils/request'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import AuditStatusTag from '@/components/AuditStatusTag.vue'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -107,6 +112,11 @@ const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref([])
|
||||
|
||||
// 设计文件预览
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('附件预览')
|
||||
|
||||
// 项目方向 options (复用 manager 侧接口)
|
||||
const planDirectionOptions = ref([])
|
||||
async function loadPlanDirectionOptions() {
|
||||
@@ -184,6 +194,12 @@ async function onBatch() {
|
||||
load()
|
||||
}
|
||||
|
||||
function openPreview(url, title) {
|
||||
previewUrl.value = url
|
||||
previewTitle.value = title || '附件预览'
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPlanDirectionOptions()
|
||||
load()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!--
|
||||
共享服务机构管理页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
共享执行单位管理页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
- 筛选: 两角色一致 (orgName / taxNo / status)
|
||||
- 新增/编辑: 仅 admin (表单 + 保存)
|
||||
- 启用/禁用: 两角色都有 (沿用原两版本行为)
|
||||
@@ -9,12 +9,12 @@
|
||||
- 与 sponsor-orgs/SponsorOrgs.vue 高度相似, 但按用户要求不抽公共组件
|
||||
-->
|
||||
<div class="page-card executor-orgs">
|
||||
<div class="breadcrumb">首页 / 服务机构管理</div>
|
||||
<div class="breadcrumb">首页 / 执行单位管理</div>
|
||||
|
||||
<!-- ========== 筛选区 ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable style="width:200px" />
|
||||
<el-form-item label="单位名称">
|
||||
<el-input v-model="q.orgName" placeholder="输入单位名称" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
<el-input v-model="q.taxNo" placeholder="输入税号" clearable style="width:220px" />
|
||||
@@ -33,15 +33,15 @@
|
||||
|
||||
<!-- ========== 批量按钮区 ========== -->
|
||||
<div class="batch-bar">
|
||||
<!-- 仅 admin 可新建服务机构 -->
|
||||
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增服务机构</el-button>
|
||||
<!-- 仅 admin 可新建执行单位 -->
|
||||
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增执行单位</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="orgName" label="单位名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="单位地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
|
||||
<el-table-column label="合作状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -82,11 +82,11 @@
|
||||
</div>
|
||||
|
||||
<!-- ========== 详情弹窗 (沿用 manager 的 el-descriptions 风格, 更清晰) ========== -->
|
||||
<el-dialog v-model="detailOpen" title="服务机构详情" width="640px" v-if="currentRow">
|
||||
<el-dialog v-model="detailOpen" title="执行单位详情" width="640px" v-if="currentRow">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="公司类型"><el-tag type="primary">执行方</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司地址">{{ currentRow.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位名称">{{ currentRow.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位地址">{{ currentRow.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作状态">
|
||||
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
|
||||
@@ -101,12 +101,12 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== 新增/编辑弹窗 (仅 admin) ========== -->
|
||||
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑服务机构' : '新增服务机构'" width="640px">
|
||||
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑执行单位' : '新增执行单位'" width="640px">
|
||||
<el-form :model="form" label-width="140px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="公司名称" prop="orgName">
|
||||
<el-form-item label="单位名称" prop="orgName">
|
||||
<el-input v-model="form.orgName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司地址">
|
||||
<el-form-item label="单位地址">
|
||||
<el-input v-model="form.address" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
@@ -222,7 +222,7 @@ const form = reactive({
|
||||
contactName: '', contactPhone: '', status: '0'
|
||||
})
|
||||
const rules = {
|
||||
orgName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }]
|
||||
orgName: [{ required: true, message: '请输入单位名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<!--
|
||||
共享服务机构下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
共享执行单位下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
- 入口: org 表格行内「人员管理」按钮 (router.push 带 orgId/orgName)
|
||||
- 接口: bizList('person', q) / bizUpdate / bizAdd / executorImport / executorImportTemplate
|
||||
- op 列: 查看 / 编辑 / 启用/禁用 (3 按钮, 共用, 不分角色)
|
||||
-->
|
||||
<div class="page-card executor-people">
|
||||
<div class="breadcrumb">
|
||||
首页 / 服务机构管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
|
||||
首页 / 执行单位管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
|
||||
</div>
|
||||
|
||||
<!-- ========== 筛选区 ========== -->
|
||||
@@ -18,6 +18,9 @@
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="q.email" placeholder="邮箱" clearable style="width:180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业名称">
|
||||
<el-input v-model="q.orgName" placeholder="企业名称" clearable style="width:180px" />
|
||||
</el-form-item>
|
||||
@@ -37,16 +40,26 @@
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="goNew">新建人员</el-button>
|
||||
<el-button @click="openImport">批量导入</el-button>
|
||||
<span class="filter-tip">*批量导入模板与列表表项一致</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" min-width="100" />
|
||||
<el-table-column prop="position" label="职务" min-width="100" />
|
||||
<!-- 管理员 switch: 仅 admin 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
|
||||
<el-table-column v-if="isAdmin" label="管理员" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.accountType === 'MAIN'"
|
||||
:disabled="switchingId === row.personId"
|
||||
@change="onChangeAdmin(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
|
||||
@@ -78,32 +91,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ========== 批量导入 ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择文件">
|
||||
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx" :on-change="onFileChange" :on-remove="onFileRemove">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item><span class="filter-tip">*导入列: 姓名/手机号/所属公司/部门/职务/角色</span></el-form-item>
|
||||
</el-form>
|
||||
<!-- ========== 批量导入 (沿用 sponsor-orgs/Experts.vue 模式: drag + 模板下载在框外 + ImportResultDialog) ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入" width="400px" append-to-body :close-on-click-modal="false">
|
||||
<el-upload
|
||||
v-if="importOpen"
|
||||
ref="uploadRef"
|
||||
:limit="1"
|
||||
accept=".xlsx, .xls"
|
||||
:on-change="onFileChange"
|
||||
:on-remove="onFileRemove"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</el-upload>
|
||||
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
|
||||
<div style="margin-top:10px;font-size:13px;text-align:center">
|
||||
<span style="color:#909399;margin-right:8px">仅允许导入 xls、xlsx 格式文件</span>
|
||||
<el-link type="primary" :underline="false" @click="downloadTemplate">下载模板</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
|
||||
<el-button @click="importOpen=false">取 消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
|
||||
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import ImportResultDialog from '@/components/ImportResultDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -115,7 +138,7 @@ const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '
|
||||
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
|
||||
const orgName = computed(() => route.query.orgName || '')
|
||||
|
||||
const q = reactive({ name: '', phone: '', orgName: '', department: '', position: '' })
|
||||
const q = reactive({ name: '', phone: '', email: '', orgName: '', department: '', position: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
@@ -145,7 +168,7 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(q, { name: '', phone: '', orgName: '', department: '', position: '' })
|
||||
Object.assign(q, { name: '', phone: '', email: '', orgName: '', department: '', position: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
@@ -171,6 +194,29 @@ async function onToggleStatus(row) {
|
||||
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
// ========== 更换机构管理员 (仅 admin, 逻辑与 sponsor 管理员一致) ==========
|
||||
const switchingId = ref('')
|
||||
async function onChangeAdmin(row) {
|
||||
if (row.accountType === 'MAIN') { ElMessage.info('已是该机构管理员'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将「${row.name}」设为该机构管理员吗?`,
|
||||
'更换管理员',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
switchingId.value = row.personId
|
||||
try {
|
||||
await changePersonAdmin(row.personId)
|
||||
ElMessage.success('已更换管理员')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '更换失败')
|
||||
} finally {
|
||||
switchingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 跳转 (按角色) ==========
|
||||
function goNew() {
|
||||
router.push({
|
||||
@@ -193,8 +239,10 @@ const importOpen = ref(false)
|
||||
const importing = ref(false)
|
||||
const uploadRef = ref()
|
||||
const importFile = ref(null)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref(null)
|
||||
|
||||
function openImport() { importFile.value = null; importOpen.value = true }
|
||||
function openImport() { importFile.value = null; importResult.value = null; importOpen.value = true }
|
||||
function onFileChange(file) { importFile.value = file.raw }
|
||||
function onFileRemove() { importFile.value = null }
|
||||
async function downloadTemplate() {
|
||||
@@ -204,7 +252,7 @@ async function downloadTemplate() {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '服务机构人员导入模板.xlsx'
|
||||
a.download = '执行单位人员导入模板.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { ElMessage.error('模板下载失败') }
|
||||
@@ -213,21 +261,18 @@ async function submitImport() {
|
||||
if (!importFile.value) { ElMessage.warning('请先选择文件'); return }
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await importPerson('executor', importFile.value)
|
||||
const res = await importPerson('executor', importFile.value, orgId.value)
|
||||
const payload = res.data || res
|
||||
const ok = payload.ok ?? 0
|
||||
const total = payload.total ?? 0
|
||||
const failed = (payload.results || []).filter(r => !r.ok)
|
||||
if (failed.length === 0) {
|
||||
ElMessage.success(`导入成功 ${ok}/${total}`)
|
||||
} else {
|
||||
ElMessageBox.alert(
|
||||
`成功 ${ok} 条, 失败 ${failed.length} 条:\n` +
|
||||
failed.map(f => ` 第${f.rowNo}行 ${f.name || ''}: ${f.message}`).join('\n'),
|
||||
'导入结果', { type: 'warning' }
|
||||
)
|
||||
// 映射为 ImportResultDialog 期望的形状 { okNum, ngNum, ngList: [{rowNum, message}] }
|
||||
importResult.value = {
|
||||
okNum: ok,
|
||||
ngNum: failed.length,
|
||||
ngList: failed.map(f => ({ rowNum: f.rowNo, message: (f.name ? f.name + ': ' : '') + (f.message || '') }))
|
||||
}
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导入失败')
|
||||
@@ -248,6 +293,5 @@ watch(() => route.fullPath, () => {
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.filter-tip { color: #909399; font-size: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<!--
|
||||
共享服务机构下人员详情 (只读, admin/manager 共用)
|
||||
共享执行单位下人员详情 (只读, admin/manager 共用)
|
||||
通过 route.path 区分角色, 回跳路径不同
|
||||
1:1 抄 ExecutorPersonNew.vue 风格 (page-card + 面包屑 + form-card nested)
|
||||
区别: 所有控件禁用, 仅展示
|
||||
-->
|
||||
<div class="page-card executor-person-detail">
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">服务机构下人员</a> > 人员详情
|
||||
<a @click="goBack">执行单位下人员</a> > 人员详情
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" shadow="never" class="form-card nested">
|
||||
@@ -21,6 +21,9 @@
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属公司">
|
||||
<el-input v-model="form.orgName" />
|
||||
</el-form-item>
|
||||
@@ -31,7 +34,9 @@
|
||||
<el-input v-model="form.position" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<span style="color:#303133">会议执行</span>
|
||||
<el-tag :type="accountTypeRoleTagType(form.accountType)" disable-transitions>
|
||||
{{ accountTypeRoleLabel(form.unitType, form.accountType) || '执行人员' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-tag :type="form.status === '1' ? 'danger' : 'success'" disable-transitions>
|
||||
@@ -52,6 +57,7 @@ import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -67,10 +73,13 @@ const form = reactive({
|
||||
userName: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: '',
|
||||
status: '0',
|
||||
unitType: '',
|
||||
accountType: '',
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
@@ -90,13 +99,16 @@ async function loadDetail() {
|
||||
const d = res.data
|
||||
Object.assign(form, {
|
||||
personId: d.personId,
|
||||
userName: d.userName || '',
|
||||
userName: d.account || '',
|
||||
name: d.name || '',
|
||||
phone: d.phone || '',
|
||||
email: d.email || '',
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
status: d.status || '0',
|
||||
unitType: d.unitType || '',
|
||||
accountType: d.accountType || '',
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<!--
|
||||
共享服务机构下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
共享执行单位下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
结构对齐 expert/ExpertNew.vue
|
||||
unitType 硬编码 'executor', role 硬编码 'meetingExecutor'
|
||||
unitType 硬编码 'executor'
|
||||
-->
|
||||
<div class="page-card executor-person-new">
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">服务机构下人员</a> > {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
|
||||
<a @click="goBack">执行单位下人员</a> > {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
|
||||
@@ -20,6 +20,9 @@
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="手机号 (与用户名一致时可重复)" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="邮箱" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属公司" prop="orgName">
|
||||
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
|
||||
</el-form-item>
|
||||
@@ -29,9 +32,6 @@
|
||||
<el-form-item label="职务">
|
||||
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<span style="color:#303133">会议执行 <span style="color:#909399;font-size:12px">(执行方人员固定, 暂唯一子类型)</span></span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
@@ -69,6 +69,7 @@ const form = reactive({
|
||||
userName: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: ''
|
||||
@@ -81,6 +82,10 @@ const rules = {
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式错误', trigger: 'blur' }
|
||||
],
|
||||
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
@@ -97,9 +102,10 @@ async function loadDetail() {
|
||||
const d = res.data
|
||||
Object.assign(form, {
|
||||
personId: d.personId,
|
||||
userName: d.userName || '',
|
||||
userName: d.account || '',
|
||||
name: d.name || '',
|
||||
phone: d.phone || '',
|
||||
email: d.email || '',
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || ''
|
||||
@@ -128,7 +134,7 @@ async function onSave() {
|
||||
try { await formRef.value.validate() } catch { return }
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form, unitType: 'executor', role: 'meetingExecutor' }
|
||||
const payload = { ...form, unitType: 'executor' }
|
||||
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
|
||||
if (!isEdit && orgId.value) payload.orgId = orgId.value
|
||||
if (isEdit) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
|
||||
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
|
||||
|
||||
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
|
||||
@@ -27,7 +27,6 @@ const formRef = ref(null)
|
||||
|
||||
const rules = {
|
||||
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phonenumber: [{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
oldPassword: [{ required: false }],
|
||||
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
|
||||
confirmPassword: [{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="page-card executor-meetings">
|
||||
<div class="breadcrumb">首页 / 会议列表</div>
|
||||
<p class="page-sub">查看执行方参与的会议</p>
|
||||
|
||||
<!-- ========== 筛选区 (与 People.vue 风格一致) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
@@ -59,10 +58,11 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="warning" @click="onSupervision(row)">查看监督意见</el-button>
|
||||
<el-button link type="primary" @click="onUpload(row)">上传材料</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -71,17 +71,6 @@
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<!-- 监督意见 (只读: 由支持方/监察员在审批时填写, 执行方仅可查看) -->
|
||||
<el-dialog v-model="supOpen" title="监督意见" width="560px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="会议名称">{{ supRow.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ stageLabel('executor', supRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="监督意见">{{ supRow.supervisionOpinion || '暂无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="supOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -97,9 +86,6 @@ const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const selected = ref([])
|
||||
|
||||
const supOpen = ref(false)
|
||||
const supRow = ref({})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -128,11 +114,12 @@ function reset() {
|
||||
|
||||
function onSelect(arr) { selected.value = arr }
|
||||
|
||||
function onView(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
|
||||
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
|
||||
|
||||
function onSupervision(row) {
|
||||
supRow.value = row
|
||||
supOpen.value = true
|
||||
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
|
||||
|
||||
function onEdit(row) {
|
||||
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
|
||||
}
|
||||
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
@@ -142,7 +129,6 @@ onMounted(() => { readQueryFromRoute(); load() })
|
||||
/* 整页面板 (与 People.vue 风格一致) */
|
||||
.executor-meetings { padding: 16px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
</style>
|
||||
@@ -64,15 +64,6 @@
|
||||
<el-input v-model="form.position" placeholder="请输入职务" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色" prop="role">
|
||||
<!-- 统一 el-select + 管理员禁用 + 编辑模式置灰 (与 sponsor/NewPerson 一致) -->
|
||||
<el-select v-model="form.role" placeholder="请选择" style="width:100%" :disabled="isEdit">
|
||||
<el-option label="会议执行" value="meetingExecutor" />
|
||||
<el-option label="管理员" value="admin" :disabled="true" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
@@ -127,7 +118,6 @@ const form = reactive({
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: '',
|
||||
role: 'meetingExecutor',
|
||||
unitType: 'executor',
|
||||
status: '0',
|
||||
// 子账号登录信息 (后端会创建 sys_user SUB, parent_user_id=主账号, 字段名跟后端 @JsonProperty 对齐)
|
||||
@@ -146,7 +136,6 @@ const rules = {
|
||||
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }],
|
||||
department:[{ required: true, message: '请输入部门', trigger: 'blur' }],
|
||||
position: [{ required: true, message: '请输入职务', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
userName: [
|
||||
{ required: true, message: '请输入子账号登录用户名', trigger: 'blur' },
|
||||
@@ -196,7 +185,6 @@ async function loadDetail() {
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
role: d.role || '',
|
||||
unitType: d.unitType || 'executor',
|
||||
status: d.status != null ? String(d.status) : '0',
|
||||
})
|
||||
@@ -213,7 +201,7 @@ async function loadDetail() {
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
const hasContent = form.name || form.phone || form.orgName || form.department || form.position || form.role
|
||||
const hasContent = form.name || form.phone || form.orgName || form.department || form.position
|
||||
if (!isEdit.value && !hasContent) { goBack(); return }
|
||||
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
<el-table-column prop="orgName" label="所属公司" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" min-width="100" />
|
||||
<el-table-column prop="position" label="职务" min-width="100" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.role === 'meetingExecutor' ? 'warning' : 'info'">{{ translatePersonRole(row.role) }}</el-tag>
|
||||
<!-- 角色与账号类型合并: 由 account_type + unitType 推导 (MAIN=管理员, SUB=执行人员) -->
|
||||
<el-tag size="small" :type="accountTypeRoleTagType(row.accountType)">{{ accountTypeRoleLabel(row.unitType, row.accountType) || '—' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
@@ -46,7 +47,7 @@
|
||||
<el-button size="small" link type="primary" @click="goEdit(row)">编辑</el-button>
|
||||
<!-- 本人不显示禁用/恢复按钮 (跟 sponsor 一样, 避免主账号把自己禁用) -->
|
||||
<template v-if="row.userId !== store.user?.userId">
|
||||
<el-button v-if="(row.status || '正常') === '正常'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
|
||||
<el-button v-if="row.status === '0'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
|
||||
<el-button v-else size="small" link type="success" @click="onToggleStatus(row, '恢复')">恢复</el-button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -110,7 +111,7 @@ import { bizUpdate } from '@/api/public'
|
||||
import { listExecutorPerson } from '@/api/business/person'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { translatePersonRole } from '@/utils/roleMap'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色">
|
||||
<!-- 与 sponsor/PersonDetail 一致: el-tag 展示 -->
|
||||
<el-tag :type="form.role === 'admin' ? 'warning' : 'primary'" disable-transitions>
|
||||
{{ translatePersonRole(form.role) }}
|
||||
<!-- 角色与账号类型合并: 由 account_type + unitType 推导 (MAIN=管理员, SUB=执行人员) -->
|
||||
<el-tag :type="accountTypeRoleTagType(form.accountType)" disable-transitions>
|
||||
{{ accountTypeRoleLabel(form.unitType, form.accountType) || '—' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -70,7 +70,7 @@ import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { translatePersonRole } from '@/utils/roleMap'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -84,7 +84,8 @@ const form = reactive({
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: '',
|
||||
role: '',
|
||||
unitType: '',
|
||||
accountType: '',
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
@@ -99,7 +100,8 @@ async function loadDetail() {
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
role: d.role || '',
|
||||
unitType: d.unitType || '',
|
||||
accountType: d.accountType || '',
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
|
||||
@@ -195,10 +195,10 @@ function reset() {
|
||||
function viewDetail(row) { router.push(`/executor/projects/detail/${row.projectId}`) }
|
||||
|
||||
async function loadStaff() {
|
||||
// 执行方自己的执行人 (role=meetingExecutor), 走 listExecutorPerson (后端强制 unit_type='executor' + 当前主账号隔离)
|
||||
// 执行方自己的执行人员 (SUB 子账号), 走 listExecutorPerson (后端强制 unit_type='executor' + 当前主账号隔离)
|
||||
try {
|
||||
const res = await listExecutorPerson({ role: 'meetingExecutor', pageNum: 1, pageSize: 100 })
|
||||
allStaff.value = res.rows || []
|
||||
const res = await listExecutorPerson({ pageNum: 1, pageSize: 100 })
|
||||
allStaff.value = (res.rows || []).filter(p => p.accountType === 'SUB')
|
||||
} catch { allStaff.value = [] }
|
||||
}
|
||||
/** 拉项目当前已分配执行人 → 回显 staffUserIds (单选模式) */
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<!-- ========== 表格 ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column type="index" label="序号" width="60" align="center" fixed />
|
||||
<el-table-column prop="name" label="姓名" width="100" fixed />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
|
||||
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" maxlength="11" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" maxlength="11" /></el-form-item>
|
||||
|
||||
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
|
||||
@@ -27,7 +27,6 @@ const formRef = ref(null)
|
||||
|
||||
const rules = {
|
||||
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phonenumber: [{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
oldPassword: [{ required: false }],
|
||||
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
|
||||
confirmPassword: [{
|
||||
|
||||
@@ -200,9 +200,8 @@ async function loadAnnouncements() {
|
||||
// ===================== 已分配执行方 (服务公司) =====================
|
||||
/**
|
||||
* 数据源同 manager/projects/edit/{id} 的 loadAssigns:
|
||||
* 1) GET /business/project/{id}/assigns 拿原始分配行 (assignId, execUserId, sessions, amount)
|
||||
* 2) 对每个 execUserId 调 listExecutorOrgs({ userId }) 补 org_name
|
||||
* (后端 SQL 已限定 parent_user_id IS NULL, 自动取 MAIN 账号对应的执行公司)
|
||||
* 1) GET /business/project/{id}/assigns 拿原始分配行 (assignId, executionUnitId, sessions, amount)
|
||||
* 2) 对每个 executionUnitId 调 listExecutorOrgs({ orgId }) 补 org_name
|
||||
* N+1 但项目分配数一般 < 10, 简单可控; 真要优化再加聚合 JOIN 接口
|
||||
*/
|
||||
async function loadAssigns() {
|
||||
@@ -214,13 +213,13 @@ async function loadAssigns() {
|
||||
const enriched = await Promise.all(list.map(async (a) => {
|
||||
let orgName = '-'
|
||||
try {
|
||||
const opts = await listExecutorOrgs({ userId: a.execUserId })
|
||||
const opts = await listExecutorOrgs({ orgId: a.executionUnitId })
|
||||
const org = (opts && opts.data && opts.data[0]) || null
|
||||
if (org && org.orgName) orgName = org.orgName
|
||||
} catch (e) { /* 单条失败不影响其它, 默认显示 '-' */ }
|
||||
return {
|
||||
assignId: a.assignId,
|
||||
execUserId: a.execUserId,
|
||||
executionUnitId: a.executionUnitId,
|
||||
orgName,
|
||||
sessions: a.sessions || 0,
|
||||
amount: a.amount || 0
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
<div class="new-card-title">项目分配</div>
|
||||
|
||||
<el-form-item label="支持单位">
|
||||
<el-select v-model="assignForm.sponsorAdminUserId" placeholder="请选择支持单位" filterable
|
||||
<el-select v-model="assignForm.sponsorOrgId" placeholder="请选择支持单位" filterable
|
||||
:filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
|
||||
<el-option v-for="u in sponsorOrgOptions" :key="u.userId"
|
||||
:label="u.orgName" :value="u.userId" />
|
||||
<el-option v-for="u in sponsorOrgOptions" :key="u.orgId"
|
||||
:label="u.orgName" :value="u.orgId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -78,14 +78,14 @@
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column label="执行单位" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.execUserId" filterable
|
||||
<el-select v-model="row.executionUnitId" filterable
|
||||
:filter-method="q => searchExecutors(q, row)"
|
||||
:loading="row._loading"
|
||||
placeholder="搜索执行方用户"
|
||||
placeholder="搜索执行单位"
|
||||
style="width:100%"
|
||||
@change="v => onExecUserPick(row, v)">
|
||||
<el-option v-for="u in (row._options || [])" :key="u.userId"
|
||||
:label="u.orgName" :value="u.userId" />
|
||||
<el-option v-for="u in (row._options || [])" :key="u.orgId"
|
||||
:label="u.orgName" :value="u.orgId" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -115,8 +115,8 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 第 5 区块: 合同文件 (单条模式显示, 批量隐藏) -->
|
||||
<div v-if="!batchMode && currentProject" class="contract-section">
|
||||
<!-- 第 5 区块: 合同文件 (暂时隐藏; 原条件 v-if="!batchMode && currentProject") -->
|
||||
<div v-if="false" class="contract-section">
|
||||
<div class="new-card-title">合同文件</div>
|
||||
<div class="notice-row">
|
||||
<span class="notice-label">支持合同:</span>
|
||||
@@ -166,7 +166,7 @@ const currentProject = ref(null) // 单条模式下的项目 (用于顶部 info
|
||||
const assignBatchProjects = ref([]) // 批量模式下的 [{projectId, projectNo, projectName, totalSessions, totalAmount}]
|
||||
|
||||
const assignForm = reactive({
|
||||
sponsorAdminUserId: null,
|
||||
sponsorOrgId: null,
|
||||
sponsorAdminUserName: '',
|
||||
totalSessions: 0, // 单条模式的项目总场次 (来自项目本身)
|
||||
totalAmount: 0, // 单条模式的项目总金额
|
||||
@@ -220,12 +220,12 @@ const availableAmount = computed(() => {
|
||||
})
|
||||
|
||||
function makeEmptyExecRow() {
|
||||
return { _loading: false, _timer: null, _options: [], execUserId: null, sessions: 0, amount: 0, remark: '' }
|
||||
return { _loading: false, _timer: null, _options: [], executionUnitId: null, sessions: 0, amount: 0, remark: '' }
|
||||
}
|
||||
|
||||
function resetAssignForm() {
|
||||
Object.assign(assignForm, {
|
||||
sponsorAdminUserId: null,
|
||||
sponsorOrgId: null,
|
||||
sponsorAdminUserName: '',
|
||||
totalSessions: 0,
|
||||
totalAmount: 0,
|
||||
@@ -250,10 +250,10 @@ async function loadSponsorOrgs(query) {
|
||||
if (query) params.orgName = query
|
||||
const r = await listSponsorOrgs(params)
|
||||
sponsorOrgOptions.value = r.data || []
|
||||
// 已选项不在结果里时按 userId 单独拉取并加首位
|
||||
if (assignForm.sponsorAdminUserId && !sponsorOrgOptions.value.find(u => u.userId === assignForm.sponsorAdminUserId)) {
|
||||
const r2 = await listSponsorOrgs({ userId: assignForm.sponsorAdminUserId })
|
||||
const sel = (r2.data || []).find(u => u.userId === assignForm.sponsorAdminUserId)
|
||||
// 已选项不在结果里时按 orgId 单独拉取并加首位
|
||||
if (assignForm.sponsorOrgId && !sponsorOrgOptions.value.find(u => u.orgId === assignForm.sponsorOrgId)) {
|
||||
const r2 = await listSponsorOrgs({ orgId: assignForm.sponsorOrgId })
|
||||
const sel = (r2.data || []).find(u => u.orgId === assignForm.sponsorOrgId)
|
||||
if (sel) sponsorOrgOptions.value = [sel, ...sponsorOrgOptions.value]
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -263,9 +263,9 @@ async function loadSponsorOrgs(query) {
|
||||
}
|
||||
function searchSponsorOrgs(q) { loadSponsorOrgs(q) }
|
||||
|
||||
// 选中支持方后回填 user_name (缓存到 biz_project.sponsor_admin_user_name)
|
||||
function onSponsorOrgPick(userId) {
|
||||
const u = sponsorOrgOptions.value.find(x => x.userId === userId)
|
||||
// 选中支持方后回填 user_name (sponsorAdminUserName 后端已改派生, 此处仅缓存展示用)
|
||||
function onSponsorOrgPick(orgId) {
|
||||
const u = sponsorOrgOptions.value.find(x => x.orgId === orgId)
|
||||
if (u) assignForm.sponsorAdminUserName = u.userName
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ async function loadAssigns(projectId) {
|
||||
if (list.length) {
|
||||
assignForm.execRows = list.map(a => ({
|
||||
...makeEmptyExecRow(),
|
||||
execUserId: a.execUserId,
|
||||
executionUnitId: a.executionUnitId,
|
||||
sessions: a.sessions || 0,
|
||||
amount: Number(a.amount || 0),
|
||||
remark: a.remark || ''
|
||||
@@ -295,8 +295,8 @@ async function loadAssigns(projectId) {
|
||||
}
|
||||
|
||||
// 加载执行方 (按执行单位名 org_name 模糊匹配)
|
||||
// 关键: 数据源是 biz_org (org_type='executor') JOIN sys_user (MAIN 账号 only)
|
||||
// label=orgName (执行单位名称), value=MAIN user_id (直接写 biz_project_assign.exec_user_id)
|
||||
// 关键: 数据源是 biz_org (org_type='executor')
|
||||
// label=orgName (执行单位名称), value=org_id (直接写 biz_project_assign.execution_unit_id)
|
||||
async function searchExecutors(query, row) {
|
||||
if (row._timer) clearTimeout(row._timer)
|
||||
row._loading = true
|
||||
@@ -306,10 +306,10 @@ async function searchExecutors(query, row) {
|
||||
if (query) params.orgName = query
|
||||
const r = await listExecutorOrgs(params)
|
||||
row._options = r.data || []
|
||||
// 选中的项如果不在结果里, 按 userId 单独拉取并加到首位
|
||||
if (row.execUserId && !row._options.find(u => u.userId === row.execUserId)) {
|
||||
const r2 = await listExecutorOrgs({ userId: row.execUserId })
|
||||
const sel = (r2.data || []).find(u => u.userId === row.execUserId)
|
||||
// 选中的项如果不在结果里, 按 orgId 单独拉取并加到首位
|
||||
if (row.executionUnitId && !row._options.find(u => u.orgId === row.executionUnitId)) {
|
||||
const r2 = await listExecutorOrgs({ orgId: row.executionUnitId })
|
||||
const sel = (r2.data || []).find(u => u.orgId === row.executionUnitId)
|
||||
if (sel) row._options = [sel, ...row._options]
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -320,9 +320,9 @@ async function searchExecutors(query, row) {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onExecUserPick(row, userId) {
|
||||
function onExecUserPick(row, orgId) {
|
||||
// 仅清掉旧的非持久化字段, 业务字段由前端从 _options 实时取
|
||||
const u = (row._options || []).find(x => x.userId === userId)
|
||||
const u = (row._options || []).find(x => x.orgId === orgId)
|
||||
if (u) { /* 兼容旧 _options 残留, 不写业务字段 */ }
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ async function loadSingleProject() {
|
||||
const p = res?.data?.data || res?.data || res
|
||||
if (!p) return
|
||||
currentProject.value = p
|
||||
assignForm.sponsorAdminUserId = p.sponsorAdminUserId || null
|
||||
assignForm.sponsorOrgId = p.sponsorOrgId || null
|
||||
assignForm.sponsorAdminUserName = p.sponsorAdminUserName || ''
|
||||
assignForm.totalSessions = Number(p.totalSessions || 0)
|
||||
assignForm.totalAmount = Number(p.totalAmount || 0)
|
||||
@@ -366,7 +366,7 @@ async function loadBatchProjects() {
|
||||
projectId: p.projectId,
|
||||
projectNo: p.projectNo,
|
||||
projectName: p.projectName,
|
||||
sponsorAdminUserId: p.sponsorAdminUserId,
|
||||
sponsorOrgId: p.sponsorOrgId,
|
||||
sponsorAdminUserName: p.sponsorAdminUserName,
|
||||
totalSessions: Number(p.totalSessions || 0),
|
||||
totalAmount: Number(p.totalAmount || 0)
|
||||
@@ -388,7 +388,7 @@ async function loadBatchProjects() {
|
||||
}
|
||||
// 批量模式: 取第一个项目的支持方作为默认 (用户可改)
|
||||
if (list.length) {
|
||||
assignForm.sponsorAdminUserId = list[0].sponsorAdminUserId || null
|
||||
assignForm.sponsorOrgId = list[0].sponsorOrgId || null
|
||||
assignForm.sponsorAdminUserName = list[0].sponsorAdminUserName || ''
|
||||
}
|
||||
loadSponsorOrgs('')
|
||||
@@ -420,7 +420,7 @@ function confirmCancel() {
|
||||
|
||||
// ========== 保存 ==========
|
||||
async function submitAssign() {
|
||||
const valid = assignForm.execRows.filter(r => r.execUserId && Number(r.sessions) > 0)
|
||||
const valid = assignForm.execRows.filter(r => r.executionUnitId && Number(r.sessions) > 0)
|
||||
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
|
||||
const amount = Math.round(valid.reduce((s, r) => s + Number(r.amount || 0), 0) * 100) / 100
|
||||
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
|
||||
@@ -449,7 +449,7 @@ async function submitAssign() {
|
||||
}
|
||||
|
||||
const assignBody = valid.map(r => ({
|
||||
execUserId: r.execUserId,
|
||||
executionUnitId: r.executionUnitId,
|
||||
sessions: r.sessions,
|
||||
amount: r.amount,
|
||||
remark: r.remark
|
||||
@@ -462,7 +462,7 @@ async function submitAssign() {
|
||||
try {
|
||||
await bizUpdate('project', {
|
||||
projectId: p.projectId,
|
||||
sponsorAdminUserId: assignForm.sponsorAdminUserId,
|
||||
sponsorOrgId: assignForm.sponsorOrgId,
|
||||
sponsorAdminUserName: assignForm.sponsorAdminUserName,
|
||||
submitDeadlineDays: assignForm.deadlineDays,
|
||||
})
|
||||
@@ -481,7 +481,7 @@ async function submitAssign() {
|
||||
} else {
|
||||
await bizUpdate('project', {
|
||||
projectId: singleProjectId,
|
||||
sponsorAdminUserId: assignForm.sponsorAdminUserId,
|
||||
sponsorOrgId: assignForm.sponsorOrgId,
|
||||
sponsorAdminUserName: assignForm.sponsorAdminUserName,
|
||||
submitDeadlineDays: assignForm.deadlineDays,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div v-loading="loadingDetail" class="page-card manager-plan-edit">
|
||||
<div class="breadcrumb">
|
||||
首页 / 策划方案管理 / <span class="current">修改策划方案</span>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" class="plan-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目规划">
|
||||
<el-select v-model="form.planDirectionId" placeholder="请选择" clearable style="width:100%">
|
||||
<el-option v-for="opt in planDirectionOptions" :key="opt.id" :label="opt.title" :value="opt.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="投稿名称" prop="planName">
|
||||
<el-input v-model="form.planName" placeholder="请输入投稿名称" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="学科方向">
|
||||
<el-input v-model="form.subjectDirection" placeholder="请输入学科方向" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="形式">
|
||||
<el-select v-model="form.projectForm" placeholder="请选择" clearable 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-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目类别">
|
||||
<dict-select v-model="form.planCategory" dict-type="biz_project_category" value-field="label" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="form.projectNo" placeholder="请输入项目编号" disabled />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="设计文件" prop="designFileUrl">
|
||||
<OssFileUploader
|
||||
v-model="form.designFileUrl"
|
||||
:dir="`ry8080/plan/${planId}/design/`"
|
||||
placeholder="点击上传设计文件"
|
||||
hint="支持 PDF / 图片, 单文件 ≤ 20MB"
|
||||
:max-size="20"
|
||||
block
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" placeholder="请输入备注" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button @click="confirmCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { bizGet, bizUpdate } from '@/api/public'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import OssFileUploader from '@/components/OssFileUploader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const planId = route.params.planId || null
|
||||
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
|
||||
// 项目方向 options (与 Plans.vue 同一接口)
|
||||
const planDirectionOptions = ref([])
|
||||
async function loadPlanDirectionOptions() {
|
||||
try {
|
||||
const { data } = await request.get('/business/specialPlan/options')
|
||||
planDirectionOptions.value = (data && data.data) || data || []
|
||||
} catch { planDirectionOptions.value = [] }
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
planId: null,
|
||||
planName: '',
|
||||
projectNo: '',
|
||||
planDirectionId: null,
|
||||
planCategory: '',
|
||||
projectForm: '',
|
||||
subjectDirection: '',
|
||||
designFileUrl: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
planName: [{ required: true, message: '请输入投稿名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!planId) return
|
||||
loadingDetail.value = true
|
||||
try {
|
||||
const res = await bizGet('projectPlan', planId)
|
||||
const d = res?.data || res
|
||||
if (!d) { ElMessage.error('加载失败'); goBack(); return }
|
||||
form.planId = d.planId
|
||||
form.planName = d.planName || ''
|
||||
form.projectNo = d.projectNo || ''
|
||||
form.planDirectionId = d.planDirectionId ?? null
|
||||
form.planCategory = d.planCategory || ''
|
||||
form.projectForm = d.projectForm || ''
|
||||
form.subjectDirection = d.subjectDirection || ''
|
||||
form.designFileUrl = d.designFileUrl || ''
|
||||
form.remark = d.remark || ''
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
goBack()
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// 带 _t 强制 Plans.vue 触发 reload, 即便路由一样也重载
|
||||
router.push({ path: '/manager/plans', query: { _t: Date.now() } })
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
ElMessageBox.confirm('确定取消修改?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (e) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await bizUpdate('projectPlan', { ...form }, { __silentError: true })
|
||||
ElMessage.success('保存成功')
|
||||
goBack()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPlanDirectionOptions()
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 整页面板 (与 ProjectsNew.vue 风格一致) */
|
||||
.manager-plan-edit {
|
||||
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; }
|
||||
|
||||
/* 底部按钮 (用 el-form-item 包裹, 不另起 div.form-actions) */
|
||||
.form-actions {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page-card manager-plans">
|
||||
<div class="breadcrumb">首页 / 项目策划方案</div>
|
||||
<div class="breadcrumb">首页 / 策划方案管理</div>
|
||||
|
||||
<!-- ========== 筛选区 (与 People.vue 风格一致) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
<!-- ========== 批量按钮 (与 People.vue 风格一致) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" :disabled="!selection.length" @click="openBatchAudit">批量审核</el-button>
|
||||
<el-button type="primary" :disabled="!selection.length" @click="batchSettle">批量结算</el-button>
|
||||
<span v-if="selection.length" class="filter-tip">已选 {{ selection.length }} 条</span>
|
||||
</div>
|
||||
@@ -70,7 +71,7 @@
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="goEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onSettle(row)" v-if="row.isSettled!=='Y'">结算</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -104,32 +105,27 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑 dialog -->
|
||||
<el-dialog v-model="editOpen" title="修改策划方案" width="640px" @closed="resetEditForm">
|
||||
<el-form :model="editForm" label-width="120px" :rules="editRules" ref="editFormRef">
|
||||
<el-form-item label="方案名称" prop="planName"><el-input v-model="editForm.planName" /></el-form-item>
|
||||
<el-form-item label="项目编号" prop="projectNo"><el-input v-model="editForm.projectNo" /></el-form-item>
|
||||
<el-form-item label="项目方向">
|
||||
<el-select v-model="editForm.planDirectionId" placeholder="请选择" style="width:100%">
|
||||
<el-option v-for="opt in planDirectionOptions" :key="opt.id" :label="opt.title" :value="opt.id" />
|
||||
<!-- 批量审核 dialog: 审核结果(通过/退回 radio) + 项目编号(仅通过时必填) + 审核意见(可选), 批量应用于所有选中方案 -->
|
||||
<el-dialog v-model="batchAuditOpen" title="批量审核策划方案" width="500px">
|
||||
<el-form :model="batchAuditForm" :rules="batchAuditRules" ref="batchAuditFormRef" label-width="100px">
|
||||
<el-form-item label="审核结果" prop="result">
|
||||
<el-radio-group v-model="batchAuditForm.result">
|
||||
<el-radio value="2">通过</el-radio>
|
||||
<el-radio value="3">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="batchAuditForm.result === '2'" label="项目编号" prop="projectNo">
|
||||
<el-select v-model="batchAuditForm.projectNo" placeholder="请选择项目编号" filterable style="width:100%">
|
||||
<el-option v-for="opt in projectNoOptions" :key="opt.projectNo" :label="opt.projectNo + (opt.projectName ? ' / ' + opt.projectName : '')" :value="opt.projectNo" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目类别">
|
||||
<dict-select v-model="editForm.planCategory" dict-type="biz_project_category" value-field="label" style="width:100%" />
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="batchAuditForm.opinion" type="textarea" rows="3" placeholder="请填写审核意见 (可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="editForm.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-form-item label="设计方案">
|
||||
<el-input v-model="editForm.designFileUrl" placeholder="文件 URL(暂以文本输入)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
||||
<el-button @click="batchAuditOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitBatchAudit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -147,6 +143,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -171,6 +168,7 @@ async function loadProjectNoOptions() {
|
||||
} catch { projectNoOptions.value = [] }
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const q = ref({ planName: '', projectNo: '', planDirectionId: '', planCategory: '', status: '', isSettled: '', remark: '' })
|
||||
const rows = ref([]); const selection = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -249,31 +247,56 @@ async function submitAudit() {
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const editOpen = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({ planId:'', planName:'', projectNo:'', planDirectionId:'', planCategory:'', projectForm:'', designFileUrl:'', remark:'' })
|
||||
const editRules = { planName:[{required:true,message:'请输入方案名称',trigger:'blur'}] }
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
planId: row.planId, planName: row.planName, projectNo: row.projectNo,
|
||||
planDirectionId: row.planDirectionId ?? null, planCategory: row.planCategory,
|
||||
projectForm: row.projectForm, designFileUrl: row.designFileUrl || '', remark: row.remark || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
// 批量审核: 与单独审核字段一致 (审核结果/项目编号/审核意见), 批量应用于所有选中方案
|
||||
const batchAuditOpen = ref(false)
|
||||
const batchAuditFormRef = ref(null)
|
||||
const batchAuditForm = reactive({ result: '2', projectNo: '', opinion: '' })
|
||||
const batchAuditRules = reactive({
|
||||
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
|
||||
projectNo: [{ required: false }]
|
||||
})
|
||||
// 通过时: projectNo 必填; 退回时: projectNo 非必填且隐藏 (同时清掉校验状态)
|
||||
watch(() => batchAuditForm.result, (val) => {
|
||||
if (val === '2') {
|
||||
batchAuditRules.projectNo = [{ required: true, message: '请填写项目编号', trigger: 'blur' }]
|
||||
} else {
|
||||
batchAuditRules.projectNo = [{ required: false }]
|
||||
batchAuditFormRef.value?.clearValidate(['projectNo'])
|
||||
}
|
||||
})
|
||||
function openBatchAudit() {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选行')
|
||||
batchAuditForm.result = '2'
|
||||
batchAuditForm.projectNo = ''
|
||||
batchAuditForm.opinion = ''
|
||||
loadProjectNoOptions()
|
||||
batchAuditOpen.value = true
|
||||
}
|
||||
function resetEditForm() { Object.assign(editForm, { planId:'', planName:'', projectNo:'', planDirectionId:'', planCategory:'', projectForm:'', designFileUrl:'', remark:'' }) }
|
||||
async function submitEdit() {
|
||||
await editFormRef.value.validate()
|
||||
async function submitBatchAudit() {
|
||||
try {
|
||||
await batchAuditFormRef.value.validate()
|
||||
submitting.value = true
|
||||
await bizUpdate('projectPlan', { ...editForm })
|
||||
ElMessage.success('已保存')
|
||||
editOpen.value = false
|
||||
const isPass = batchAuditForm.result === '2'
|
||||
const payload = { status: batchAuditForm.result, auditOpinion: batchAuditForm.opinion }
|
||||
if (isPass) payload.projectNo = batchAuditForm.projectNo
|
||||
let ok = 0
|
||||
for (const r of selection.value) {
|
||||
try { await bizUpdate('projectPlan', { ...payload, planId: r.planId }); ok++ } catch {}
|
||||
}
|
||||
submitting.value = false
|
||||
ElMessage.success(`批量审核 ${ok}/${selection.value.length} 条`)
|
||||
batchAuditOpen.value = false
|
||||
selection.value = []
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
} catch (e) { if (e?.message) ElMessage.error(e.message) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
// 修改: 跳转到独立页面 PlanEdit.vue
|
||||
function goEdit(row) {
|
||||
router.push({ path: `/manager/plans/edit/${row.planId}` })
|
||||
}
|
||||
|
||||
const settleOpen = ref(false)
|
||||
const settleForm = reactive({ planId:'', planName:'' })
|
||||
function onSettle(row) { settleForm.planId = row.planId; settleForm.planName = row.planName; settleOpen.value = true }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="支持单位:">
|
||||
<el-select
|
||||
v-model="q.sponsorAdminUserIds"
|
||||
v-model="q.sponsorOrgIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
@@ -17,12 +17,12 @@
|
||||
placeholder="输入支持单位名称搜索"
|
||||
style="width:260px"
|
||||
>
|
||||
<el-option v-for="o in sponsorOrgOptions" :key="o.userId" :label="o.orgName" :value="o.userId" />
|
||||
<el-option v-for="o in sponsorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务机构:">
|
||||
<el-select
|
||||
v-model="q.execAdminUserIds"
|
||||
v-model="q.execOrgIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
@@ -34,7 +34,7 @@
|
||||
placeholder="输入服务机构名称搜索"
|
||||
style="width:260px"
|
||||
>
|
||||
<el-option v-for="o in executorOrgOptions" :key="o.userId" :label="o.orgName" :value="o.userId" />
|
||||
<el-option v-for="o in executorOrgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||||
@@ -60,7 +60,6 @@
|
||||
<el-option label="已结算" value="Y" /><el-option label="未结算" value="N" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价得分:"><el-input v-model="q.managerScore" placeholder="输入评分" clearable /></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>
|
||||
|
||||
@@ -119,22 +118,22 @@
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="op-cell">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="doEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="doCreateMeeting(row)">建会</el-button>
|
||||
<el-button link type="primary" @click="doAssign(row)">分配</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onAction(cmd, row)">
|
||||
<el-button link type="primary" class="op-dropdown">
|
||||
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="createMeeting">建会</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
|
||||
<el-dropdown-item command="assign">项目分配</el-dropdown-item>
|
||||
<el-dropdown-item command="view">查看</el-dropdown-item>
|
||||
<el-dropdown-item command="edit">编辑</el-dropdown-item>
|
||||
<el-dropdown-item command="activate">开通</el-dropdown-item>
|
||||
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
|
||||
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
|
||||
<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-item command="exportExperts">导出报名专家</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -180,7 +179,7 @@
|
||||
<!-- 开通专用字段 -->
|
||||
<template v-else-if="batchKind==='activate'">
|
||||
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
|
||||
<el-form-item label="开通截止时间"><el-date-picker v-model="activateDeadline" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="开通截止时间"><el-date-picker v-model="activateDeadline" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="batchOpen=false">取消</el-button>
|
||||
@@ -196,8 +195,11 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 单行开通 modal ============= -->
|
||||
<el-dialog v-model="activateModalOpen" title="您确定要开通吗?" width="420px" :show-close="false">
|
||||
<!-- ============= 单行开通 modal (置 open_status=Y + 开通截止时间 open_deadline) ============= -->
|
||||
<el-dialog v-model="activateModalOpen" title="开通项目" width="420px" :show-close="false">
|
||||
<el-form-item label="开通截止时间">
|
||||
<el-date-picker v-model="activateDeadlineSingle" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmActivate">确定</el-button>
|
||||
<el-button @click="activateModalOpen=false">取消</el-button>
|
||||
@@ -248,7 +250,7 @@ const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const q = ref({ sponsorAdminUserIds: [], execAdminUserIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', managerScore: '' })
|
||||
const q = ref({ sponsorOrgIds: [], execOrgIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
@@ -266,19 +268,19 @@ async function load() {
|
||||
}
|
||||
// 操作列按钮 handlers
|
||||
function onAction(cmd, row) {
|
||||
if (cmd === 'createMeeting') doCreateMeeting(row)
|
||||
else if (cmd === 'close') openClose(row)
|
||||
else if (cmd === 'assign') doAssign(row)
|
||||
else if (cmd === 'rate') openSingleScore(row)
|
||||
else if (cmd === 'exportExperts') exportExperts(row)
|
||||
if (cmd === 'close') openClose(row)
|
||||
else if (cmd === 'view') viewDetail(row)
|
||||
else if (cmd === 'edit') doEdit(row)
|
||||
else if (cmd === 'activate') openActivate(row)
|
||||
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
|
||||
else if (cmd === 'rate') openSingleScore(row)
|
||||
else if (cmd === 'exportExperts') exportExperts(row)
|
||||
else if (cmd === 'delete') doDelete(row)
|
||||
}
|
||||
// 仅 admin / manager 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||||
// 仅 admin 可见删除按钮 (前端 v-if, 后端 controller 再兜底 role 校验)
|
||||
const canDelete = computed(() => {
|
||||
const r = userStore.role
|
||||
return r === 'admin' || r === 'manager'
|
||||
return r === 'admin'
|
||||
})
|
||||
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
|
||||
function doAssign(row) {
|
||||
@@ -288,7 +290,7 @@ 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
|
||||
// 后端 BizProjectController.remove 已强制仅 admin, 前端只做可见性 v-if
|
||||
async function doDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -335,7 +337,7 @@ async function onBatchDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { q.value = { sponsorAdminUserIds:[], execAdminUserIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', managerScore:'' }; page.pageNum = 1; load() }
|
||||
function reset() { q.value = { sponsorOrgIds:[], execOrgIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'' }; page.pageNum = 1; load() }
|
||||
|
||||
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=1 / ?isSettled=Y 等, 让列表页自动应用筛选)
|
||||
// 注意: 只读不改 URL — 列表页内 reset()/search 不反向写 URL, Workbench 是 source of truth
|
||||
@@ -393,11 +395,13 @@ function confirmClose() {
|
||||
|
||||
function openActivate(row) {
|
||||
activateTargetRow.value = row
|
||||
activateDeadlineSingle.value = row.openDeadline || ''
|
||||
activateModalOpen.value = true
|
||||
}
|
||||
function confirmActivate() {
|
||||
if (!activateTargetRow.value) return
|
||||
bizUpdate('project', { projectId: activateTargetRow.value.projectId, isFinished: '0' })
|
||||
if (!activateDeadlineSingle.value) return ElMessage.warning('请选择开通截止时间')
|
||||
bizUpdate('project', { projectId: activateTargetRow.value.projectId, openStatus: 'Y', openDeadline: activateDeadlineSingle.value })
|
||||
.then(() => { ElMessage.success('已开通'); activateModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('开通失败'))
|
||||
}
|
||||
@@ -408,15 +412,8 @@ function openDeleteAnnouncement(row) {
|
||||
}
|
||||
function confirmDeleteAnn() {
|
||||
if (!delAnnTargetRow.value) return
|
||||
// 清空全部公示 URL + 取消发布状态
|
||||
// (mapper.updateByPrimaryKey 的 trim prefix="SET" 必须有字段, 不能只传空字符串)
|
||||
bizUpdate('project', {
|
||||
projectId: delAnnTargetRow.value.projectId,
|
||||
publishUrl: '',
|
||||
invitationUrl: '',
|
||||
supportLetterUrl: '',
|
||||
isPublished: '0'
|
||||
})
|
||||
// 删除公告: 后端专用端点, 将 3 个公示 URL 置 NULL (未发布由 URL 为空判定, 无独立 is_published 列)
|
||||
request.delete(`/business/project/${delAnnTargetRow.value.projectId}/announcement`)
|
||||
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('删除失败'))
|
||||
}
|
||||
@@ -500,6 +497,7 @@ const batchSubmitting = ref(false)
|
||||
const batchMap = { score: '批量评分', close: '批量结题', activate: '批量开通' }
|
||||
const batchScoreForm = reactive({ qualityScore: 5, responseScore: 5, cooperationScore: 5, complianceScore: 5 }) // 批量评分: 共享一份 4 维度
|
||||
const activateDeadline = ref('')
|
||||
const activateDeadlineSingle = ref('')
|
||||
|
||||
// 5 个单行 modal 状态
|
||||
const closeModalOpen = ref(false)
|
||||
@@ -564,7 +562,7 @@ async function confirmBatch() {
|
||||
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
|
||||
try {
|
||||
batchSubmitting.value = true
|
||||
await Promise.all(selection.value.map(r => bizUpdate('project', { projectId: r.projectId, endTime: activateDeadline.value })))
|
||||
await Promise.all(selection.value.map(r => bizUpdate('project', { projectId: r.projectId, openStatus: 'Y', openDeadline: activateDeadline.value })))
|
||||
ElMessage.success(`已批量开通 ${selection.value.length} 个项目`)
|
||||
batchOpen.value = false
|
||||
selection.value = []
|
||||
@@ -632,12 +630,12 @@ function searchSponsorOrgsForFilter(query) {
|
||||
if (query) params.orgName = query
|
||||
const r = await listSponsorOrgs(params)
|
||||
sponsorOrgOptions.value = r.data || []
|
||||
// 已选项不在结果里时按 userId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||||
const ids = q.value.sponsorAdminUserIds || []
|
||||
for (const uid of ids) {
|
||||
if (!sponsorOrgOptions.value.find(u => u.userId === uid)) {
|
||||
const r2 = await listSponsorOrgs({ userId: uid })
|
||||
const sel = (r2.data || []).find(u => u.userId === uid)
|
||||
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||||
const ids = q.value.sponsorOrgIds || []
|
||||
for (const id of ids) {
|
||||
if (!sponsorOrgOptions.value.find(u => u.orgId === id)) {
|
||||
const r2 = await listSponsorOrgs({ orgId: id })
|
||||
const sel = (r2.data || []).find(u => u.orgId === id)
|
||||
if (sel) sponsorOrgOptions.value = [sel, ...sponsorOrgOptions.value]
|
||||
}
|
||||
}
|
||||
@@ -659,12 +657,12 @@ function searchExecutorOrgsForFilter(query) {
|
||||
if (query) params.orgName = query
|
||||
const r = await listExecutorOrgs(params)
|
||||
executorOrgOptions.value = r.data || []
|
||||
// 已选项不在结果里时按 userId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||||
const ids = q.value.execAdminUserIds || []
|
||||
for (const uid of ids) {
|
||||
if (!executorOrgOptions.value.find(u => u.userId === uid)) {
|
||||
const r2 = await listExecutorOrgs({ userId: uid })
|
||||
const sel = (r2.data || []).find(u => u.userId === uid)
|
||||
// 已选项不在结果里时按 orgId 单独拉回 (multi-select 必须保证每个已选 tag 都有 label)
|
||||
const ids = q.value.execOrgIds || []
|
||||
for (const id of ids) {
|
||||
if (!executorOrgOptions.value.find(u => u.orgId === id)) {
|
||||
const r2 = await listExecutorOrgs({ orgId: id })
|
||||
const sel = (r2.data || []).find(u => u.orgId === id)
|
||||
if (sel) executorOrgOptions.value = [sel, ...executorOrgOptions.value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +82,6 @@
|
||||
|
||||
<!-- ========== 第二区块:角色劳务设置 ========== -->
|
||||
<div class="new-card-title">角色劳务设置</div>
|
||||
<div class="hint-text">
|
||||
<p>*角色劳务设置针对该项目所有会议生效</p>
|
||||
<p>*专家劳务明细当会议劳务金额超过角色劳务设置时,该劳务金额为税后金额,则系统进行提示</p>
|
||||
</div>
|
||||
<div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
|
||||
<el-select v-model="r.role" placeholder="请选择" style="width:160px" @change="onRoleSelectChange(r)">
|
||||
<el-option label="主席" value="主席" />
|
||||
@@ -98,11 +94,10 @@
|
||||
<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-button link type="primary" @click="addRoleRow">增加角色</el-button>
|
||||
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除角色</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">*当选择其他时,需手动填写角色名称,显示填写框</p>
|
||||
<el-button link type="primary" @click="addRoleRow" style="margin-top: 8px;">+ 增加角色</el-button>
|
||||
|
||||
<!-- ========== 第三区块:公告文件 ========== -->
|
||||
<div class="new-card-title">公告文件</div>
|
||||
@@ -245,11 +240,12 @@ async function loadProject() {
|
||||
if (m) { n.url = m.url || ''; n.name = m.name || '' }
|
||||
}
|
||||
} else {
|
||||
// 兼容老数据 (DB 实测只剩 invitationUrl/supportLetterUrl/publishUrl 3 个独立列)
|
||||
// 兼容老数据 (独立列: invitationUrl/supportLetterUrl/publishUrl/scheduleUrl)
|
||||
const noticeMap = {
|
||||
invitation: p.invitationUrl,
|
||||
supportLetter: p.supportLetterUrl,
|
||||
publish: p.publishUrl
|
||||
notice: p.publishUrl,
|
||||
schedule: p.scheduleUrl
|
||||
}
|
||||
for (const n of form.notices) {
|
||||
n.url = noticeMap[n.key] || ''
|
||||
@@ -332,6 +328,8 @@ async function submit(mode = 'save') {
|
||||
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') {
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<!-- 页标题 -->
|
||||
<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">
|
||||
@@ -29,30 +27,31 @@
|
||||
<div class="info-row"><span class="info-label">会议结束时间:</span><span class="info-value">{{ fmtDateTime(row.endTime) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">材料审核状态:</span><span class="info-value">{{ fmtAuditStage(row.materialAuditStage) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">凭证审核状态:</span><span class="info-value">{{ fmtAuditStage(row.voucherAuditStage) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.meetingFee) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
|
||||
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中…</el-tag>
|
||||
</div>
|
||||
<!-- 监察员 + 执行人员 (各占整行, 跨 2 列) -->
|
||||
<!-- 监察员 + 执行人员 (各占整行, 跨 2 列). 临时: manager 详情页隐掉 -->
|
||||
<template v-if="!isManager">
|
||||
<div class="info-row info-row-full">
|
||||
<span class="info-label">监察员:</span>
|
||||
<div class="tag-list">
|
||||
<el-tag v-for="u in supervisors" :key="u.userId" type="info" size="small">{{ u.userName }}</el-tag>
|
||||
<el-tag v-for="u in supervisors" :key="u.sponsorOrgId" type="info" size="small">{{ u.orgName }}</el-tag>
|
||||
<span v-if="!supervisors.length" class="text-muted">- 未分配 -</span>
|
||||
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
|
||||
<el-button v-if="isManager && !isReadonly" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row info-row-full">
|
||||
<span class="info-label">执行人员:</span>
|
||||
<div class="tag-list">
|
||||
<el-tag v-for="u in executors" :key="u.userId" type="success" size="small">{{ u.userName }}</el-tag>
|
||||
<el-tag v-for="u in executors" :key="u.executorOrgId" type="success" size="small">{{ u.orgName }}</el-tag>
|
||||
<span v-if="!executors.length" class="text-muted">- 未分配 -</span>
|
||||
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
|
||||
<el-button v-if="isManager && !isReadonly" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,13 +62,12 @@
|
||||
<el-tab-pane label="劳务材料" name="labor">
|
||||
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) -->
|
||||
<div class="attendee-section">
|
||||
<div v-if="!isSponsor" class="attendee-toolbar">
|
||||
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<el-button size="small" @click="openAttendeeImport">批量导入</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
|
||||
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
|
||||
<span class="toolbar-hint">勾选参会人后点"邀请参会"发站内信 / 点"推送电子签"发短信+站内信; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
|
||||
</div>
|
||||
<!--
|
||||
参会人表有 13 列, min-width 累加 ~1680px, 直接 el-table width:100% 会撑爆 left-col
|
||||
@@ -79,7 +77,8 @@
|
||||
-->
|
||||
<div class="attendee-table-wrap">
|
||||
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange">
|
||||
<el-table-column v-if="!isSponsor" type="selection" width="42" />
|
||||
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
|
||||
<el-table-column type="index" label="序号" width="50" align="center" />
|
||||
<el-table-column prop="name" label="医生" min-width="90" />
|
||||
<el-table-column label="联系电话" width="120">
|
||||
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
|
||||
@@ -148,7 +147,7 @@
|
||||
<el-tag v-else type="info" size="small">未推送</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isSponsor" label="操作" width="100" align="center" fixed="right">
|
||||
<el-table-column v-if="!isSponsor && !isReadonly" label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
|
||||
@@ -175,19 +174,45 @@
|
||||
</div>
|
||||
|
||||
<div class="file-list" style="margin-top: 16px;">
|
||||
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
|
||||
<div v-if="!isSponsor && !isReadonly" class="file-row">
|
||||
<span class="file-label">劳务协议:</span>
|
||||
<div class="agreement-actions">
|
||||
<el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button>
|
||||
<el-button size="small" type="success" :loading="agreementSubmitting" @click="onPickAgreementZip">上传协议</el-button>
|
||||
<input ref="agreementZipInput" type="file" accept=".zip" style="display:none" @change="onAgreementZipChange" />
|
||||
</div>
|
||||
<div class="file-hint">下载空目录模板 (劳务协议/序号_姓名/), 放入各参会人协议后压缩上传回填</div>
|
||||
</div>
|
||||
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor" class="file-uploader" />
|
||||
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
|
||||
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
<!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 -->
|
||||
<div v-if="!isSponsor && !isReadonly" class="file-row">
|
||||
<span class="file-label">专家照片:</span>
|
||||
<div class="agreement-actions">
|
||||
<el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button>
|
||||
<el-button size="small" type="success" :loading="expertPhotoSubmitting" @click="onPickExpertPhotoZip">上传照片</el-button>
|
||||
<input ref="expertPhotoZipInput" type="file" accept=".zip" style="display:none" @change="onExpertPhotoZipChange" />
|
||||
</div>
|
||||
<div class="file-hint">下载空目录模板 (专家照片/序号_姓名/), 放入各参会人照片后压缩上传回填</div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<span class="file-label">日程海报:</span>
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor" />
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议日程海报</div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<span class="file-label">邀请函:</span>
|
||||
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议邀请函</div>
|
||||
</div>
|
||||
<!-- 临时: manager 详情页隐掉海报生成 (含 生成海报/预览海报) -->
|
||||
<div v-if="!isManager && !isReadonly" class="file-row">
|
||||
<span class="file-label">海报生成:</span>
|
||||
<div class="poster-actions">
|
||||
<el-button v-if="!isSponsor" size="small" type="primary" :loading="genPosterLoading" @click="onGeneratePoster">生成海报</el-button>
|
||||
@@ -200,7 +225,7 @@
|
||||
<div class="file-list">
|
||||
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
@@ -209,51 +234,53 @@
|
||||
<span class="fee-value">¥ {{ meetingFee.total.toFixed(2) }}</span>
|
||||
<el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag>
|
||||
</div>
|
||||
<!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) -->
|
||||
<div v-if="!isSponsor && !isReadonly" class="file-row">
|
||||
<span class="file-label">打包上传:</span>
|
||||
<div class="agreement-actions">
|
||||
<el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button>
|
||||
<el-button size="small" type="success" :loading="serviceSubmitting" @click="onPickServiceZip">上传会务材料</el-button>
|
||||
<input ref="serviceZipInput" type="file" accept=".zip" style="display:none" @change="onServiceZipChange" />
|
||||
</div>
|
||||
<div class="file-hint">下载空目录模板 (会务材料/物料制作·酒店·大交通·小交通·执行费·设计费·其他·总结算单·总发票/), 放入材料文件后压缩上传回填; 单个目录多文件会自动打包为一个 zip</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="劳务凭证" name="laborVoucher">
|
||||
<el-tab-pane v-if="canSeeVouchers" label="劳务凭证" name="laborVoucher">
|
||||
<div class="file-list">
|
||||
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="会务凭证" name="serviceVoucher">
|
||||
<el-tab-pane v-if="canSeeVouchers" label="会务凭证" name="serviceVoucher">
|
||||
<div class="file-list">
|
||||
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
|
||||
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="tab-actions">
|
||||
<el-button v-if="!isSponsor" @click="onPackUpload">打包上传</el-button>
|
||||
<a class="file-link inline-link" href="javascript:void(0)" @click="onDownloadTemplate">会务材料文件夹模板下载</a>
|
||||
<span style="flex:1"></span>
|
||||
<!-- 执行人员: 提交材料 / 提交凭证 -->
|
||||
<el-button v-if="canSubmitMaterial" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
|
||||
<el-button v-if="canSubmitVoucher" type="warning" :loading="busy.submitVoucher" @click="onSubmitVoucher">提交凭证</el-button>
|
||||
<!-- 合规 / 监察审核 -->
|
||||
<el-button v-if="canComplianceAudit('MATERIAL')" type="success" @click="openAuditDialog('COMPLIANCE','MATERIAL')">合规审核 (材料)</el-button>
|
||||
<el-button v-if="canComplianceAudit('VOUCHER')" type="success" @click="openAuditDialog('COMPLIANCE','VOUCHER')">合规审核 (凭证)</el-button>
|
||||
<el-button v-if="canSupervisionAudit('MATERIAL')" type="primary" @click="openAuditDialog('SUPERVISION','MATERIAL')">监察审核 (材料)</el-button>
|
||||
<el-button v-if="canSupervisionAudit('VOUCHER')" type="primary" @click="openAuditDialog('SUPERVISION','VOUCHER')">监察审核 (凭证)</el-button>
|
||||
<!-- 执行人员: 提交材料 -->
|
||||
<el-button v-if="canSubmitMaterial && !isReadonly" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
|
||||
<!-- 合规 / 监察审核 (材料) -->
|
||||
<el-button v-if="canComplianceAudit() && !isReadonly" type="success" @click="openAuditDialog('COMPLIANCE')">合规审核 (材料)</el-button>
|
||||
<el-button v-if="canSupervisionAudit() && !isReadonly" type="primary" @click="openAuditDialog('SUPERVISION')">监察审核 (材料)</el-button>
|
||||
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
|
||||
<el-button v-if="canSettle" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
|
||||
<el-button v-if="canFinish" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
|
||||
<el-button v-if="!isSponsor" type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
|
||||
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
|
||||
<el-button v-if="!isSponsor && !isReadonly" type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</div>
|
||||
<p class="hint-text">*结算后, 已完结状态, 执行方不能再进行编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右列: 材料/凭证 双时间轴 (时间轴 + 审核轨迹合并) -->
|
||||
<!-- 右列: 材料审核时间轴 -->
|
||||
<div class="audit-columns">
|
||||
<div class="card audit-column">
|
||||
<div class="section-title">材料审核</div>
|
||||
@@ -312,68 +339,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card audit-column">
|
||||
<div class="section-title">凭证审核</div>
|
||||
<div class="timeline">
|
||||
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
|
||||
<div class="timeline-title">会议已执行</div>
|
||||
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
|
||||
</div>
|
||||
<template v-for="(round, idx) in displayVoucherRounds" :key="`vch-r${idx}`">
|
||||
<div :class="['timeline-item', partStatus(round.submit)]">
|
||||
<div class="timeline-title">执行方提交凭证</div>
|
||||
<div v-if="round.submit" class="timeline-meta">
|
||||
<span>{{ round.submit.auditor }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
|
||||
</div>
|
||||
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
|
||||
</div>
|
||||
<div :class="['timeline-item', partStatus(round.compliance)]">
|
||||
<div class="timeline-title">合规审核</div>
|
||||
<div v-if="round.compliance" class="timeline-meta">
|
||||
<span>{{ round.compliance.auditor }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
|
||||
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
|
||||
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="timeline-desc pending-text">待合规审核</div>
|
||||
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
|
||||
</div>
|
||||
<div :class="['timeline-item', partStatus(round.supervision)]">
|
||||
<div class="timeline-title">监察意见</div>
|
||||
<div v-if="round.supervision" class="timeline-meta">
|
||||
<span>{{ round.supervision.auditor }}</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
|
||||
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
|
||||
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="timeline-desc pending-text">待监察审核</div>
|
||||
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="['timeline-item', fixedNodeStatus('POST')]">
|
||||
<div class="timeline-title">会议结算</div>
|
||||
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
|
||||
</div>
|
||||
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
|
||||
<div class="timeline-title">会议完结</div>
|
||||
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分配 dialog -->
|
||||
<el-dialog v-model="assignDialog.show" :title="assignDialog.title" width="500px">
|
||||
<el-select v-model="assignDialog.selectedIds" multiple filterable :placeholder="`选择${assignDialog.roleLabel}`" style="width:100%" :loading="assignDialog.loading">
|
||||
<el-option v-for="u in assignDialog.candidates" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
|
||||
<el-option v-for="u in assignDialog.candidates" :key="u.orgId" :label="u.orgName" :value="u.orgId" />
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialog.show = false">取消</el-button>
|
||||
@@ -381,15 +353,9 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核 dialog (合规/监察通用, 支持材料+凭证多选) -->
|
||||
<!-- 审核 dialog (合规/监察通用, 材料审核) -->
|
||||
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="审核类型">
|
||||
<el-checkbox-group v-model="auditDialog.types">
|
||||
<el-checkbox label="MATERIAL">材料</el-checkbox>
|
||||
<el-checkbox label="VOUCHER">凭证</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="意见">
|
||||
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
@@ -543,7 +509,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick }
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { listSupporters, listExecutor } from '@/api/system'
|
||||
import { listSponsorOrgs, listExecutorOrgs } from '@/api/system'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import OssFileUploader from '@/components/OssFileUploader.vue'
|
||||
@@ -567,6 +533,8 @@ const meetingId = ref('')
|
||||
const row = ref({})
|
||||
/** 日程海报 URL (暂存本地, 点"保存"随材料一起落库到 biz_meeting.schedule_url) */
|
||||
const scheduleUrl = ref('')
|
||||
/** 邀请函 URL (暂存本地, 点"保存"随材料一起落库到 biz_meeting.invitation_url) */
|
||||
const invitationUrl = ref('')
|
||||
/** 生成海报 loading */
|
||||
const genPosterLoading = ref(false)
|
||||
/** 生成海报配置 dialog 显隐 */
|
||||
@@ -581,16 +549,18 @@ const saving = ref(false)
|
||||
const supervisors = ref([])
|
||||
const executors = ref([])
|
||||
const auditTrail = ref([])
|
||||
const busy = ref({ submitMaterial: false, submitVoucher: false, settle: false, finish: false })
|
||||
const busy = ref({ submitMaterial: false, settle: false, finish: false })
|
||||
|
||||
const currentUserId = computed(() => userStore.user?.userId)
|
||||
const currentRole = computed(() => userStore.role)
|
||||
const isManager = computed(() => currentRole.value === 'manager')
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
const isSponsor = computed(() => currentRole.value === 'sponsor')
|
||||
/** 软删除会议: admin / manager 可调 (后端 BizMeetingController.DELETE 强校验) */
|
||||
const canDeleteMeeting = computed(() => isAdmin.value || isManager.value)
|
||||
const deleting = ref(false)
|
||||
/** 只读模式: 从会议列表「查看」跳入 (route.meta.readonly), 仅展示内容 + 返回, 无任何操作按钮 */
|
||||
const isReadonly = computed(() => route.meta.readonly === true)
|
||||
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
|
||||
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
|
||||
/** 凭证 tab 可见性: 合规/管理员随时可见 (需上传); 执行方/支持方等结算后 (is_settled=1) 才可见 */
|
||||
const canSeeVouchers = computed(() => (isManager.value || isAdmin.value) || isOne(row.value.isSettled))
|
||||
|
||||
// ===================== 工具 =====================
|
||||
function pad(n) { return String(n).padStart(2, '0') }
|
||||
@@ -649,19 +619,16 @@ const ROW_CONFIG = [
|
||||
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '设计稿, PPT' },
|
||||
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
|
||||
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '总发票单独推送至OA, 单独下载' },
|
||||
{ type: 'LABOR', subType: 'L_DETAIL', label: '劳务明细表', hint: '劳务明细表下载' },
|
||||
{ type: 'LABOR', subType: 'L_AGREEMENT', label: '劳务协议', hint: '劳务协议模板' },
|
||||
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
|
||||
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
|
||||
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
|
||||
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
|
||||
{ type: 'LABOR', subType: 'L_PANORAMA_BACK', label: '后全景', hint: '会议现场后全景 (不带定位框)' },
|
||||
{ type: 'LABOR', subType: 'L_EXPERT_PHOTO', label: '专家照片', hint: '专家现场照片' },
|
||||
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
|
||||
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
|
||||
]
|
||||
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
|
||||
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK', 'L_EXPERT_PHOTO'])
|
||||
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
|
||||
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
|
||||
const CAMERA_SUBTYPES = new Set(['L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
|
||||
function isCameraSubType(subType) { return CAMERA_SUBTYPES.has(subType) }
|
||||
@@ -700,15 +667,11 @@ function parseRounds(auditType) {
|
||||
}
|
||||
|
||||
const materialRounds = computed(() => parseRounds('MATERIAL'))
|
||||
const voucherRounds = computed(() => parseRounds('VOUCHER'))
|
||||
|
||||
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
|
||||
const displayMaterialRounds = computed(() =>
|
||||
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
|
||||
)
|
||||
const displayVoucherRounds = computed(() =>
|
||||
voucherRounds.value.length ? voucherRounds.value : [{ submit: null, compliance: null, supervision: null }]
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点状态视觉:
|
||||
@@ -746,9 +709,10 @@ function nodeDesc(slot) {
|
||||
// ===================== 按钮显隐 =====================
|
||||
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
|
||||
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
|
||||
// 后端 submit-material/submit-voucher 用 isExecutorOfProject 做同等强校验.
|
||||
// 后端 submit-material 用 isExecutorOfProject 做同等强校验.
|
||||
const isExecutor = computed(() => currentRole.value === 'executor')
|
||||
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.value))
|
||||
// 监察员判定: 后端 audit-supervision 用 org_id 强校验 (前端无 org_id, role=sponsor 兜底显隐, 后端兜底拒绝)
|
||||
const isAssignedSupervisor = computed(() => isSponsor.value)
|
||||
// 支持方 MAIN 账号 (role=sponsor 且 account_type=MAIN) 也可审 (后端 audit-supervision 同样放行 sponsor_admin_user_id)
|
||||
const isSponsorMain = computed(() => currentRole.value === 'sponsor' && userStore.isMain)
|
||||
const executed = computed(() => isOne(row.value.isExecuted))
|
||||
@@ -756,23 +720,17 @@ const frozen = computed(() => isOne(row.value.isFrozen))
|
||||
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
|
||||
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
|
||||
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
|
||||
const canSubmitVoucher = computed(() => isExecutor.value && executed.value && !frozen.value
|
||||
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.voucherAuditStage))
|
||||
function canComplianceAudit(type) {
|
||||
function canComplianceAudit() {
|
||||
if (!isManager.value) return false
|
||||
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
|
||||
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
|
||||
return s === 'SUBMITTED' && !isOne(c)
|
||||
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
|
||||
}
|
||||
function canSupervisionAudit(type) {
|
||||
function canSupervisionAudit() {
|
||||
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false
|
||||
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
|
||||
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
|
||||
return s === 'SUBMITTED' && isOne(c)
|
||||
return row.value.materialAuditStage === 'SUBMITTED' && isOne(row.value.materialComplianceApproved)
|
||||
}
|
||||
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role)
|
||||
const canSettle = computed(() => (isManager.value || isAdmin.value)
|
||||
&& row.value.materialAuditStage === 'APPROVED' && row.value.voucherAuditStage === 'APPROVED'
|
||||
&& row.value.materialAuditStage === 'APPROVED'
|
||||
&& !isOne(row.value.isSettled))
|
||||
const canFinish = computed(() => (isManager.value || isAdmin.value)
|
||||
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished))
|
||||
@@ -786,7 +744,7 @@ async function loadMaterials() {
|
||||
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`)
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
materialsLoaded.value = Array.isArray(list) ? list : []
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...laborVoucherRows.value, ...serviceVoucherRows.value]
|
||||
all.forEach(r => { r.url = ''; r.fileName = '' })
|
||||
list.forEach(item => {
|
||||
const target = all.find(r => r.subType === item.subType)
|
||||
@@ -1216,6 +1174,114 @@ async function onExportAttendees() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
|
||||
const agreementTplLoading = ref(false)
|
||||
const agreementSubmitting = ref(false)
|
||||
const agreementZipInput = ref(null)
|
||||
|
||||
/** 下载"劳务协议"空目录模板 zip (劳务协议/{序号}_{姓名}/ 全空目录), 走 fetch 直接流式下载 */
|
||||
async function onDownloadAgreementTemplate() {
|
||||
agreementTplLoading.value = true
|
||||
try {
|
||||
const resp = await fetch(
|
||||
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/agreementTemplate/${meetingId.value}`,
|
||||
{ headers: importHeaders.value }
|
||||
)
|
||||
if (!resp.ok) throw new Error('下载失败')
|
||||
const blob = await resp.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = '劳务协议模板.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
agreementTplLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPickAgreementZip() {
|
||||
agreementZipInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAgreementZipChange(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许重复选择同一文件
|
||||
if (!file) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
agreementSubmitting.value = true
|
||||
try {
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/uploadAgreements?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
} finally {
|
||||
agreementSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 专家照片 批量收发 (下载空目录模板 + 上传回填 on_site_photos) =====================
|
||||
const expertPhotoTplLoading = ref(false)
|
||||
const expertPhotoSubmitting = ref(false)
|
||||
const expertPhotoZipInput = ref(null)
|
||||
|
||||
/** 下载"专家照片"空目录模板 zip (专家照片/{序号}_{姓名}/ 全空目录) */
|
||||
async function onDownloadExpertPhotoTemplate() {
|
||||
expertPhotoTplLoading.value = true
|
||||
try {
|
||||
const resp = await fetch(
|
||||
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/expertPhotoTemplate/${meetingId.value}`,
|
||||
{ headers: importHeaders.value }
|
||||
)
|
||||
if (!resp.ok) throw new Error('下载失败')
|
||||
const blob = await resp.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = '专家照片目录.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
expertPhotoTplLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPickExpertPhotoZip() {
|
||||
expertPhotoZipInput.value?.click()
|
||||
}
|
||||
|
||||
async function onExpertPhotoZipChange(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许重复选择同一文件
|
||||
if (!file) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
expertPhotoSubmitting.value = true
|
||||
try {
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/uploadExpertPhotos?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
} finally {
|
||||
expertPhotoSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submitImportFile() {
|
||||
importUploadRef.value?.submit()
|
||||
}
|
||||
@@ -1275,8 +1341,14 @@ async function load() {
|
||||
const resp = await bizGet('meeting', meetingId.value)
|
||||
row.value = (resp && (resp.data || resp)) || {}
|
||||
scheduleUrl.value = row.value.scheduleUrl || ''
|
||||
invitationUrl.value = row.value.invitationUrl || ''
|
||||
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()])
|
||||
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
|
||||
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
|
||||
if (route.query.settle === '1' && canSettle.value) {
|
||||
router.replace(route.path)
|
||||
onSettle()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[meeting-detail] load failed', e)
|
||||
row.value = {}
|
||||
@@ -1309,12 +1381,12 @@ async function loadProjectRoles() {
|
||||
|
||||
// ===================== 保存 (材料) =====================
|
||||
/**
|
||||
* 把当前 4 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
|
||||
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
|
||||
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
|
||||
* onSave (保存按钮) 与 onSubmitMaterial/onSubmitVoucher (提交前自动落库) 共用.
|
||||
* onSave (保存按钮) 与 onSubmitMaterial (提交前自动落库) 共用.
|
||||
*/
|
||||
async function saveMaterials() {
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
|
||||
|
||||
// 1. 快照保存前的 material (subType -> {id, ossUrl})
|
||||
// 用于保存后区分: 新增 / 替换 / 未变
|
||||
@@ -1369,6 +1441,17 @@ async function saveMaterials() {
|
||||
return { saved, ocrCount: toOcr.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存付款凭证 (LV_PAYMENT + SV_PAYMENT) — 独立于材料保存, 只清/插这两个 subType,
|
||||
* 不触发 OCR / 不影响会议费用. 合规/管理员 可随时上传保存; 结算接口只校验凭证是否已存在.
|
||||
*/
|
||||
async function saveVouchers() {
|
||||
const payload = [...laborVoucherRows.value, ...serviceVoucherRows.value]
|
||||
.filter(r => r.url && r.url.trim())
|
||||
.map(r => ({ materialType: r.type, subType: r.subType, ossUrl: r.url, fileName: r.fileName || '' }))
|
||||
await request.post(`/business/meetingMaterial/${meetingId.value}/saveVouchers`, payload, { __silentError: true })
|
||||
}
|
||||
|
||||
/** 保存日程海报到 biz_meeting.schedule_url (与材料一致: 上传暂存本地, 点"保存"才落库) */
|
||||
async function saveSchedulePoster() {
|
||||
const cur = row.value.scheduleUrl || ''
|
||||
@@ -1377,6 +1460,14 @@ async function saveSchedulePoster() {
|
||||
row.value.scheduleUrl = scheduleUrl.value
|
||||
}
|
||||
|
||||
/** 保存邀请函到 biz_meeting.invitation_url (与日程海报一致) */
|
||||
async function saveInvitation() {
|
||||
const cur = row.value.invitationUrl || ''
|
||||
if (invitationUrl.value === cur) return
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), invitationUrl: invitationUrl.value }, { __silentError: true })
|
||||
row.value.invitationUrl = invitationUrl.value
|
||||
}
|
||||
|
||||
/** 点击"生成海报": 弹出配置 dialog (直接使用背景 or 自定义文字颜色) */
|
||||
function onGeneratePoster() {
|
||||
if (!row.value.scheduleUrl) {
|
||||
@@ -1416,6 +1507,8 @@ async function onSave() {
|
||||
try {
|
||||
const { saved, ocrCount } = await saveMaterials()
|
||||
await saveSchedulePoster()
|
||||
await saveInvitation()
|
||||
if (canUploadVoucher.value) await saveVouchers()
|
||||
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
|
||||
refreshFee()
|
||||
ElMessage.success(`保存成功 (${saved.length} 条)`)
|
||||
@@ -1485,7 +1578,7 @@ function submitOcrForMaterials(items) {
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 提交材料 / 提交凭证 =====================
|
||||
// ===================== 提交材料 =====================
|
||||
// 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表,
|
||||
// 只上传不保存时表为空, 会误报「未上传」.
|
||||
async function onSubmitMaterial() {
|
||||
@@ -1500,18 +1593,6 @@ async function onSubmitMaterial() {
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
|
||||
finally { busy.value.submitMaterial = false }
|
||||
}
|
||||
async function onSubmitVoucher() {
|
||||
busy.value.submitVoucher = true
|
||||
try {
|
||||
await saveMaterials()
|
||||
// __silentError: 同上, 避免双 toast
|
||||
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`, null, { __silentError: true })
|
||||
ElMessage.success('凭证已提交, 待合规审核')
|
||||
row.value.voucherAuditStage = (resp && resp.data) || 'SUBMITTED'
|
||||
await loadTrail()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
|
||||
finally { busy.value.submitVoucher = false }
|
||||
}
|
||||
|
||||
// ===================== 分配 dialog =====================
|
||||
const assignDialog = ref({
|
||||
@@ -1525,17 +1606,15 @@ async function openAssignDialog(role) {
|
||||
roleLabel: role === 'supervisor' ? '监察员' : '执行人员',
|
||||
title: `分配${role === 'supervisor' ? '监察员' : '执行人员'}`,
|
||||
candidates: [],
|
||||
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => u.userId),
|
||||
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => role === 'supervisor' ? u.sponsorOrgId : u.executorOrgId),
|
||||
loading: false,
|
||||
saving: false
|
||||
}
|
||||
assignDialog.value.loading = true
|
||||
try {
|
||||
const fn = role === 'supervisor' ? listSupporters : listExecutor
|
||||
const resp = await fn({ status: '0', accountType: 'MAIN' })
|
||||
// 兼容不同返回结构
|
||||
const rows = resp?.rows || resp?.data?.rows || (Array.isArray(resp) ? resp : [])
|
||||
assignDialog.value.candidates = rows
|
||||
const fn = role === 'supervisor' ? listSponsorOrgs : listExecutorOrgs
|
||||
const resp = await fn({})
|
||||
assignDialog.value.candidates = resp?.data || []
|
||||
} catch (e) { ElMessage.error('加载候选人失败') }
|
||||
finally { assignDialog.value.loading = false }
|
||||
}
|
||||
@@ -1546,7 +1625,7 @@ async function confirmAssign() {
|
||||
: `/business/meeting/executor/${meetingId.value}`
|
||||
assignDialog.value.saving = true
|
||||
try {
|
||||
await request.put(url, { userIds: selectedIds })
|
||||
await request.put(url, { orgIds: selectedIds })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialog.value.show = false
|
||||
await loadStaff()
|
||||
@@ -1554,36 +1633,29 @@ async function confirmAssign() {
|
||||
finally { assignDialog.value.saving = false }
|
||||
}
|
||||
|
||||
// ===================== 审核 dialog (材料+凭证可多选) =====================
|
||||
// ===================== 审核 dialog (材料审核) =====================
|
||||
const auditDialog = ref({
|
||||
show: false, action: '', types: [], title: '',
|
||||
show: false, action: '', title: '',
|
||||
opinion: '', approved: true, saving: false
|
||||
})
|
||||
function openAuditDialog(action, auditType) {
|
||||
const types = [auditType]
|
||||
const other = auditType === 'MATERIAL' ? 'VOUCHER' : 'MATERIAL'
|
||||
const eligible = action === 'COMPLIANCE' ? canComplianceAudit : canSupervisionAudit
|
||||
if (eligible(other)) types.push(other)
|
||||
function openAuditDialog(action) {
|
||||
auditDialog.value = {
|
||||
show: true,
|
||||
action,
|
||||
types,
|
||||
title: action === 'COMPLIANCE' ? '合规审核' : '监察审核',
|
||||
title: action === 'COMPLIANCE' ? '合规审核 (材料)' : '监察审核 (材料)',
|
||||
opinion: '',
|
||||
approved: true,
|
||||
saving: false
|
||||
}
|
||||
}
|
||||
async function confirmAudit() {
|
||||
const { action, types, opinion, approved } = auditDialog.value
|
||||
if (!types || !types.length) { ElMessage.warning('请选择审核类型'); return }
|
||||
const auditType = types.length === 2 ? 'BOTH' : types[0]
|
||||
const { action, opinion, approved } = auditDialog.value
|
||||
const url = action === 'COMPLIANCE'
|
||||
? `/business/meeting/${meetingId.value}/audit-compliance`
|
||||
: `/business/meeting/${meetingId.value}/audit-supervision`
|
||||
auditDialog.value.saving = true
|
||||
try {
|
||||
await request.post(url, { auditType, approved, opinion }, { __silentError: true })
|
||||
await request.post(url, { approved, opinion }, { __silentError: true })
|
||||
ElMessage.success(approved ? '审核通过' : '已拒绝')
|
||||
auditDialog.value.show = false
|
||||
await refreshMeeting()
|
||||
@@ -1592,15 +1664,17 @@ async function confirmAudit() {
|
||||
}
|
||||
|
||||
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
|
||||
// 结算前必须先已上传保存付款凭证 (劳务/会务凭证 tab), 后端 settle 校验凭证存在, 缺失则报错.
|
||||
async function onSettle() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认结算? 结算后将锁定材料与凭证 (仅可继续点「完结」)', '结算确认', { type: 'warning' })
|
||||
await ElMessageBox.confirm('确认结算? 结算前请确保已上传劳务/会务付款凭证。', '结算确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
busy.value.settle = true
|
||||
try {
|
||||
await request.post(`/business/meeting/${meetingId.value}/settle`, null, { __silentError: true })
|
||||
ElMessage.success('已结算')
|
||||
await refreshMeeting()
|
||||
await loadMaterials()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '结算失败') }
|
||||
finally { busy.value.settle = false }
|
||||
}
|
||||
@@ -1644,31 +1718,58 @@ function scheduleFeePoll() {
|
||||
feePollTimer = setInterval(refreshFee, 15000)
|
||||
}
|
||||
|
||||
// ===================== 占位 =====================
|
||||
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
|
||||
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
|
||||
// ===================== 会务材料 打包上传 (下载空目录模板 + 上传回填 biz_meeting_material) =====================
|
||||
const serviceTplLoading = ref(false)
|
||||
const serviceSubmitting = ref(false)
|
||||
const serviceZipInput = ref(null)
|
||||
|
||||
// ===================== admin / manager 删除会议 (软删) =====================
|
||||
/**
|
||||
* 后端 BizMeetingController.DELETE 强校验 role in (admin, manager).
|
||||
* 详情页删除后跳回当前角色对应的会议列表 (避免停在已删会议的空页).
|
||||
*/
|
||||
async function onAdminDelete() {
|
||||
/** 下载"会务材料"空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录) */
|
||||
async function onDownloadServiceTemplate() {
|
||||
serviceTplLoading.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除会议「${row.value.meetingName || ''}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`,
|
||||
'删除确认', { type: 'warning' }
|
||||
const resp = await fetch(
|
||||
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/serviceTemplate/${meetingId.value}`,
|
||||
{ headers: importHeaders.value }
|
||||
)
|
||||
} catch { return }
|
||||
deleting.value = true
|
||||
try {
|
||||
await request.delete(`/business/meeting/${meetingId.value}`)
|
||||
ElMessage.success('删除成功')
|
||||
router.push(isAdmin.value ? '/admin/meetings' : '/manager/meetings')
|
||||
if (!resp.ok) throw new Error('下载失败')
|
||||
const blob = await resp.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = '会务材料目录.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '删除失败')
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
serviceTplLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPickServiceZip() {
|
||||
serviceZipInput.value?.click()
|
||||
}
|
||||
|
||||
async function onServiceZipChange(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许重复选择同一文件
|
||||
if (!file) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
serviceSubmitting.value = true
|
||||
try {
|
||||
const resp = await request.post(
|
||||
`/business/meetingMaterial/uploadServiceMaterials?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 类会务材料`)
|
||||
loadMaterials()
|
||||
refreshFee()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
} finally {
|
||||
serviceSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1689,7 +1790,6 @@ onBeforeUnmount(stopFeePolling)
|
||||
.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; 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; min-width: 0; }
|
||||
.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; }
|
||||
@@ -1725,13 +1825,13 @@ onBeforeUnmount(stopFeePolling)
|
||||
.fee-label { font-size: 14px; color: #595959; }
|
||||
.fee-value { font-size: 16px; font-weight: 600; color: #f5222d; }.file-uploader { width: 100%; min-width: 0; }
|
||||
.file-hint { grid-column: 2; font-size: 11px; color: #8c8c8c; line-height: 1.5; }
|
||||
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
|
||||
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
|
||||
.file-link:hover { text-decoration: underline; }
|
||||
.file-link.inline-link { font-size: 13px; }
|
||||
|
||||
.tab-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
|
||||
|
||||
.timeline { position: relative; padding-left: 24px; border-left: 2px solid #e8e8e8; }
|
||||
.timeline-item { padding: 6px 0 12px 16px; position: relative; }
|
||||
@@ -1747,7 +1847,7 @@ onBeforeUnmount(stopFeePolling)
|
||||
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
|
||||
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
|
||||
|
||||
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; min-width: 0; }
|
||||
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; }
|
||||
.audit-column { margin-bottom: 0; padding: 16px 18px; min-width: 0; }
|
||||
.audit-column .section-title { font-size: 14px; margin-bottom: 12px; padding-left: 8px; }
|
||||
|
||||
@@ -1756,7 +1856,6 @@ onBeforeUnmount(stopFeePolling)
|
||||
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
|
||||
.attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; }
|
||||
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.toolbar-hint { font-size: 12px; color: #909399; line-height: 1.5; }
|
||||
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
|
||||
|
||||
/* 参会人 dialog 2 列布局: el-form 用 grid, 每个 el-form-item 占 1 列, form-hint 横跨 2 列 */
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<span class="form-value">{{ snap.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称">
|
||||
<span class="form-value">{{ form.meetingName }}</span>
|
||||
<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="支持单位">
|
||||
<span class="form-value">{{ snap.sponsorOrgName }}</span>
|
||||
@@ -58,9 +59,11 @@ import { ElMessage } from 'element-plus'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ========== 角色感知 (admin/manager 共用此新建页, 按 URL 第 1 段识别) ==========
|
||||
const roleSegment = computed(() => route.path.split('/')[1]) // 'admin' | 'manager'
|
||||
// ========== 角色感知 (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`)
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
<!-- ========== 批量按钮 (admin/manager 共用: 批量提交 + 批量删除; sponsor 隐藏) ========== -->
|
||||
<div v-if="isRole('admin', 'manager')" class="batch-bar">
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
|
||||
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadService">批量下载会务</el-button>
|
||||
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchDownloadLabor">批量下载劳务</el-button>
|
||||
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -56,19 +58,28 @@
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isRole('admin', 'manager')" label="提交剩余时间" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'remain-danger': submitRemain(row).red }">{{ submitRemain(row).text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel(roleSegment, row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<el-table-column label="操作" width="500" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="viewOnly(row)">查看</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="viewDetail(row)">上传材料</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onCopy(row)">复制</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager') && canSettleRow(row)" link type="success" @click="onSettle(row)">结算</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager') && canFinishRow(row)" link type="primary" @click="onFinish(row)">完结</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager') && isOneVal(row.isFrozen)" link type="warning" @click="onUnfreeze(row)">解冻</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onDownloadService(row)">会务下载</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onDownloadLabor(row)">劳务下载</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
<el-button v-if="isRole('sponsor') && derivePhysicalStage(row) === 'AWAITING_SUPERVISION'" link type="warning" @click="onApproval(row)">审批</el-button>
|
||||
</template>
|
||||
@@ -193,6 +204,10 @@ function readQueryFromRoute() {
|
||||
function viewDetail(row) {
|
||||
router.push(`${detailBase.value}/detail/${row.meetingId}`)
|
||||
}
|
||||
// 只读查看 (新): 跳 meetings/view/:id, 无任何操作按钮 (只有返回)
|
||||
function viewOnly(row) {
|
||||
router.push(`${detailBase.value}/view/${row.meetingId}`)
|
||||
}
|
||||
// 修改 / 复制: 按角色选 admin-meetings-new 或 manager-meetings-new (MeetingNew.vue 公共页)
|
||||
function onEdit(row) {
|
||||
router.push({ name: newRouteName.value, query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
|
||||
@@ -240,21 +255,53 @@ async function onBatchDelete() {
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量删除失败') }
|
||||
}
|
||||
|
||||
// ========== 批量下载会务/劳务材料 (admin/manager) ==========
|
||||
async function onBatchDownloadService() {
|
||||
if (!selectedIds.value.length) return ElMessage.warning('请先勾选会议')
|
||||
try {
|
||||
const r = await request.post('/business/meetingMaterial/batchDownloadZip', { meetingIds: selectedIds.value })
|
||||
const url = r?.data
|
||||
if (!url) { ElMessage.warning('暂无可下载的会务材料'); return }
|
||||
window.open(url, '_blank')
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '批量下载会务失败') }
|
||||
}
|
||||
async function onBatchDownloadLabor() {
|
||||
if (!selectedIds.value.length) return ElMessage.warning('请先勾选会议')
|
||||
try {
|
||||
const r = await request.post('/business/meetingMaterial/batchDownloadLaborZip', { meetingIds: selectedIds.value })
|
||||
const url = r?.data
|
||||
if (!url) { ElMessage.warning('暂无可下载的劳务材料'); return }
|
||||
window.open(url, '_blank')
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '批量下载劳务失败') }
|
||||
}
|
||||
|
||||
// ========== 结算 (合规/管理员 手动点击, 后端强校验 role) ==========
|
||||
function isOneVal(v) { return v === 1 || v === '1' || v === true }
|
||||
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (材料+凭证都 APPROVED 且未结算)
|
||||
function canSettleRow(row) {
|
||||
return row.materialAuditStage === 'APPROVED' && row.voucherAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
|
||||
|
||||
// ========== 提交剩余时间 (仅 admin/manager 列) ==========
|
||||
// 已提交(SUBMITTED/APPROVED): 曾被冻结过 → 超时提交, 否则 → 按时提交
|
||||
// 未提交/退回(NOT_SUBMITTED/REJECTED): 已冻结 → 红色 0小时; 否则 submitDeadline - now 倒计时
|
||||
function submitRemain(row) {
|
||||
const stage = row.materialAuditStage
|
||||
if (stage === 'SUBMITTED' || stage === 'APPROVED') {
|
||||
return row.freezeTime ? { text: '超时提交', red: false } : { text: '按时提交', red: false }
|
||||
}
|
||||
if (isOneVal(row.isFrozen)) return { text: '0小时', red: true }
|
||||
if (!row.submitDeadline) return { text: '-', red: false }
|
||||
const ms = new Date(row.submitDeadline).getTime() - Date.now()
|
||||
if (ms <= 0) return { text: '0小时', red: true }
|
||||
const totalHours = Math.floor(ms / 3600000)
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
return { text: `${days}天${hours}小时`, red: false }
|
||||
}
|
||||
async function onSettle(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认结算会议「${row.meetingName}」?`, '结算确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/business/meeting/${row.meetingId}/settle`, null, { __silentError: true })
|
||||
ElMessage.success('已结算')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '结算失败') }
|
||||
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (材料 APPROVED 且未结算)
|
||||
function canSettleRow(row) {
|
||||
return row.materialAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
|
||||
}
|
||||
function onSettle(row) {
|
||||
// 结算需要上传付款凭证 (详情页结算 dialog 二合一), 跳详情并自动打开结算 dialog
|
||||
router.push({ path: `${detailBase.value}/detail/${row.meetingId}`, query: { settle: '1' } })
|
||||
}
|
||||
// 列表完结按钮显隐: 已结算 且 未完结
|
||||
function canFinishRow(row) {
|
||||
@@ -271,6 +318,18 @@ async function onFinish(row) {
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '完结失败') }
|
||||
}
|
||||
|
||||
// ========== 解冻 (合规/管理员) ==========
|
||||
async function onUnfreeze(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认解冻会议「${row.meetingName}」?\n解冻后提交截止时间将重新计算。`, '解冻确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/business/meeting/${row.meetingId}/unfreeze`, null)
|
||||
ElMessage.success('已解冻')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') }
|
||||
}
|
||||
|
||||
// ========== 支持方(监察员) 审批 ==========
|
||||
const approvalOpen = ref(false)
|
||||
const approvalSaving = ref(false)
|
||||
@@ -292,7 +351,6 @@ async function onApprovalConfirm() {
|
||||
approvalSaving.value = true
|
||||
try {
|
||||
await request.post(`/business/meeting/${approvalRow.value.meetingId}/audit-supervision`, {
|
||||
auditType: 'MATERIAL',
|
||||
approved: approvalForm.approved,
|
||||
opinion: approvalForm.opinion
|
||||
})
|
||||
@@ -306,6 +364,30 @@ async function onApprovalConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会务下载 (OSS 端打 zip: 后端 staging copy + 阿里云 FC 打包) ==========
|
||||
async function onDownloadService(row) {
|
||||
try {
|
||||
const r = await request.get(`/business/meetingMaterial/${row.meetingId}/downloadZip`)
|
||||
const url = r?.data
|
||||
if (!url) { ElMessage.warning('暂无可下载的会务材料'); return }
|
||||
window.open(url, '_blank')
|
||||
} catch (e) {
|
||||
// 拦截器已 toast 错误 (含后端 ServiceException 提示语), 这里静默
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 劳务下载 (同会务下载: 劳务材料 + 参会人信息 Excel 一并打 zip) ==========
|
||||
async function onDownloadLabor(row) {
|
||||
try {
|
||||
const r = await request.get(`/business/meetingMaterial/${row.meetingId}/downloadLaborZip`)
|
||||
const url = r?.data
|
||||
if (!url) { ElMessage.warning('暂无可下载的劳务材料'); return }
|
||||
window.open(url, '_blank')
|
||||
} catch (e) {
|
||||
// 拦截器已 toast 错误 (含后端 ServiceException 提示语), 这里静默
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
</script>
|
||||
|
||||
@@ -334,4 +416,6 @@ onMounted(() => { readQueryFromRoute(); load() })
|
||||
.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; }
|
||||
/* 提交剩余时间: 已冻结/逾期 → 红色 0小时 */
|
||||
.remain-danger { color: #ff4d4f; font-weight: 600; }
|
||||
</style>
|
||||
@@ -167,9 +167,12 @@ async function load() {
|
||||
const type = r.announcementType || '公示'
|
||||
// 根据 announcementType 选择对应 URL
|
||||
const urlMap = {
|
||||
invitation: r.invitationUrl,
|
||||
support: r.supportLetterUrl,
|
||||
publish: r.publishUrl
|
||||
invitation: r.invitationUrl,
|
||||
support: r.supportLetterUrl,
|
||||
supportLetter: r.supportLetterUrl,
|
||||
publish: r.publishUrl,
|
||||
notice: r.publishUrl,
|
||||
schedule: r.scheduleUrl
|
||||
}
|
||||
return {
|
||||
annId: r.projectId,
|
||||
|
||||
@@ -90,20 +90,28 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 右: 操作按钮 - 悬浮固定到页面最右 -->
|
||||
<!-- 右: 操作按钮 - 悬浮固定到页面最右 (按选中 tab 动态显示) -->
|
||||
<aside class="detail-actions">
|
||||
<button class="action-btn primary signup-btn" :disabled="signing || signed" @click="onSignup">
|
||||
<!-- 邀请函: 立即报名 -->
|
||||
<button v-if="activeTab === 'invitation'" class="action-btn primary signup-btn" :disabled="signing || signed" @click="onSignup">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
|
||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
|
||||
</svg>
|
||||
<span>{{ signed ? '已报名' : (signing ? '报名中…' : '立即报名') }}</span>
|
||||
</button>
|
||||
<button v-if="canShowSupportBtn" class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent">
|
||||
<span>{{ supportSubmitted ? '已支持' : (supportSubmitting ? '提交中…' : '表达支持意向') }}</span>
|
||||
<!-- 支持函: 公益支持 (表达支持意向) -->
|
||||
<button v-if="activeTab === 'supportLetter' && canShowSupportBtn" class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent">
|
||||
<span>{{ supportSubmitted ? '已支持' : (supportSubmitting ? '提交中…' : '公益支持') }}</span>
|
||||
</button>
|
||||
<button v-if="canShowExecutionBtn" class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent">
|
||||
<span>{{ executionSubmitted ? '已表达意向' : (executionSubmitting ? '提交中…' : '表达执行意向') }}</span>
|
||||
<!-- 通知: 项目执行申请 (表达执行意向) -->
|
||||
<button v-if="activeTab === 'notice' && canShowExecutionBtn" class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent">
|
||||
<span>{{ executionSubmitted ? '已申请' : (executionSubmitting ? '提交中…' : '项目执行申请') }}</span>
|
||||
</button>
|
||||
<!-- 通知: 查看招标公告 (仅招标项目, 暂跳百度占位) -->
|
||||
<button v-if="activeTab === 'notice' && isBidProject" class="action-btn primary" @click="onViewBidAnnouncement">
|
||||
<span>查看招标公告</span>
|
||||
</button>
|
||||
<!-- 分享二维码: 所有 tab 均显示 -->
|
||||
<button class="action-btn primary share-btn" @click="showQr = true">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4z"/>
|
||||
@@ -282,8 +290,10 @@ const activeTab = ref('invitation')
|
||||
|
||||
// 按 URL 字段定义固定 tab (key 必须与 biz_project 字段名后缀一致)
|
||||
const TAB_DEFS = [
|
||||
{ key: 'invitation', label: '邀请函', urlKey: 'invitationUrl' },
|
||||
{ key: 'supportLetter', label: '支持函', urlKey: 'supportLetterUrl' }
|
||||
{ key: 'invitation', label: '专家参与邀请函', urlKey: 'invitationUrl' },
|
||||
{ key: 'supportLetter', label: '项目公益支持函', urlKey: 'supportLetterUrl' },
|
||||
{ key: 'notice', label: '项目通知', urlKey: 'publishUrl' },
|
||||
{ key: 'schedule', label: '日程', urlKey: 'scheduleUrl' }
|
||||
]
|
||||
|
||||
// 只显示项目里"实际有文件 URL"的 tab
|
||||
@@ -303,6 +313,12 @@ const tabList = computed(() => {
|
||||
|
||||
const currentTab = computed(() => tabList.value.find(t => t.key === activeTab.value) || tabList.value[0])
|
||||
|
||||
// 是否招标项目 (is_bid_project = 'Y'): 通知 tab 下显示"查看招标公告"按钮
|
||||
const isBidProject = computed(() => {
|
||||
const v = ann.value?.isBidProject
|
||||
return v === 'Y' || v === '1' || v === 1 || v === true
|
||||
})
|
||||
|
||||
// 判断文件类型: 通过 URL 扩展名 (兼容 OSS 签名 URL)
|
||||
function isPdf(url) {
|
||||
if (!url) return false
|
||||
@@ -516,13 +532,13 @@ function onSupportIntent() {
|
||||
if (supportSubmitting.value || supportSubmitted.value) return
|
||||
if (loggedIn.value) {
|
||||
guestDialog.type = 'support'
|
||||
guestDialog.title = '表达支持意向'
|
||||
guestDialog.title = '公益支持'
|
||||
guestDialog.open = true
|
||||
prefillGuestFormFromUser() // 异步, 不 await; dialog 已先打开, 数据慢慢填进去
|
||||
} else {
|
||||
resetGuestForm()
|
||||
guestDialog.type = 'support'
|
||||
guestDialog.title = '表达支持意向'
|
||||
guestDialog.title = '公益支持'
|
||||
guestDialog.open = true
|
||||
}
|
||||
}
|
||||
@@ -532,13 +548,13 @@ function onExecutionIntent() {
|
||||
if (executionSubmitting.value || executionSubmitted.value) return
|
||||
if (loggedIn.value) {
|
||||
guestDialog.type = 'execution'
|
||||
guestDialog.title = '表达执行意向'
|
||||
guestDialog.title = '项目执行申请'
|
||||
guestDialog.open = true
|
||||
prefillGuestFormFromUser() // 异步, 不 await
|
||||
} else {
|
||||
resetGuestForm()
|
||||
guestDialog.type = 'execution'
|
||||
guestDialog.title = '表达执行意向'
|
||||
guestDialog.title = '项目执行申请'
|
||||
guestDialog.open = true
|
||||
}
|
||||
}
|
||||
@@ -587,6 +603,11 @@ async function doSubmitIntent(type, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
// 查看招标公告: 暂跳百度占位 (后续替换为真实招标公告链接)
|
||||
function onViewBidAnnouncement() {
|
||||
window.open('https://www.baidu.com', '_blank')
|
||||
}
|
||||
|
||||
function onKeydown(e) { if (e.key === 'Escape') showQr.value = false }
|
||||
function handleScroll() { isScrolled.value = window.scrollY > 10 }
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
<!-- ========== 筛选区 ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable style="width:200px" />
|
||||
<el-form-item label="单位名称">
|
||||
<el-input v-model="q.orgName" placeholder="输入单位名称" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
<el-input v-model="q.taxNo" placeholder="输入税号" clearable style="width:220px" />
|
||||
@@ -32,15 +32,16 @@
|
||||
|
||||
<!-- ========== 批量按钮区 ========== -->
|
||||
<div class="batch-bar">
|
||||
<!-- 仅 admin 可新建支持单位 -->
|
||||
<!-- 仅 admin 可新建 / 批量导入支持单位 -->
|
||||
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增支持单位</el-button>
|
||||
<el-button v-if="isAdmin" @click="openImport">批量导入</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="orgName" label="单位名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="单位地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
|
||||
<el-table-column label="合作状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
@@ -84,8 +85,8 @@
|
||||
<el-dialog v-model="detailOpen" title="支持单位详情" width="640px" v-if="currentRow">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="公司类型"><el-tag type="success">支持方</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司地址">{{ currentRow.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位名称">{{ currentRow.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位地址">{{ currentRow.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作状态">
|
||||
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
|
||||
@@ -101,11 +102,11 @@
|
||||
|
||||
<!-- ========== 新增/编辑弹窗 (仅 admin) ========== -->
|
||||
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑支持单位' : '新增支持单位'" width="640px">
|
||||
<el-form :model="form" label-width="140px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="公司名称" prop="orgName">
|
||||
<el-form :model="form" label-width="170px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="单位名称" prop="orgName">
|
||||
<el-input v-model="form.orgName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司地址">
|
||||
<el-form-item label="单位地址">
|
||||
<el-input v-model="form.address" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
@@ -129,6 +130,36 @@
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== 批量导入支持单位 (沿用 Experts.vue 模式: v-if + onError + 模板下载在框外 + ImportResultDialog) ========== -->
|
||||
<el-dialog v-if="isAdmin" v-model="importOpen" title="批量导入支持单位" width="400px" append-to-body :close-on-click-modal="false">
|
||||
<el-upload
|
||||
v-if="importOpen"
|
||||
ref="uploadRef"
|
||||
:limit="1"
|
||||
:headers="importHeaders"
|
||||
accept=".xlsx, .xls"
|
||||
:action="importAction"
|
||||
:on-progress="onImportProgress"
|
||||
:on-success="onImportSuccess"
|
||||
:on-error="onImportError"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</el-upload>
|
||||
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
|
||||
<div style="margin-top:10px;font-size:13px;text-align:center">
|
||||
<span style="color:#909399;margin-right:8px">仅允许导入 xls、xlsx 格式文件</span>
|
||||
<el-link type="primary" :underline="false" @click="downloadImportTpl">下载模板</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen=false">取 消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImportFile">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -137,6 +168,8 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { bizList, bizAdd, bizUpdate, toggleOrgStatus } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import ImportResultDialog from '@/components/ImportResultDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -221,7 +254,7 @@ const form = reactive({
|
||||
contactName: '', contactPhone: '', status: '0'
|
||||
})
|
||||
const rules = {
|
||||
orgName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }]
|
||||
orgName: [{ required: true, message: '请输入单位名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
@@ -254,6 +287,75 @@ async function onSave() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ===== 批量导入支持单位 (沿用 Experts.vue 最佳实践) =====
|
||||
const importOpen = ref(false)
|
||||
const importing = ref(false)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref(null)
|
||||
const uploadRef = ref(null)
|
||||
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
|
||||
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
|
||||
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action
|
||||
const importAction = computed(() => import.meta.env.VITE_APP_BASE_API + '/business/org/importData')
|
||||
|
||||
function openImport() {
|
||||
importResult.value = null
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
// 模板下载: fetch + blob (后端 /business/org/importTemplate)
|
||||
function downloadImportTpl() {
|
||||
fetch(import.meta.env.VITE_APP_BASE_API + '/business/org/importTemplate', { headers: importHeaders.value })
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = '支持单位批量导入模板.xlsx'
|
||||
a.click()
|
||||
})
|
||||
.catch(() => ElMessage.error('模板下载失败'))
|
||||
}
|
||||
|
||||
// 触发 el-upload 提交 (auto-upload=false 时手动调)
|
||||
function submitImportFile() {
|
||||
uploadRef.value?.submit()
|
||||
}
|
||||
|
||||
function onImportProgress() { importing.value = true }
|
||||
|
||||
function onImportSuccess(res) {
|
||||
importing.value = false
|
||||
// 后端返回 {code,msg,data: ImportResult}; 兼容 res.data 兜底 (防拦截器 unwrap 缺失)
|
||||
const result = (res && res.data) ? res.data : res
|
||||
// 守卫: 非 ImportResult (后端异常但 HTTP 200, 或 401 重定向回包) 不开 dialog
|
||||
if (!result || typeof result.okNum !== 'number') {
|
||||
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
|
||||
importResult.value = null
|
||||
return
|
||||
}
|
||||
importResult.value = result
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
load()
|
||||
}
|
||||
|
||||
function onImportError(err) {
|
||||
importing.value = false
|
||||
// el-upload 用原生 XHR, err.response 是后端返回的 JSON 字符串 (RuoYi 拦截器格式 {code,msg})
|
||||
let msg = '上传失败, 请重试'
|
||||
if (err && err.response != null) {
|
||||
try {
|
||||
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
|
||||
msg = body.msg || msg
|
||||
} catch { /* JSON 解析失败, 用兜底 */ }
|
||||
} else if (err && err.message) {
|
||||
msg = err.message
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
// 清残留, 防止上次成功 result 在空白 dialog 里显示
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="goNew">新建人员</el-button>
|
||||
<el-button @click="openImport">批量导入</el-button>
|
||||
<span class="filter-tip">*批量导入模板与列表表项一致</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 ========== -->
|
||||
@@ -52,6 +51,16 @@
|
||||
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 管理员 switch: 仅 admin 可见, 指定机构管理员 (MAIN) -->
|
||||
<el-table-column v-if="isAdmin" label="管理员" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.accountType === 'MAIN'"
|
||||
:disabled="switchingId === row.personId"
|
||||
@change="onChangeAdmin(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
|
||||
@@ -78,32 +87,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ========== 批量导入 ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择文件">
|
||||
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx" :on-change="onFileChange" :on-remove="onFileRemove">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item><span class="filter-tip">*导入列: 姓名/手机号/所属公司/部门/职务/角色</span></el-form-item>
|
||||
</el-form>
|
||||
<!-- ========== 批量导入 (沿用 executor-people 模式: drag + 模板下载在框外 + ImportResultDialog) ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入" width="400px" append-to-body :close-on-click-modal="false">
|
||||
<el-upload
|
||||
v-if="importOpen"
|
||||
ref="uploadRef"
|
||||
:limit="1"
|
||||
accept=".xlsx, .xls"
|
||||
:on-change="onFileChange"
|
||||
:on-remove="onFileRemove"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</el-upload>
|
||||
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
|
||||
<div style="margin-top:10px;font-size:13px;text-align:center">
|
||||
<span style="color:#909399;margin-right:8px">仅允许导入 xls、xlsx 格式文件</span>
|
||||
<el-link type="primary" :underline="false" @click="downloadTemplate">下载模板</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
|
||||
<el-button @click="importOpen=false">取 消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
|
||||
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import ImportResultDialog from '@/components/ImportResultDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -171,6 +190,30 @@ async function onToggleStatus(row) {
|
||||
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
// ========== 更换机构管理员 (仅 admin) ==========
|
||||
const switchingId = ref('')
|
||||
async function onChangeAdmin(row) {
|
||||
// switch 受控 (:model-value), 当前管理员 (MAIN) 的 switch 恒为开, 无法直接关掉
|
||||
if (row.accountType === 'MAIN') { ElMessage.info('已是该机构管理员'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将「${row.name}」设为该机构管理员吗?`,
|
||||
'更换管理员',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
switchingId.value = row.personId
|
||||
try {
|
||||
await changePersonAdmin(row.personId)
|
||||
ElMessage.success('已更换管理员')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '更换失败')
|
||||
} finally {
|
||||
switchingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 跳转 (按角色) ==========
|
||||
function goNew() {
|
||||
router.push({
|
||||
@@ -193,8 +236,10 @@ const importOpen = ref(false)
|
||||
const importing = ref(false)
|
||||
const uploadRef = ref()
|
||||
const importFile = ref(null)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref(null)
|
||||
|
||||
function openImport() { importFile.value = null; importOpen.value = true }
|
||||
function openImport() { importFile.value = null; importResult.value = null; importOpen.value = true }
|
||||
function onFileChange(file) { importFile.value = file.raw }
|
||||
function onFileRemove() { importFile.value = null }
|
||||
async function downloadTemplate() {
|
||||
@@ -213,21 +258,18 @@ async function submitImport() {
|
||||
if (!importFile.value) { ElMessage.warning('请先选择文件'); return }
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await importPerson('sponsor', importFile.value)
|
||||
const res = await importPerson('sponsor', importFile.value, orgId.value)
|
||||
const payload = res.data || res
|
||||
const ok = payload.ok ?? 0
|
||||
const total = payload.total ?? 0
|
||||
const failed = (payload.results || []).filter(r => !r.ok)
|
||||
if (failed.length === 0) {
|
||||
ElMessage.success(`导入成功 ${ok}/${total}`)
|
||||
} else {
|
||||
ElMessageBox.alert(
|
||||
`成功 ${ok} 条, 失败 ${failed.length} 条:\n` +
|
||||
failed.map(f => ` 第${f.rowNo}行 ${f.name || ''}: ${f.message}`).join('\n'),
|
||||
'导入结果', { type: 'warning' }
|
||||
)
|
||||
// 映射为 ImportResultDialog 期望的形状 { okNum, ngNum, ngList: [{rowNum, message}] }
|
||||
importResult.value = {
|
||||
okNum: ok,
|
||||
ngNum: failed.length,
|
||||
ngList: failed.map(f => ({ rowNum: f.rowNo, message: (f.name ? f.name + ': ' : '') + (f.message || '') }))
|
||||
}
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导入失败')
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
<el-input v-model="form.position" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<span style="color:#303133">监察员</span>
|
||||
<el-tag :type="accountTypeRoleTagType(form.accountType)" disable-transitions>
|
||||
{{ accountTypeRoleLabel(form.unitType, form.accountType) || '监察员' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-tag :type="form.status === '1' ? 'danger' : 'success'" disable-transitions>
|
||||
@@ -52,6 +54,7 @@ import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -71,6 +74,8 @@ const form = reactive({
|
||||
department: '',
|
||||
position: '',
|
||||
status: '0',
|
||||
unitType: '',
|
||||
accountType: '',
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
@@ -97,6 +102,8 @@ async function loadDetail() {
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
status: d.status || '0',
|
||||
unitType: d.unitType || '',
|
||||
accountType: d.accountType || '',
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!--
|
||||
共享支持单位下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
|
||||
结构对齐 expert/ExpertNew.vue
|
||||
unitType 硬编码 'sponsor', role 硬编码 'supervisor'
|
||||
unitType 硬编码 'sponsor'
|
||||
-->
|
||||
<div class="page-card sponsor-person-new">
|
||||
<div class="breadcrumb">
|
||||
@@ -29,9 +29,6 @@
|
||||
<el-form-item label="职务">
|
||||
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<span style="color:#303133">监察员 <span style="color:#909399;font-size:12px">(支持方人员固定)</span></span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
@@ -128,7 +125,7 @@ async function onSave() {
|
||||
try { await formRef.value.validate() } catch { return }
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form, unitType: 'sponsor', role: 'supervisor' }
|
||||
const payload = { ...form, unitType: 'sponsor' }
|
||||
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
|
||||
if (!isEdit && orgId.value) payload.orgId = orgId.value
|
||||
if (isEdit) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
|
||||
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
|
||||
|
||||
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
|
||||
@@ -27,7 +27,6 @@ const formRef = ref(null)
|
||||
|
||||
const rules = {
|
||||
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phonenumber: [{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
oldPassword: [{ required: false }],
|
||||
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
|
||||
confirmPassword: [{
|
||||
|
||||
@@ -64,20 +64,6 @@
|
||||
<el-input v-model="form.position" placeholder="请输入职务" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色" prop="role">
|
||||
<!--
|
||||
统一用 el-select, 不论新建/编辑:
|
||||
- 管理员 选项常驻, 但 :disabled=true 永远不能选 (一公司一管理员只在注册时建, 不能从 UI 任命)
|
||||
- 编辑模式整个 el-select :disabled=true (灰), 不可改 (与"操作列禁用本人删除"语义一致)
|
||||
- 解决历史主账号 role=NULL 时空白显示问题 (select 至少能稳定渲染当前值)
|
||||
-->
|
||||
<el-select v-model="form.role" placeholder="请选择" style="width:100%" :disabled="isEdit">
|
||||
<el-option label="监察员" value="supervisor" />
|
||||
<el-option label="管理员" value="admin" :disabled="true" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
@@ -130,7 +116,6 @@ const form = reactive({
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: '',
|
||||
role: 'supervisor',
|
||||
unitType: 'sponsor',
|
||||
status: '0',
|
||||
// 子账号登录信息 (后端会创建 sys_user SUB,parent_user_id=主账号, 字段名跟后端 @JsonProperty 对齐)
|
||||
@@ -148,7 +133,6 @@ const rules = {
|
||||
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }],
|
||||
department:[{ required: true, message: '请输入部门', trigger: 'blur' }],
|
||||
position: [{ required: true, message: '请输入职务', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
userName: [
|
||||
{ required: true, message: '请输入子账号登录用户名', trigger: 'blur' },
|
||||
@@ -199,7 +183,6 @@ async function loadDetail() {
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
role: d.role || '',
|
||||
unitType: d.unitType || 'sponsor',
|
||||
status: d.status != null ? String(d.status) : '0',
|
||||
})
|
||||
@@ -217,7 +200,7 @@ async function loadDetail() {
|
||||
|
||||
function confirmCancel() {
|
||||
// 检查表单是否有内容 (新建时只要 form.name 即可)
|
||||
const hasContent = form.name || form.phone || form.orgName || form.department || form.position || form.role
|
||||
const hasContent = form.name || form.phone || form.orgName || form.department || form.position
|
||||
if (!isEdit && !hasContent) { goBack(); return }
|
||||
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色">
|
||||
<!-- 主账号 role=admin(管理员), 子账号 role=supervisor(监察员), 都走 roleMap 翻译 -->
|
||||
<el-tag v-if="detail.role === 'admin' || detail.role === 'supervisor'"
|
||||
:type="detail.role === 'admin' ? 'warning' : 'primary'" disable-transitions>
|
||||
{{ translatePersonRole(detail.role) }}
|
||||
<!-- 角色与账号类型合并: 由 account_type + unitType 推导 (MAIN=管理员, SUB=监察员) -->
|
||||
<el-tag v-if="accountTypeRoleLabel(detail.unitType, detail.accountType)"
|
||||
:type="accountTypeRoleTagType(detail.accountType)" disable-transitions>
|
||||
{{ accountTypeRoleLabel(detail.unitType, detail.accountType) }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#909399">{{ display.role }}</span>
|
||||
<span v-else style="color:#909399">—</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -83,7 +83,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { translatePersonRole } from '@/utils/roleMap'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -106,7 +106,6 @@ const display = computed(() => ({
|
||||
orgName: fmt(detail.value.orgName),
|
||||
department: fmt(detail.value.department),
|
||||
position: fmt(detail.value.position),
|
||||
role: fmt(detail.value.role),
|
||||
createTime: fmtTime(detail.value.createTime),
|
||||
}))
|
||||
|
||||
|
||||
@@ -258,9 +258,9 @@ async function load() {
|
||||
|
||||
async function loadPersons() {
|
||||
try {
|
||||
// sponsor 团队下"监察员"角色 + 已绑 sys_user 的子账号
|
||||
const r = await bizList('person', { pageNum: 1, pageSize: 100, unitType: 'sponsor', role: 'supervisor' })
|
||||
personList.value = ((r.data && r.data.rows) || r.rows || []).filter(p => p.userId)
|
||||
// sponsor 团队下"监察员" (SUB 子账号), 排除主账号
|
||||
const r = await bizList('person', { pageNum: 1, pageSize: 100, unitType: 'sponsor' })
|
||||
personList.value = ((r.data && r.data.rows) || r.rows || []).filter(p => p.userId && p.accountType === 'SUB')
|
||||
} catch (e) { personList.value = [] }
|
||||
}
|
||||
|
||||
@@ -302,8 +302,8 @@ function onCreateMeeting(row) {
|
||||
}
|
||||
|
||||
function onSupportUnit(row) {
|
||||
if (!row.sponsorAdminUserId) { ElMessage.warning('该项目未关联支持方负责人'); return }
|
||||
ElMessage.info(`查看支持方负责人: ${row.sponsorAdminUserName || row.sponsorAdminUserId}`)
|
||||
if (!row.sponsorOrgId) { ElMessage.warning('该项目未关联支持方'); return }
|
||||
ElMessage.info(`查看支持方: ${row.sponsorAdminUserName || row.sponsorOrgId}`)
|
||||
}
|
||||
async function onDeleteNotice(row) {
|
||||
try { await ElMessageBox.confirm(`确定删除「${row.projectName}」的所有公告吗?删除后无法恢复`, '删除公告', { type: 'warning' }) } catch { return }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
筛选栏: <el-form inline :model="q" class="filter-form">
|
||||
工具栏: <div class="toolbar">
|
||||
列表列 (按原型 sponsor-people.html left 排序):
|
||||
复选框 / 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色 / 账号类型 / 状态 / 操作
|
||||
复选框 / 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色(角色+账号类型已合并) / 状态 / 操作
|
||||
-->
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 人员管理</div>
|
||||
@@ -50,22 +50,14 @@
|
||||
<el-table-column prop="orgName" label="所属公司" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" width="120" />
|
||||
<el-table-column prop="position" label="职务" width="120" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }">
|
||||
<!-- 主账号 role=admin(管理员/总负责人), 子账号 role=supervisor(监察员), 都走 roleMap 翻译 -->
|
||||
<el-tag :type="row.role === 'admin' ? 'warning' : (row.role === 'supervisor' ? 'primary' : 'info')" disable-transitions>
|
||||
{{ translatePersonRole(row.role) || '—' }}
|
||||
<!-- 角色与账号类型合并: 由 account_type + unitType 推导 (MAIN=管理员, SUB=监察员) -->
|
||||
<el-tag :type="accountTypeRoleTagType(row.accountType)" disable-transitions>
|
||||
{{ accountTypeRoleLabel(row.unitType, row.accountType) || '—' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.accountType" :type="row.accountType === 'MAIN' ? 'success' : 'warning'" disable-transitions>
|
||||
{{ row.accountType === 'MAIN' ? '主账号' : '子账号' }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#909399">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'danger'" disable-transitions>
|
||||
@@ -148,7 +140,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizUpdate, bizDelete } from '@/api/public'
|
||||
import { listSponsorPerson } from '@/api/business/person'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { translatePersonRole } from '@/utils/roleMap'
|
||||
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -308,9 +308,9 @@ const allMonitors = ref([])
|
||||
async function loadMonitors() {
|
||||
// 跟 sponsor/people (人员管理) 完全一致: 走 sponsorList, SQL 硬编码 unit_type='sponsor'
|
||||
// + (u.parent_user_id = mainUid OR u.user_id = mainUid), 自动取当前主账号
|
||||
// 仅筛 role='supervisor' (监察员候选人)
|
||||
const res = await listSponsorPerson({ role: 'supervisor', pageNum: 1, pageSize: 100 })
|
||||
allMonitors.value = res.rows || []
|
||||
// 仅取 SUB 子账号 (监察员候选人), 排除主账号
|
||||
const res = await listSponsorPerson({ pageNum: 1, pageSize: 100 })
|
||||
allMonitors.value = (res.rows || []).filter(p => p.accountType === 'SUB')
|
||||
}
|
||||
/** 拉项目当前已分配监察员 → 回显 monitorUserIds (单选模式) */
|
||||
async function loadCurrentAssigns(projectId) {
|
||||
|
||||
Reference in New Issue
Block a user