feat: 会议ID应用赋值 + 会议表单/详情优化 + admin工作台统计与用户管理

- meetingId 由 DB 自增改为应用赋值 10 位数字 (yyMMdd + 4 位序列, Redis 按日期计数)
- 会议开始时间不能晚于结束时间校验; 参会人表单身份证附件/角色单独一行
- 会议详情左右列比例 7:3 -> 8:2 (材料审核列收窄)
- admin/workbench 用户总数/角色类型数/各角色分布统计修复 (改用 listByRole 按 role_type 过滤)
- 新增 admin 用户管理接口 + 门户页脚 + H5 签到/上传优化
This commit is contained in:
郭庆泰
2026-08-25 23:33:14 +08:00
parent 591a43fe61
commit ea2024d083
66 changed files with 1454 additions and 1228 deletions
+5
View File
@@ -91,6 +91,8 @@ export const markMessageRead = (msgId) => request.put(`/business/message/read/${
export const markAllMessagesRead = () => request.put('/business/message/readAll')
export const bizGet = (entity, id) => request.get(`/business/${entity}/${id}`)
// executor 建会页"总场次": 分配给本执行方(公司)的场次 (非项目总场次)
export const getProjectAssignedSessions = (projectId) => request.get(`/business/project/${projectId}/assignedSessions`)
// opts 透传给 request (例如 { __silentError: true } 让拦截器不自动 toast, 由调用方控制)
export const bizAdd = (entity, data, opts) => request.post(`/business/${entity}`, data, opts)
export const bizUpdate = (entity, data, opts) => request.put(`/business/${entity}`, data, opts)
@@ -103,6 +105,9 @@ export const bizDelete = (entity, ids) => request.delete(`/business/${entity}/${
// 更换机构管理员 (admin/sponsor-people 管理员 switch): 目标人员晋升 MAIN, 原管理员降 SUB
export const changePersonAdmin = (personId) => request.put('/business/person/changeAdmin', { personId })
// 重置人员登录密码 (默认 123456)
export const resetPersonPassword = (personId) => request.put('/business/person/resetPassword', { personId })
// 人员批量导入 (unitType: 'sponsor' | 'executor')
export const importPerson = (unitType, file, orgId) => {
const form = new FormData()
+13 -3
View File
@@ -54,6 +54,7 @@ import { uploadToOss } from '@/utils/oss'
const props = defineProps({
modelValue: { type: String, default: '' },
name: { type: String, default: '' }, // 原文件名 (v-model:name 回传, 用于显示而非 OSS key)
dir: { type: String, default: 'ry8080/file/' },
placeholder: { type: String, default: '点击上传文件' },
hint: { type: String, default: '' },
@@ -64,16 +65,23 @@ const props = defineProps({
showPreview: { type: Boolean, default: true } // 右侧预览框 (图片/PDF/其他)
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'update:name'])
const fileInput = ref(null)
const uploading = ref(false)
const fileName = computed(() => {
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
if (props.name) return props.name
if (!props.modelValue) return ''
// 取 URL 最后一段, 去 query 参数
// 取 URL 最后一段, 去 query 参数; 上传时已对路径做了 encodeURIComponent, 这里先还原
const url = props.modelValue.split('?')[0]
return url.substring(url.lastIndexOf('/') + 1)
let last = url.substring(url.lastIndexOf('/') + 1)
try { last = decodeURIComponent(last) } catch (e) { /* 已解码/旧 key, 原样用 */ }
// 新 key 格式: 原名_时间戳_随机串.扩展名 → 还原 原名.扩展名; 旧 key (无原名) 原样返回
const m = last.match(/^(.+)_\d{13}_[a-z0-9]{6}(\.[^.]+)?$/)
if (m) return m[1] + (m[2] || '')
return last
})
const ext = computed(() => {
const n = fileName.value
@@ -127,6 +135,7 @@ async function onFileChange(e) {
try {
const url = await uploadToOss(file, props.dir)
emit('update:modelValue', url)
emit('update:name', file.name)
ElMessage.success('上传成功')
} catch (err) {
ElMessage.error(err.message || '上传失败')
@@ -137,6 +146,7 @@ async function onFileChange(e) {
function onRemove() {
emit('update:modelValue', '')
emit('update:name', '')
}
</script>
+112
View File
@@ -0,0 +1,112 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
<a href="https://v2.bahim.org.cn/computer/#/home" target="_blank" rel="noopener">北京整合医学学会首页</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a href="https://bxxcx.bahim.org.cn/#/" target="_blank" rel="noopener">百姓巡常行</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E5%81%A5%E5%BA%B7%E4%B8%AD%E5%9B%BD%E8%A1%8C%E5%8A%A8%E7%B3%BB%E5%88%97%E9%87%8D%E7%82%B9%E9%A1%B9%E7%9B%AE?columnId=59&projectId=5" target="_blank" rel="noopener">健康中国行动</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E4%B9%A1%E6%9D%91%E5%B9%B8%E7%A6%8F%E5%AE%89%E5%BA%B7%E5%B7%A5%E7%A8%8B?columnId=25&projectId=2" target="_blank" rel="noopener">乡村幸福安康工程</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E4%B8%B4%E5%BA%8A%E7%A7%91%E7%A0%94%E8%B5%84%E5%8A%A9%E8%AE%A1%E5%88%92?columnId=27&projectId=4" target="_blank" rel="noopener">临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
if (router.currentRoute.value.path === '/') {
window.scrollTo({ top: 0, behavior: 'smooth' })
} else {
router.push('/')
}
}
function goPublicity() { router.push('/publicity') }
</script>
<style scoped>
.container { max-width: 1354px; margin: 0 auto; padding: 0 60px; }
.footer { background: var(--brand-primary-darker); color: rgba(255, 255, 255, 0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px; }
}
</style>
+3 -92
View File
@@ -43,52 +43,7 @@
</main>
<!-- ========== 底部 ========== -->
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -97,6 +52,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import PortalFooter from '@/components/PortalFooter.vue'
defineProps({
activeNav: { type: String, default: '' } // 'home' | 'publicity' | ''
@@ -112,7 +68,7 @@ const userName = computed(() => userStore.user?.userName || '用户')
function handleScroll() { isScrolled.value = window.scrollY > 10 }
function goHome() { isScrolled.value = false; router.push('/portal/home') }
function goHome() { isScrolled.value = false; router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() {
@@ -203,53 +159,8 @@ a { color: inherit; text-decoration: none; }
/* ========== 主体容器 ========== */
.portal-main { min-height: calc(100vh - 72px - 350px); } /* 给 footer 留位 */
.container { max-width: 1354px; margin: 0 auto; padding: 0 60px; }
/* ========== 底部 ========== */
.footer { background: var(--brand-primary-darker); color: rgba(255, 255, 255, 0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.top-nav { padding: 0 24px; }
.container { padding: 0 24px; }
}
</style>
+39 -16
View File
@@ -1,10 +1,22 @@
<template>
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="fileUrl" class="preview-box">
<!-- 多图 (身份证正反面等): 并排缩略图, 点开走 el-image 全屏轮播 -->
<div v-if="isMulti" class="preview-gallery">
<el-image
v-for="(f, i) in files"
:key="i"
:src="f"
:preview-src-list="files"
:initial-index="i"
fit="contain"
class="preview-gallery-item"
/>
</div>
<div v-else-if="primary" class="preview-box">
<!-- 图片 -->
<img v-if="kind === 'image'" :src="fileUrl" class="preview-img" :alt="title" />
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
<!-- PDFiframe 预览浏览器内置 viewer -->
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(fileUrl)" class="preview-iframe" />
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(primary)" class="preview-iframe" />
<!-- 其它doc/xls/zip 给下载链接 -->
<div v-else class="preview-fallback">
<el-icon class="fallback-icon"><Document /></el-icon>
@@ -16,7 +28,7 @@
<el-empty v-else description="暂无附件" />
<template #footer>
<el-button @click="open = false">关闭</el-button>
<el-button v-if="fileUrl" type="primary" @click="openNew">在新窗口打开</el-button>
<el-button v-if="primary && !isMulti" type="primary" @click="openNew">在新窗口打开</el-button>
</template>
</el-dialog>
</template>
@@ -29,6 +41,8 @@ import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: Boolean,
url: { type: String, default: '' },
// 多文件列表 (身份证正反面等): 传入后优先于 url, 走多图画廊预览
urls: { type: Array, default: () => [] },
title: { type: String, default: '附件预览' }
})
const emit = defineEmits(['update:modelValue'])
@@ -38,14 +52,21 @@ const open = computed({
set: v => emit('update:modelValue', v)
})
const fileUrl = computed(() => props.url || '')
const kind = computed(() => {
const u = fileUrl.value.split('?')[0].toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(u)) return 'image'
if (/\.pdf(\?.*)?$/.test(u)) return 'pdf'
return 'other'
/** 统一文件列表: urls 优先, 否则 url 单条; 空段过滤 */
const files = computed(() => {
if (Array.isArray(props.urls) && props.urls.length) return props.urls.filter(Boolean)
return props.url ? [props.url] : []
})
const primary = computed(() => files.value[0] || '')
const isMulti = computed(() => files.value.length > 1)
function kindOf(u) {
const x = (u || '').split('?')[0].toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(x)) return 'image'
if (/\.pdf(\?.*)?$/.test(x)) return 'pdf'
return 'other'
}
const kind = computed(() => kindOf(primary.value))
// OSS bucket 设置了 Content-Disposition: attachment, iframe 直接访问会被强制下载 (空白+下载)
// PDF 走 /common/oss/proxy 后端代理重写为 inline (与 publicity/{id} / doctor/Meetings / manager/Plans 同技术)
@@ -59,13 +80,13 @@ function proxyUrl(url) {
}
function openNew() {
if (!fileUrl.value) return
window.open(proxyUrl(fileUrl.value), '_blank', 'noopener')
if (!primary.value) return
window.open(proxyUrl(primary.value), '_blank', 'noopener')
}
async function copyUrl() {
if (!fileUrl.value) return
if (!primary.value) return
try {
await navigator.clipboard.writeText(fileUrl.value)
await navigator.clipboard.writeText(primary.value)
ElMessage.success('链接已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
@@ -80,4 +101,6 @@ async function copyUrl() {
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
.preview-fallback p { margin: 12px 0 24px; }
</style>
.preview-gallery { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; align-items: flex-start; min-height: 320px; padding: 8px 0; }
.preview-gallery-item { width: 320px; max-width: 48%; max-height: 60vh; border: 1px solid #ebeef5; border-radius: 4px; }
</style>
+6 -1
View File
@@ -33,7 +33,7 @@
<el-container>
<el-header v-if="!route.meta?.hideMenu" class="topbar">
<div class="topbar-left">
<span class="page-title">{{ route.meta?.title || '工作台' }}</span>
<span class="page-title">{{ headerTitle }}</span>
</div>
<div class="topbar-right">
<el-button text @click="$router.push('/')">门户首页</el-button>
@@ -70,6 +70,7 @@ import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { sseStart, sseStop, onGlobal } from '@/utils/sseClient'
import { listMyMessages } from '@/api/public'
import { usePageTitle } from '@/utils/pageTitle'
import { getMyExpertProfile } from '@/api/business/expert'
import { ElNotification } from 'element-plus'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection } from '@element-plus/icons-vue'
@@ -79,6 +80,10 @@ const router = useRouter()
const store = useUserStore()
const role = computed(() => route.meta?.role || store.role)
// 顶栏标题: 页面动态标题 (如会议 新建/修改/复制) 优先, 否则回退 route.meta.title
const dynamicTitle = usePageTitle()
const headerTitle = computed(() => dynamicTitle.value || route.meta?.title || '工作台')
// ===== 全局未读数 (navbar bell 角标) =====
// AdminLayout 是唯一永驻组件, 由它统一维护 unreadCount,
// 任何路由切换都不会丢订阅, 永远正确
+3 -14
View File
@@ -9,20 +9,10 @@
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
*/
const H24 = 24 * 3600 * 1000
function isTrue(v) {
return v === 1 || v === '1' || v === true
}
/** 待结算: 材料审核通过 且 材料审核时间已超 24h */
function settlementReady(row) {
if (row.materialAuditStage !== 'APPROVED') return false
const mat = row.materialAuditTime ? new Date(row.materialAuditTime).getTime() : 0
if (!mat) return false
return Date.now() - mat >= H24
}
/**
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
*/
@@ -33,7 +23,7 @@ export function derivePhysicalStage(row) {
if (isTrue(row.isSettled)) return 'SETTLED'
const material = row.materialAuditStage
if (material === 'REJECTED') return 'RECTIFYING'
if (material === 'APPROVED') return settlementReady(row) ? 'AWAITING_SETTLEMENT' : 'SUPERVISION_APPROVED'
if (material === 'APPROVED') return 'AWAITING_SETTLEMENT'
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
}
@@ -55,9 +45,9 @@ export function deriveStage(role, row) {
return role === 'executor' ? '已退回' : '待整改'
}
// 材料已支持方通过
// 材料已支持方通过 → 待结算 (不再有 24h 慢路径)
if (material === 'APPROVED') {
return settlementReady(row) ? '待结算' : '审核通过'
return '待结算'
}
// 材料在审 (SUBMITTED)
@@ -120,7 +110,6 @@ export const STAGE_OPTIONS = [
{ label: '执行中', value: 'RUNNING' },
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
{ label: '待支持方审核', value: 'AWAITING_SUPERVISION' },
{ label: '审核通过', value: 'SUPERVISION_APPROVED' },
{ label: '待整改', value: 'RECTIFYING' },
{ label: '待结算', value: 'AWAITING_SETTLEMENT' },
{ label: '已结算', value: 'SETTLED' },
+11 -3
View File
@@ -45,8 +45,14 @@ function getMimeByExt(name) {
export async function uploadToOss(file, dir) {
const sign = await getOssSign(dir)
const ext = (file.name || '').split('.').pop() || 'bin'
const key = sign.dir + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.' + ext
// 原文件名进 OSS key: 原始名称_时间戳_随机串.原扩展名 (保留中文, 仅清洗 URL/路径危险字符)
// 这样上传控件能从 URL 里还原原名显示, 无需额外 name 字段/列
const rawName = file.name || 'file'
const dot = rawName.lastIndexOf('.')
const ext = (dot > 0 ? rawName.slice(dot) : '').replace(/[\\/:*?"<>|%\s]+/g, '')
const base = (dot > 0 ? rawName.slice(0, dot) : rawName)
.replace(/[\\/:*?"<>|%\s]+/g, '_') || 'file'
const key = sign.dir + base + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + ext
// 显式传 Content-Type,让 OSS 按此存文件元数据, 浏览器拿到 application/pdf 即可内嵌预览
const mime = getMimeByExt(file.name)
@@ -68,5 +74,7 @@ export async function uploadToOss(file, dir) {
if (!resp.ok) {
throw new Error('OSS 上传失败: HTTP ' + resp.status)
}
return sign.host + '/' + key
// 返回 URL 时对路径逐段编码 (保留 / 分隔), 让含中文/特殊字符的 key 在所有下载场景下都是 ASCII 安全 URL
// OSS 对象 key 本身仍是原始值 (FormData 的 key 字段未编码), 下载时 OSS 会自动把 %XX 还原
return sign.host + '/' + key.split('/').map(encodeURIComponent).join('/')
}
+18
View File
@@ -0,0 +1,18 @@
import { ref } from 'vue'
// navbar 顶栏动态标题 (AdminLayout 顶栏优先读它, 覆盖 route.meta.title)
// 页面组件进入时 setPageTitle, 离开时 onBeforeUnmount(clearPageTitle),
// 否则切到别的页面会残留上一个页面的标题.
const dynamicTitle = ref('')
export function setPageTitle(t) {
dynamicTitle.value = t || ''
}
export function clearPageTitle() {
dynamicTitle.value = ''
}
export function usePageTitle() {
return dynamicTitle
}
+174 -4
View File
@@ -6,6 +6,7 @@
<el-form inline :model="q" class="filter-form">
<el-form-item label="用户账号"><el-input v-model="q.userName" placeholder="输入用户账号" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.nickName" placeholder="输入姓名" clearable /></el-form-item>
<el-form-item label="单位名称"><el-input v-model="q.orgName" placeholder="输入单位名称" clearable /></el-form-item>
<el-form-item label="角色">
<el-select v-model="q.roleType" placeholder="全部角色" clearable style="width:140px">
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
@@ -23,6 +24,11 @@
</el-form-item>
</el-form>
<!-- 新建用户工具栏 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<!-- ========== 表格 ( People.vue 风格一致, page-card 包裹) ========== -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="80" />
@@ -31,6 +37,7 @@
<el-table-column prop="roleType" label="角色" width="100">
<template #default="{ row }">{{ ROLE_LABEL[row.roleType] || row.roleType }}</template>
</el-table-column>
<el-table-column prop="orgName" label="单位名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="phonenumber" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80">
@@ -39,9 +46,10 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="330" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<el-button size="small" link :type="row.status === '0' ? 'danger' : 'success'" @click="toggleStatus(row)">
{{ row.status === '0' ? '停用' : '启用' }}
</el-button>
@@ -94,11 +102,87 @@
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 新建用户 dialog (按角色级联) -->
<el-dialog v-model="createVisible" title="新建用户" width="560px">
<el-form :model="createForm" label-width="110px">
<el-form-item label="角色" required>
<el-select v-model="createForm.roleType" placeholder="请选择角色" style="width:100%" @change="onRoleChange">
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
</el-select>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="createForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="登录账号" required>
<el-input v-model="createForm.userName" placeholder="4-20 位字母/数字/下划线" />
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="createForm.password" type="password" show-password placeholder="6-20 位" />
</el-form-item>
<!-- admin / manager: 简单账号 -->
<template v-if="isSimpleRole">
<el-form-item label="姓名"><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
</template>
<!-- doctor: 评审专家 -->
<template v-else-if="createForm.roleType === 'doctor'">
<el-form-item label="姓名" required><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号" required><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="createForm.workUnit" /></el-form-item>
<el-form-item label="科室"><el-input v-model="createForm.department" /></el-form-item>
<el-form-item label="职称"><el-input v-model="createForm.title" /></el-form-item>
</template>
<!-- executor / sponsor: 主账号 / 子账号 -->
<template v-else-if="isUnitRole">
<el-form-item label="账号类型" required>
<el-radio-group v-model="createForm.accountType" @change="onAccountTypeChange">
<el-radio value="MAIN">主账号(新建单位)</el-radio>
<el-radio value="SUB">子账号(选已有单位)</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="createForm.accountType === 'MAIN'">
<el-form-item label="单位名称" required><el-input v-model="createForm.orgName" /></el-form-item>
<el-form-item label="企业性质"><el-input v-model="createForm.businessNature" /></el-form-item>
<el-form-item label="联系人"><el-input v-model="createForm.contactName" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="createForm.contactPhone" maxlength="11" /></el-form-item>
</template>
<template v-else>
<el-form-item label="所属单位" required>
<el-select v-model="createForm.orgId" filterable 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>
<el-form-item label="姓名" required><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号" required><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
<el-form-item label="部门"><el-input v-model="createForm.department" /></el-form-item>
<el-form-item label="职务"><el-input v-model="createForm.position" /></el-form-item>
</template>
</template>
<el-form-item label="状态">
<el-radio-group v-model="createForm.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="saveCreate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { listUser } from '@/api/system'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -112,7 +196,7 @@ const ROLE_LABEL = {
}
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
const q = reactive({ userName: '', nickName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
const q = reactive({ userName: '', nickName: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
@@ -131,7 +215,7 @@ async function load() {
}
function reset() {
q.userName = ''; q.nickName = ''; q.roleType = ''; q.status = ''
q.userName = ''; q.nickName = ''; q.orgName = ''; q.roleType = ''; q.status = ''
q.pageNum = 1
load()
}
@@ -174,6 +258,22 @@ async function toggleStatus(row) {
}
}
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.userName}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await request({ url: '/system/user/resetPwd', method: 'put', data: { userId: row.userId, password: '123456' } })
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function delUser(row) {
try {
await ElMessageBox.confirm(
@@ -191,6 +291,75 @@ async function delUser(row) {
}
}
// ========== 新建用户 (按角色级联) ==========
const createVisible = ref(false)
const orgOptions = ref([])
const createForm = reactive({
roleType: '', userName: '', nickName: '', password: '', phonenumber: '',
email: '', status: '0',
workUnit: '', department: '', title: '', position: '',
practiceCertUrl: '', titleCertUrl: '',
accountType: 'MAIN', orgId: null, orgName: '', businessNature: '',
contactName: '', contactPhone: ''
})
const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager')
const isUnitRole = computed(() => createForm.roleType === 'executor' || createForm.roleType === 'sponsor')
function openCreate() {
Object.assign(createForm, {
roleType: '', userName: '', nickName: '', password: '', phonenumber: '',
email: '', status: '0',
workUnit: '', department: '', title: '', position: '',
practiceCertUrl: '', titleCertUrl: '',
accountType: 'MAIN', orgId: null, orgName: '', businessNature: '',
contactName: '', contactPhone: ''
})
orgOptions.value = []
createVisible.value = true
}
function onRoleChange() {
createForm.accountType = 'MAIN'
orgOptions.value = []
}
async function onAccountTypeChange(val) {
orgOptions.value = []
if (val === 'SUB' && isUnitRole.value) await loadOrgOptions()
}
async function loadOrgOptions() {
try {
const r = await request({
url: '/business/org/list',
method: 'get',
params: { orgType: createForm.roleType, pageSize: 500 }
})
orgOptions.value = (r.data && r.data.rows) || r.rows || []
} catch (e) { /* GET 错误拦截器已统一 toast, 这里静默 */ }
}
async function saveCreate() {
if (!createForm.roleType) return ElMessage.warning('请选择角色')
if (!createForm.userName) return ElMessage.warning('请填写登录账号')
if (!createForm.password) return ElMessage.warning('请填写密码')
if (isUnitRole.value) {
if (createForm.accountType === 'MAIN' && !createForm.orgName) return ElMessage.warning('请填写单位名称')
if (createForm.accountType === 'SUB' && !createForm.orgId) return ElMessage.warning('请选择所属单位')
}
if ((createForm.roleType === 'doctor' || (isUnitRole.value && createForm.accountType === 'SUB')) && !createForm.nickName) {
return ElMessage.warning('请填写姓名')
}
try {
await request({ url: '/business/adminUser/create', method: 'post', data: { ...createForm } })
ElMessage.success('新建成功')
createVisible.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '新建失败')
}
}
onMounted(load)
</script>
@@ -199,5 +368,6 @@ onMounted(load)
.admin-users { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.toolbar { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
+7 -5
View File
@@ -41,11 +41,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { listUser } from '@/api/system'
import { listUser, listByRole } from '@/api/system'
import { bizList } from '@/api/public'
const stats = ref({
totalUsers: 0, roleCount: 6, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
totalUsers: 0, roleCount: 0, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
})
const roleStats = ref([])
@@ -58,20 +58,22 @@ async function load() {
try {
// 用户总数 (sys_user)
const u = await listUser({ pageNum: 1, pageSize: 1 })
stats.value.totalUsers = u.total || 0
stats.value.totalUsers = (u.data && u.data.total) || 0
// 各角色用户数
const all = []
const codes = Object.keys(ROLE_LABEL)
for (const code of codes) {
try {
const r = await listUser({ pageNum: 1, pageSize: 1, roleType: code })
all.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || r.total || 0 })
const r = await listByRole({ pageNum: 1, pageSize: 1, roleType: code })
all.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || 0 })
} catch (e) {
all.push({ code, label: ROLE_LABEL[code], count: 0 })
}
}
roleStats.value = all
// 角色类型数 = 实际定义的业务角色数 (admin/manager/doctor/executor/sponsor = 5)
stats.value.roleCount = codes.length
// 项目数
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
+11 -14
View File
@@ -208,14 +208,6 @@ const registerTypes = [
{ value: 'sponsor', name: '项目支持单位', desc: '注册后默认为管理员角色, 可建监察员子账号' }
]
const userTypes = [
{ value: 'admin', name: '系统管理员', desc: '系统配置、用户管理、权限控制' },
{ value: 'manager', name: '项目经理', desc: '项目管理、方案审核、过程监督' },
{ value: 'doctor', name: '评审专家', desc: '项目评审、评分反馈、查看任务' },
{ value: 'executor', name: '执行单位', desc: '会议组织、人员安排、劳务结算' },
{ value: 'sponsor', name: '支持单位', desc: '支持函管理、人员管理、合作记录' },
]
async function loadCaptcha() {
await runCaptchaOnce(async () => {
try {
@@ -245,7 +237,8 @@ async function onSubmit() {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
await afterLogin(res.token, form.username)
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
// 登录是 POST, 拦截器对写操作不自动 toast, 需在此显式弹 (否则锁定/密码错等 msg 看不到)
ElMessage.error(e?.msg || e?.message || '登录失败')
loadCaptcha()
} finally {
loading.value = false
@@ -283,7 +276,7 @@ async function afterLogin(token, displayName) {
ElMessage.error('获取用户信息失败, 请重新登录')
return router.replace({ name: 'login' })
}
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
ElMessage.success(`欢迎,${displayName}`)
// 角色不在角色首页映射里 → 拒绝
if (!roleHome[role]) return router.replace({ name: 'login' })
// 所有角色统一跳门户首页 '/' (业务方 2026-08-22 要求, 各自角色菜单从导航栏进入)
@@ -331,7 +324,8 @@ async function sendLoginSmsCode() {
}
}, 1000)
} catch (e) {
// request.js 已弹错误, 这里只重置 uuid
// 短信发送是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
smsForm.uuid = ''
}
})
@@ -347,7 +341,8 @@ async function onSmsLoginSubmit() {
// 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定
await afterLogin(res.token, smsForm.phone)
} catch (e) {
// request.js 已弹错误
// 短信登录是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '登录失败')
}
})
}
@@ -437,7 +432,8 @@ async function sendForgotSms() {
}
}, 1000)
} catch (e) {
// request.js 拦截器已弹 ElMessage.error, 这里只重置
// 忘记密码短信是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
forgotSmsUuid = ''
}
})
@@ -468,7 +464,8 @@ async function onResetSubmit() {
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// request.js 已弹错误
// 重置密码是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '重置失败')
} finally {
forgotLoading.value = false
}
+5 -3
View File
@@ -40,10 +40,10 @@
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
</el-form-item>
<el-form-item label="执业证书/证明">
<el-form-item label="执业证书/证明" prop="licenseCertUrl">
<OssImageUploader v-model="form.licenseCertUrl" dir="ry8080/cert/license/" placeholder="+" />
</el-form-item>
<el-form-item label="职称证明">
<el-form-item label="职称证明" prop="titleCertUrl">
<OssImageUploader v-model="form.titleCertUrl" dir="ry8080/cert/title/" placeholder="+" />
</el-form-item>
@@ -132,7 +132,9 @@ const rules = {
},
trigger: 'blur'
}
]
],
licenseCertUrl: [{ required: true, message: '请上传执业证书/证明', trigger: 'change' }],
titleCertUrl: [{ required: true, message: '请上传职称证明', trigger: 'change' }]
}
const agreed = ref(false)
@@ -65,7 +65,7 @@
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
@@ -75,6 +75,8 @@
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
@@ -123,7 +125,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
@@ -217,6 +219,23 @@ async function onChangeAdmin(row) {
}
}
// ========== 重置密码 ==========
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
// ========== 跳转 (按角色) ==========
function goNew() {
router.push({
@@ -138,7 +138,6 @@ async function onSave() {
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
+75 -48
View File
@@ -2,66 +2,63 @@
<div class="page-card executor-meetings">
<div class="breadcrumb">首页 / 会议列表</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<!-- ========== 筛选区 (仿 manager/meetings/Meetings.vue: 项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" clearable placeholder="输入项目编号" style="width:160px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" /></el-form-item>
<el-form-item label="是否系列会">
<el-select v-model="q.isSeries" clearable placeholder="请选择" style="width:140px">
<el-option label="是" value="1" />
<el-option label="否" value="0" />
</el-select>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
<el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
<span class="date-sep"></span>
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:170px" />
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="q.isFinished" clearable placeholder="请选择" style="width:140px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
<el-form-item label="项目形式">
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="当前阶段">
<el-select v-model="q.currentStage" clearable placeholder="请选择" style="width:140px">
<el-select v-model="q.currentStage" placeholder="请选择" clearable style="width:140px">
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="期数"><el-input v-model="q.sessionNo" clearable placeholder="输入期数" style="width:100px" /></el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width:140px" /></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>
<!-- ========== 表格 ( People.vue 风格一致, page-card 包裹) ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelect">
<el-table-column type="selection" width="48" />
<el-table-column prop="projectNo" label="项目编号" width="120" />
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="isSeries" label="是否系列会" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isSeries === '1' || row.isSeries === '是' ? 'success' : 'info'" size="small">
{{ row.isSeries === '1' || row.isSeries === '是' ? '是' : '否' }}
</el-tag>
</template>
<!-- ========== 表格 (仿 manager/meetings/Meetings.vue 信息列, 去掉 admin/manager 专属列) ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
<el-table-column label="会议开始时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.startTime) }}</template>
</el-table-column>
<el-table-column prop="seriesTopic" label="系列会主题" min-width="160" show-overflow-tooltip />
<el-table-column label="期数/总期数" width="110" align="center">
<template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template>
<el-table-column label="会议结束时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
</el-table-column>
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
<el-table-column label="期数" width="80" align="center">
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
</el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }">
<el-tag :type="row.currentStage === 'RUNNING' ? 'success' : 'info'" size="small">{{ stageLabel('executor', row) }}</el-tag>
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel('executor', row) }}</span>
</template>
</el-table-column>
<el-table-column prop="isFinished" label="是否结题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.isFinished === '1' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
{{ row.isFinished === '1' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onUpload(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>
@@ -78,13 +75,25 @@
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList } from '@/api/public'
import { stageLabel, STAGE_OPTIONS } from '@/utils/meetingStage'
import { stageLabel, STAGE_OPTIONS, stageClass } from '@/utils/meetingStage'
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' })
// 与 manager/meetings/Meetings.vue 一致: 8 项筛选 (项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注)
const q = ref({
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
projectForm: '', currentStage: '', remark: '',
startTime: '', endTime: ''
})
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const selected = ref([])
// 工具: 时间格式 (yyyy.MM.dd HH:mm), 与 manager 列表一致
function fmtTime(d) {
if (!d) return ''
const dt = new Date(d)
const pad = n => String(n).padStart(2, '0')
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
async function load() {
loading.value = true
@@ -96,24 +105,25 @@ async function load() {
finally { loading.value = false }
}
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED + ?isFinished=1, 让列表页自动应用筛选)
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED, 让列表页自动应用筛选)
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
const router = useRouter()
function readQueryFromRoute() {
const q2 = route.query
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
}
function reset() {
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' }
q.value = {
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
projectForm:'', currentStage:'', remark:'',
startTime:'', endTime:''
}
page.pageNum = 1
load()
}
function onSelect(arr) { selected.value = arr }
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
@@ -126,9 +136,26 @@ onMounted(() => { readQueryFromRoute(); load() })
</script>
<style scoped>
/* 整页面板 (与 People.vue 风格一致) */
/* 整页面板 (仿 manager/meetings/Meetings.vue 风格) */
.executor-meetings { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.date-sep { margin: 0 4px; color: #606266; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
/* 当前阶段 tag 颜色 (与 manager 列表一致) */
.stage-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 3px;
font-size: 12px;
border: 1px solid;
}
.stage-pending { color: #909399; border-color: #dcdfe6; background: #f5f7fa; }
.stage-running { color: var(--brand-primary); border-color: #b3c5e0; background: #eef1f8; }
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
.stage-waiting { color: #ff4d4f; border-color: #fbc4c4; background: #fef0f0; }
.stage-frozen { color: #909399; border-color: #dcdfe6; background: #f5f7fa; opacity: 0.6; }
.stage-default { color: #606266; border-color: #dcdfe6; background: #fafafa; }
</style>
+20 -2
View File
@@ -41,10 +41,12 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="onView(row)">查看</el-button>
<el-button size="small" link type="primary" @click="goEdit(row)">编辑</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" size="small" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<!-- 本人不显示禁用/恢复按钮 ( sponsor 一样, 避免主账号把自己禁用) -->
<template v-if="row.userId !== store.user?.userId">
<el-button v-if="row.status === '0'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
@@ -107,7 +109,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { bizUpdate } from '@/api/public'
import { bizUpdate, resetPersonPassword } from '@/api/public'
import { listExecutorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -151,6 +153,22 @@ function goEdit(row) { router.push(`/executor/people/edit/${row.personId}`) }
function onView(row) { router.push(`/executor/people/detail/${row.personId}`) }
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function onToggleStatus(row, action) {
try {
await ElMessageBox.confirm(`确定${action}${row.name}」吗?`, action, { type: 'warning' })
@@ -33,7 +33,7 @@
<el-col :span="12"><el-form-item label="项目负责人"><span class="info-value">{{ row.leadUserName || '-' }}</span></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorOrgName || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目评价"><span class="info-value">{{ fmtScore(row.managerScore) }}</span></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
@@ -89,18 +89,18 @@
<!-- (已分配执行方 已折叠进上方"项目信息"section, 此处不再重复) -->
<!-- ================= 4. 已发布公告 (只读, 仅查看) ================= -->
<div class="new-card-title">已发布公告</div>
<el-table v-loading="loading" :data="announcements" border>
<el-table-column prop="title" label="公告标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="createTime" label="发布时间" width="160" align="center" :formatter="(r)=>fmtDate(r.createTime)" />
<el-table-column prop="creatorName" label="发布人" width="120" align="center" />
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openPreview(row.fileUrl || row.url, row.title)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!-- ================= 4. 公告文件 (只读, 仅查看: 邀请函/支持函/通知/日程) ================= -->
<div class="new-card-title">公告文件</div>
<div class="notice-list">
<div v-for="(n, idx) in notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
<span v-if="n.url" class="notice-file">
<span class="file-name">{{ n.name || '已上传' }}</span>
<el-button link type="primary" @click="openPreview(n.url, n.label)">查看</el-button>
</span>
<span v-else class="file-empty">未上传</span>
</div>
</div>
<!-- 公告预览 (只读) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
@@ -108,9 +108,9 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizGet, bizList } from '@/api/public'
import { bizGet } from '@/api/public'
import { listExecutorOrgs } from '@/api/system'
import request from '@/utils/request'
import Preview from '@/components/Preview.vue'
@@ -135,8 +135,22 @@ const form = reactive({
// 监督员 (从 biz_project.monitors JSON 字段读)
const monitors = ref([])
// 已发布公告
const announcements = ref([])
// 公告文件 (邀请函/支持函/通知/日程) — 数据源 biz_project 4 个独立列, 与 ProjectsNew 反显逻辑一致
// 注: 后端无 publicityFiles 字段 (前端传了但不落库), 实际落库是 invitation_url/support_letter_url/publish_url/schedule_url
const notices = computed(() => {
const r = row.value || {}
const items = [
{ label: '邀请函', url: r.invitationUrl },
{ label: '支持函', url: r.supportLetterUrl },
{ label: '通知', url: r.publishUrl },
{ label: '日程', url: r.scheduleUrl }
]
return items.map(m => ({
label: m.label,
url: m.url,
name: m.url ? (m.url.split('/').pop() || '') : ''
}))
})
// 公告预览
const previewOpen = ref(false)
@@ -186,17 +200,6 @@ async function loadProject() {
}
}
async function loadAnnouncements() {
try {
const resp = await bizList('publicity', { projectId: projectId.value })
const arr = (resp && resp.rows) || ((resp && resp.data) && (resp && resp.data).rows) || (resp && resp.data) || []
announcements.value = Array.isArray(arr) ? arr : []
} catch (e) {
console.error('[project-detail] announcements load failed', e)
announcements.value = []
}
}
// ===================== 已分配执行方 (服务公司) =====================
/**
* 数据源同 manager/projects/edit/{id} 的 loadAssigns:
@@ -272,7 +275,6 @@ function openPreview(url, title) {
onMounted(async () => {
await loadProject()
await Promise.all([loadAssigns(), loadMonitors()])
await loadAnnouncements()
})
</script>
@@ -326,6 +328,14 @@ onMounted(async () => {
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; }
.empty-row { text-align: center; color: #c0c4cc; }
/* 公告文件 (只读 4 行: 邀请函/支持函/通知/日程) */
.notice-list { display: flex; flex-direction: column; gap: 6px; }
.notice-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
.notice-label { width: 90px; flex-shrink: 0; color: #606266; font-size: 14px; }
.notice-file { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; }
.file-name { color: #1a1a1a; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-empty { color: #c0c4cc; font-size: 13px; }
/* 提示 (灰色, 跟 ProjectsNew 一致) */
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.hint-text p { margin-bottom: 4px; }
@@ -66,7 +66,6 @@
<span v-if="singleAmountOver" class="over-warn"> 已超出 ¥ {{ Math.round((assignedAmount - assignForm.totalAmount) * 100) / 100 }}</span>
<span class="sep">|</span>
<span>可用金额: <b>¥ {{ availableAmount }}</b></span>
<span class="hint">(公式: 总金额×(1-管理费/总金额)-累计劳务-累计会务)</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身校验 -->
<div v-else class="assign-sessions-bar">
@@ -84,7 +83,7 @@
placeholder="搜索执行单位"
style="width:100%"
@change="v => onExecUserPick(row, v)">
<el-option v-for="u in (row._options || [])" :key="u.orgId"
<el-option v-for="u in execOptions(row)" :key="u.orgId"
:label="u.orgName" :value="u.orgId" />
</el-select>
</template>
@@ -111,7 +110,7 @@
<el-form-item label="提交截止()" style="margin-top:12px">
<el-input-number v-model="assignForm.deadlineDays" :min="1" :max="100" controls-position="right" />
<span class="hint-text" style="margin-left:12px">自然日, 1~100 天 (超出报错)</span>
<span class="hint-text" style="margin-left:12px">自然日, 1~100 天</span>
</el-form-item>
</el-form>
@@ -326,6 +325,16 @@ function onExecUserPick(row, orgId) {
if (u) { /* 兼容旧 _options 残留, 不写业务字段 */ }
}
// 当前行下拉候选 = _options 排除「其它行已选」的执行单位
// (避免同一项目重复分配同一执行单位; 当前行自己已选的保留, 否则 el-select 已选 label 会空白)
function execOptions(row) {
const excluded = new Set()
for (const r of assignForm.execRows) {
if (r !== row && r.executionUnitId) excluded.add(r.executionUnitId)
}
return (row._options || []).filter(u => !excluded.has(u.orgId))
}
// ========== 初始化 ==========
async function loadSingleProject() {
if (!singleProjectId) return
@@ -101,7 +101,6 @@
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<p class="hint-text">*支持 PDF / 图片, 单文件 10MB; 名称规则: 公告文件类型 + 项目编号后两段 + 项目名称</p>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
@@ -335,11 +334,6 @@ async function submit(mode = 'save') {
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
// announcement_type 取第一个已上传的文件类型
const firstUploaded = uploaded[0]
if (firstUploaded) {
payload.announcementType = firstUploaded.key
}
}
// 多执行方分配已移至"项目分配"页面单独管理
try {
+37 -16
View File
@@ -33,8 +33,9 @@
<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 ). 临时: manager 详情页隐掉 -->
<template v-if="!isManager">
<!-- 监察员 + 执行人员: 暂时整体注释掉 (所有角色都不看, admin). 需要时取消注释恢复 -->
<!--
<template v-if="!isManager && !isExecutor && !isSponsor">
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
@@ -52,6 +53,7 @@
</div>
</div>
</template>
-->
</div>
</div>
@@ -99,7 +101,7 @@
</el-table-column>
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<a v-if="row.idCardAttachments" :href="row.idCardAttachments.split(',')[0]" target="_blank" class="file-link">查看</a>
<el-button v-if="idCardFiles(row).length" link type="primary" size="small" @click="openPreview(idCardFiles(row), '身份证')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -119,7 +121,7 @@
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="protocolUrl(row)" :href="protocolUrl(row)" target="_blank" class="file-link">查看</a>
<el-button v-if="protocolUrl(row)" link type="primary" size="small" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -187,7 +189,7 @@
<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 || 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" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :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>
@@ -206,13 +208,13 @@
<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">
<div v-if="!isExecutor" 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">
<!-- 临时: manager / executor 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !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>
@@ -225,7 +227,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 || isReadonly" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :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>
@@ -250,7 +252,7 @@
<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="!canUploadVoucher" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :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>
@@ -259,7 +261,7 @@
<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="!canUploadVoucher" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :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>
@@ -275,7 +277,7 @@
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<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>
<el-button v-if="!isSponsor && !isReadonly && materialsEditable" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</div>
@@ -314,7 +316,7 @@
<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 v-if="!(round.compliance?.auditResult === 'REJECTED')" :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
@@ -388,7 +390,7 @@
<el-form-item label="身份证号">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</el-form-item>
<el-form-item label="身份证附件">
<el-form-item label="身份证附件" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
</el-form-item>
<el-form-item label="科室">
@@ -416,7 +418,7 @@
<el-form-item label="银行详细地址">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色">
<el-form-item label="角色" style="grid-column: 1 / -1">
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
</el-form-item>
<el-form-item label="应发金额">
@@ -497,6 +499,7 @@
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
@@ -517,6 +520,7 @@ import CameraQrUpload from '@/components/CameraQrUpload.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
@@ -543,6 +547,10 @@ const genPosterVisible = ref(false)
const genForm = reactive({ addText: false, textColor: '#FFFFFF' })
/** 海报预览 dialog 显隐 */
const posterPreviewVisible = ref(false)
/** 通用附件预览 (身份证多图 / 劳务协议 PDF), 复用 components/Preview.vue */
const previewOpen = ref(false)
const previewUrls = ref([])
const previewTitle = ref('附件预览')
const loading = ref(false)
const activeTab = ref('labor')
const saving = ref(false)
@@ -608,6 +616,10 @@ function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
}
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
@@ -720,6 +732,8 @@ 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))
// 材料可编辑 (保存修改): 未提交 / 被驳回 时; 提交审核后 (SUBMITTED) / 通过后 (APPROVED) 锁定, 隐藏保存按钮
const materialsEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
function canComplianceAudit() {
if (!isManager.value) return false
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
@@ -1501,6 +1515,13 @@ function openPosterPreview() {
posterPreviewVisible.value = true
}
/** 通用附件预览: urls 数组 → Preview 组件 (多图走画廊, 单 PDF 走 iframe) */
function openPreview(urls, title) {
previewUrls.value = Array.isArray(urls) ? urls : [urls]
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
async function onSave() {
if (saving.value) return
saving.value = true
@@ -1803,7 +1824,7 @@ onBeforeUnmount(stopFeePolling)
.info-value.fee-val { font-weight: 600; color: #f5222d; }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 3fr); gap: 16px; align-items: flex-start; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.
+30 -2
View File
@@ -51,9 +51,10 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizGet, bizUpdate } from '@/api/public'
import { bizAdd, bizGet, bizUpdate, getProjectAssignedSessions } from '@/api/public'
import { setPageTitle, clearPageTitle } from '@/utils/pageTitle'
import { ElMessage } from 'element-plus'
const route = useRoute()
@@ -82,6 +83,10 @@ const mode = computed(() => {
})
const pageTitle = computed(() => ({ new: '新建会议', edit: '修改会议', copy: '复制会议' }[mode.value]))
// 同步标题到 navbar 顶栏 (route.meta 是 shallowRef 不响应嵌套改动, 改用共享 pageTitle util)
watchEffect(() => { setPageTitle(pageTitle.value) })
onBeforeUnmount(clearPageTitle)
const formRef = ref(null)
const submitting = ref(false)
@@ -121,6 +126,14 @@ async function loadProjectSnapshot(pid) {
snap.projectName = d.projectName || ''
snap.totalSessions = d.totalSessions ?? ''
snap.sponsorOrgName = d.sponsorOrgName || ''
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
if (roleSegment.value === 'executor') {
try {
const ar = await getProjectAssignedSessions(pid)
const ad = (ar && ar.data) || {}
if (ad.assignedSessions != null) snap.totalSessions = ad.assignedSessions
} catch (e) { /* 静默, 不阻塞主流程 */ }
}
} catch (e) { /* 静默, 不阻塞主流程 */ }
}
@@ -151,6 +164,21 @@ async function loadMeeting(mid) {
// ========== 保存 ==========
async function onSave() {
try { await formRef.value.validate() } catch (e) { return }
// 会议开始时间不能晚于会议结束时间 (通用校验)
if (form.startTime && form.endTime
&& new Date(form.startTime.replace(' ', 'T')) > new Date(form.endTime.replace(' ', 'T'))) {
ElMessage.error('会议开始时间不能晚于会议结束时间')
return
}
// executor: 期数不得超过分配给本机构的场次 (快照 totalSessions 已换成 executor 口径)
if (roleSegment.value === 'executor') {
const pn = Number(form.periodNo)
const total = Number(snap.totalSessions)
if (!Number.isNaN(pn) && !Number.isNaN(total) && pn > total) {
ElMessage.error(`期数不能超过分配给本机构的场次 (共 ${total} 场)`)
return
}
}
submitting.value = true
try {
const payload = {
+5 -5
View File
@@ -7,7 +7,7 @@
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数(第几期)"><el-input v-model="q.periodNo" placeholder="请输入期数" clearable style="width:140px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
<el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
<span class="date-sep"></span>
@@ -69,10 +69,10 @@
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="500" fixed="right">
<el-table-column label="操作" width="500" fixed="right" align="center">
<template #default="{ row }">
<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="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>
@@ -150,7 +150,7 @@ const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'mana
// ========== 筛选 (按实际 8 项: 项目编号/会议ID/会议名称/第?期/会议时间/项目形式/当前阶段/备注) ==========
const q = ref({
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
projectForm: '', currentStage: '', remark: '',
startTime: '', endTime: ''
})
@@ -184,7 +184,7 @@ async function load() {
}
function reset() {
q.value = {
projectNo:'', meetingId:'', meetingName:'', periodNo:'',
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
projectForm:'', currentStage:'', remark:'',
startTime:'', endTime:''
}
+2 -98
View File
@@ -46,52 +46,7 @@
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -101,6 +56,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const route = useRoute()
const router = useRouter()
@@ -311,58 +267,6 @@ a { color: inherit; text-decoration: none; }
padding: 60px 0;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.article-card { padding: 32px 24px; }
+2 -211
View File
@@ -59,18 +59,6 @@
</p>
<div class="plan-image" v-html="planSvg"></div>
</section>
<section class="notice-section">
<div class="section-title">参与说明</div>
<div class="notice-card">
<ul class="notice-list">
<li><strong>登录要求</strong> · 点击计划详情及项目提案,需先登录系统;</li>
<li><strong>支持方注册</strong> · 注册后默认为管理员角色,可登录后新建监察员子账号;</li>
<li><strong>支持方管理</strong> · 注册时选择的企业为主单位,默认进入该企业的人员管理页,由企业管理员(总负责人)进行管理,管理员账号可新建监察员账号;</li>
<li><strong>执行方注册</strong> · 注册跳转至供应商系统,走入库流程,注册成功后该账号默认为管理员账号,管理员账号可新建执行人员账号</li>
</ul>
</div>
</section>
</div>
<section class="specialty-section">
@@ -91,52 +79,7 @@
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a href="首页.html">首页</a>
<a href="index2.html">年度项目规划</a>
<a href="项目公示.html">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -147,6 +90,7 @@ import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const router = useRouter()
const userStore = useUserStore()
@@ -660,157 +604,4 @@ a { color: inherit; text-decoration: none; }
text-decoration: none;
}
/* ========== 提示 ========== */
.notice-section {
padding: 30px 0 50px;
}
.notice-card {
background: #fff;
border: 1px solid #e5e7eb;
padding: 20px 24px;
}
.notice-list {
list-style: none;
}
.notice-list li {
padding: 8px 0;
font-size: 13px;
color: #4b5563;
line-height: 1.7;
position: relative;
padding-left: 16px;
}
.notice-list li::before {
content: '';
position: absolute;
left: 0;
top: 18px;
width: 4px;
height: 4px;
background: var(--brand-primary);
}
.notice-list li strong {
color: var(--brand-primary);
font-weight: 500;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+5 -173
View File
@@ -83,52 +83,7 @@
</div>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<span style="display: block; font-size: 13px; color: rgba(255,255,255,0.6);">项目公示</span>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -139,6 +94,7 @@ import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import { bizList } from '@/api/public'
import PortalFooter from '@/components/PortalFooter.vue'
const router = useRouter()
const userStore = useUserStore()
@@ -163,26 +119,15 @@ async function load() {
const res = await request({ url: '/business/public/announcements', method: 'get' })
const list = res.data?.data || res.data || []
allRows.value = list.map(r => {
// ann_type 来自 announcement_type 字段; URL 来自邀请函/支持函/通知/日程/公示 URL
const type = r.announcementType || '公示'
// 根据 announcementType 选择对应 URL
const urlMap = {
invitation: r.invitationUrl,
support: r.supportLetterUrl,
supportLetter: r.supportLetterUrl,
publish: r.publishUrl,
notice: r.publishUrl,
schedule: r.scheduleUrl
}
return {
annId: r.projectId,
date: r.startTime,
endTime: r.endTime,
type,
type: '公示',
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
fileUrl: urlMap[type] || r.invitationUrl || r.supportLetterUrl || r.publishUrl || ''
fileUrl: r.invitationUrl || r.supportLetterUrl || r.publishUrl || r.scheduleUrl || ''
}
})
} catch (e) {
@@ -242,6 +187,7 @@ function nextPage() { if (currentPage.value < totalPages.value) { currentPage.va
function goPage(p) { currentPage.value = p; window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
function goUser() { /* placeholder */ }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
@@ -679,118 +625,4 @@ main.container {
margin-left: 12px;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+2 -175
View File
@@ -121,52 +121,7 @@
</aside>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
<!-- 分享二维码弹窗 (el-dialog 自带右上角 X 关闭按钮 + Esc + 遮罩点击) -->
<el-dialog v-model="showQr" title="分享本页" width="420px" align-center destroy-on-close>
@@ -229,6 +184,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import PortalFooter from '@/components/PortalFooter.vue'
import { listBizPerson } from '@/api/business/person'
import { bizGet } from '@/api/public'
import {
@@ -819,21 +775,6 @@ onBeforeUnmount(() => {
.logo-title,
.logo-subtitle,
.top-tools,
.footer-main,
.footer-brand,
.footer-col,
.footer-col h4,
.brand-row,
.footer-desc,
.footer-col a,
.footer-col p,
.footer-qr,
.footer-logo,
.qr-image,
.qr-label,
.footer-bottom,
.footer-brand-name,
.footer-brand-en,
.action-bar,
.modal,
.modal-qr,
@@ -1329,118 +1270,4 @@ a { color: inherit; text-decoration: none; }
text-align: center;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+2 -85
View File
@@ -67,52 +67,7 @@
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -122,6 +77,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const route = useRoute()
const router = useRouter()
@@ -293,45 +249,6 @@ a { color: inherit; text-decoration: none; }
.content-empty p { margin: 8px 0 0; }
.content-empty .hint { font-size: 12px; }
/* ===== 底部 ===== */
.footer { background: var(--brand-primary-darker); color: rgba(255,255,255,0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255,255,255,0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255,255,255,0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px; position: relative;
}
.footer-col h4::after { content: ''; position: absolute; left: 0; bottom: 0; width: 24px; height: 2px; background: #93c5fd; }
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255,255,255,0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.plan-card { padding: 32px 24px; }
@@ -61,7 +61,7 @@
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
@@ -71,6 +71,8 @@
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
@@ -119,7 +121,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
@@ -214,6 +216,23 @@ async function onChangeAdmin(row) {
}
}
// ========== 重置密码 ==========
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
// ========== 跳转 (按角色) ==========
function goNew() {
router.push({
@@ -225,10 +244,22 @@ function goNew() {
})
}
function goEdit(row) {
router.push(listBasePath.value + '/edit/' + row.personId)
router.push({
path: listBasePath.value + '/edit/' + row.personId,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
function goView(row) {
router.push(listBasePath.value + '/view/' + row.personId)
router.push({
path: listBasePath.value + '/view/' + row.personId,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
// ========== 批量导入 ==========
@@ -63,6 +63,8 @@ const personId = route.params.id
// 角色感知: admin/manager 都能跳到此页, 回跳路径按 route 区分
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/sponsor-people' : '/manager/sponsor-people')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
const loading = ref(false)
const form = reactive({
@@ -79,7 +81,13 @@ const form = reactive({
})
function goBack() {
router.push(listBasePath.value)
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
@@ -11,8 +11,8 @@
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="用户名" prop="userName">
<el-input v-model="form.userName" placeholder="登录用户名" maxlength="50" :disabled="isEdit" />
<el-form-item v-if="!isEdit" label="用户名" prop="userName">
<el-input v-model="form.userName" placeholder="登录用户名" maxlength="50" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="姓名" maxlength="20" />
@@ -82,7 +82,13 @@ const rules = {
}
function goBack() {
router.push(listBasePath.value)
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
@@ -129,7 +135,6 @@ async function onSave() {
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
+4 -5
View File
@@ -13,9 +13,8 @@
<el-form-item label="会议名称">
<el-input v-model="q.meetingName" placeholder="会议名称" clearable style="width:180px" />
</el-form-item>
<el-form-item label="">
<el-input v-model="q.periodNo" placeholder="期数" clearable style="width:80px" />
<span style="margin:0 4px"></span>
<el-form-item label="期数">
<el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:110px" />
</el-form-item>
<el-form-item label="会议时间起">
<el-date-picker v-model="q.startTimeRange" type="datetimerange" range-separator="至"
@@ -172,7 +171,7 @@ const list = ref([])
const loading = ref(false)
const selected = ref([])
const q = reactive({
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
startTimeRange: null, projectForm: '', currentStage: '', remark: ''
})
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -216,7 +215,7 @@ async function loadList() {
}
function reset() {
Object.assign(q, { projectNo: '', meetingId: '', meetingName: '', periodNo: '', startTimeRange: null, projectForm: '', currentStage: '', remark: '' })
Object.assign(q, { projectNo: '', meetingId: '', meetingName: '', periodNo: null, startTimeRange: null, projectForm: '', currentStage: '', remark: '' })
page.pageNum = 1
loadList()
}
+20 -2
View File
@@ -66,10 +66,12 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<!-- 本人不显示禁用/恢复按钮 (避免主账号把自己禁用导致登录不上) -->
<el-button v-if="row.userId !== store.user?.userId" link :type="row.status === '0' ? 'warning' : 'success'" @click="onToggleStatus(row)">
{{ row.status === '0' ? '禁用' : '恢复' }}
@@ -137,7 +139,7 @@
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizUpdate, bizDelete } from '@/api/public'
import { bizUpdate, bizDelete, resetPersonPassword } from '@/api/public'
import { listSponsorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
@@ -193,6 +195,22 @@ function onEdit(row) {
router.push({ path: `/sponsor/people/edit/${row.personId}` })
}
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function onToggleStatus(row) {
const newStatus = row.status === '0' ? '1' : '0'
const label = newStatus === '0' ? '恢复' : '禁用'