feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源

邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md):
- 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML
- 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置
- 上传进度: UploadProgressRegistry + UploadProgressController
- 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js
- 原型: proto/html/components/invite-detail / new-invitation / send-invitation

登录/账号:
- 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗
- 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name
- OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader)

其它:
- OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll
- doctor 项目详情 ProjectDetail.vue
- 数据库/测试/设计文档 (md) 入库
This commit is contained in:
郭庆泰
2026-09-10 20:40:49 +08:00
parent 56615f9806
commit 5a0a892574
171 changed files with 11032 additions and 876 deletions
+31
View File
@@ -0,0 +1,31 @@
import request from '@/utils/request'
/**
* 发送邀请 API (manager 工作台, 独立于项目/会议).
* 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮), 公开端点无需登录.
*/
// ========== manager 发送邀请 ==========
export function listInvites(params) {
return request.get('/business/invite/list', { params })
}
export function getInvite(id) {
return request.get(`/business/invite/${id}`)
}
export function createInvite(data) {
return request.post('/business/invite', data)
}
export function sendInvite(id) {
return request.post(`/business/invite/${id}/send`)
}
export function deleteInvite(ids) {
return request.delete(`/business/invite/${Array.isArray(ids) ? ids.join(',') : ids}`)
}
// ========== 公开邀请 (短信/邮件里的链接) ==========
export function getPublicInvite(token) {
return request.get(`/business/public/invite/${token}`)
}
export function confirmPublicInvite(token) {
return request.post(`/business/public/invite/${token}/confirm`)
}
+5 -3
View File
@@ -2,10 +2,12 @@ import request from '@/utils/request'
/**
* 当前登录用户的"待签署协议"会议列表 (已推送电子签 is_esigned=1 且 任一未签)
* 返回 [{id, meetingId, meetingName, startTime, isEsigned, ...}, ...]
* 后端分页, 返回 { rows: [...], total: N } (RuoYi TableDataInfo 风格, request.js 已归一化)
* @param {number} pageNum 页码 (默认 1)
* @param {number} pageSize 每页条数 (默认 5)
*/
export function listUnsignedMeetingProtocols() {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
export function listUnsignedMeetingProtocols(pageNum = 1, pageSize = 5) {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get', params: { pageNum, pageSize } })
}
/**
-10
View File
@@ -251,16 +251,6 @@ h1, h2, h3, h4, h5, h6 { margin: 0; font-weight: 600; color: var(--el-text-color
.stat-card .stat-value { font-size: 28px; font-weight: 600; color: var(--brand-primary); line-height: 1.1; }
.stat-card .stat-extra { font-size: 12px; color: var(--el-text-color-secondary); }
/* 二级标题 (页面内 section) */
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0 0 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--brand-slate-200);
}
/* 空状态 */
.empty-block {
padding: 48px 20px;
+42 -17
View File
@@ -21,13 +21,23 @@
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
<el-icon><CameraFilled /></el-icon>&nbsp;扫码拍照
</el-button>
<el-link :underline="false" v-if="modelValue" type="primary" @click="openPreview">查看照片</el-link>
<span v-if="photos.length" class="cam-count">{{ photos.length }} </span>
<span v-else class="cam-empty">未上传</span>
<span v-if="polling" class="cam-polling">等待手机回传</span>
</div>
<!-- 照片预览: 复用全局 Preview.vue (dialog 内预览, 图片/PDF 自适应, 非新窗口) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<!-- 多张缩略图: 点任意一张走 el-image 全屏轮播预览 (签到表多张 / 前后全景单张) -->
<div v-if="photos.length" class="camera-thumbs">
<el-image
v-for="(p, i) in photos"
:key="i"
:src="p"
:preview-src-list="photos"
:initial-index="i"
fit="cover"
class="thumb"
/>
</div>
<el-dialog
v-model="visible"
@@ -42,7 +52,7 @@
<img v-else-if="qrUrl" :src="qrUrl" class="qr-img" />
<div v-else class="qr-error">二维码生成失败, 请关闭重试</div>
<div class="qr-tip">用手机相机扫一扫, 进入{{ label }}拍照页</div>
<div class="qr-tip-sub">拍完点使用, 照片自动回传到本会议</div>
<div class="qr-tip-sub">{{ isSignin ? '连拍多张:每张点「加入」攒批,最后点「提交全部」回传' : '拍完点「使用」,照片自动回传到本会议' }}</div>
<div v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开</div>
</div>
</el-dialog>
@@ -50,12 +60,11 @@
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
import { CameraFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import Preview from '@/components/Preview.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
@@ -77,12 +86,25 @@ const visible = ref(false)
const qrUrl = ref('')
const qrLoading = ref(false)
const polling = ref(false)
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
let pollTimer = null
let baseline = ''
/** 签到表连拍多张, 前后全景单张 */
const isSignin = computed(() => props.subType === 'L_SIGN_IN')
/** 解析 modelValue: 签到表多张存 JSON 数组字符串, 单张/前后全景为普通 URL → 统一成数组展示 */
const photos = computed(() => {
const v = props.modelValue
if (!v) return []
if (typeof v === 'string' && v.trim().startsWith('[')) {
try {
const arr = JSON.parse(v)
if (Array.isArray(arr)) return arr.filter(Boolean)
} catch (e) { /* 解析失败按单 URL 处理 */ }
}
return [v]
})
/**
* 生成二维码并开始轮询.
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
@@ -149,14 +171,6 @@ function onClosed() {
stopPolling()
}
/** 打开照片预览 dialog (复用全局 Preview.vue, 图片/PDF 自适应) */
function openPreview() {
if (!props.modelValue) return
previewUrl.value = props.modelValue
previewTitle.value = props.label
previewOpen.value = true
}
onBeforeUnmount(stopPolling)
</script>
@@ -167,8 +181,19 @@ onBeforeUnmount(stopPolling)
min-height: 36px;
}
.cam-empty { color: #c0c4cc; font-size: 13px; }
.cam-count { color: #67c23a; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; }
.camera-thumbs {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 8px;
}
.thumb {
width: 56px; height: 56px;
border: 1px solid #ebeef5; border-radius: 4px;
cursor: pointer;
}
.qr-wrap { text-align: center; padding: 8px 0 4px; }
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
.qr-img { width: 240px; height: 240px; }
@@ -0,0 +1,73 @@
<template>
<el-dialog
:model-value="visible"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
title="请设置登录密码"
width="420px"
align-center
>
<el-form label-position="left" label-width="80px" @submit.prevent>
<el-form-item label="新密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入密码(6-20位)"
maxlength="20"
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认密码">
<el-input
v-model="form.confirm"
type="password"
show-password
placeholder="请再次输入密码"
maxlength="20"
autocomplete="new-password"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
const store = useUserStore()
// 仅当登录后 getInfo 标记"密码为空"时弹窗 (不限角色), 且不可关闭/ESC/点蒙层关闭
const visible = computed(() => store.user?.isPasswordEmpty === true)
const form = ref({ password: '', confirm: '' })
const submitting = ref(false)
async function submit() {
const pwd = form.value.password
if (!pwd) return ElMessage.warning('请输入新密码')
if (pwd.length < 6 || pwd.length > 20) return ElMessage.warning('密码长度 6-20 位')
if (pwd !== form.value.confirm) return ElMessage.warning('两次输入的密码不一致')
submitting.value = true
try {
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: '', newPassword: pwd } })
ElMessage.success('密码设置成功')
store.setUser({ ...store.user, isPasswordEmpty: false })
form.value = { password: '', confirm: '' }
} catch (e) {
ElMessage.error(e?.msg || e?.message || '设置失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
</style>
+2 -2
View File
@@ -93,8 +93,8 @@ async function onFileChange(e, side) {
ElMessage.warning('只能上传图片文件')
return
}
if (file.size / 1024 / 1024 > 10) {
ElMessage.warning('图片大小不能超过 10MB')
if (file.size / 1024 / 1024 > 200) {
ElMessage.warning('图片大小不能超过 200MB')
return
}
uploading.value[side] = true
+177 -47
View File
@@ -10,41 +10,80 @@
/>
-->
<template>
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly, uploading }" @click="handleClick">
<div v-if="uploading" class="ht-file-progress">
<span class="progress-label">上传中</span>
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
</div>
<div v-else-if="!modelValue && !readonly" class="ht-file-placeholder">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
<span class="placeholder-text">未上传</span>
</div>
<div v-else-if="modelValue" class="ht-file-info">
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<a class="file-name" :href="openHref" target="_blank" @click.stop>{{ fileName }}</a>
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
<div class="ht-file-upload" :class="{ 'has-file': hasValue, 'is-block': block, readonly, uploading, multiple }" @click="handleClick">
<!-- 多文件模式: 上传多个文件, modelValue JSON 数组字符串 (单文件时退化为单 URL 字符串) -->
<template v-if="multiple">
<div v-if="uploading" class="ht-file-progress">
<span class="progress-label">上传中</span>
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
</div>
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box">
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
<a v-else :href="openHref" target="_blank" class="preview-file" @click.stop>
<el-icon :size="28"><Document /></el-icon>
<span class="preview-text">{{ getFileType(modelValue) }}</span>
</a>
<template v-else>
<div v-if="!modelList.length && !readonly" class="ht-file-placeholder" @click.stop="handleClick">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
<div v-if="!modelList.length && readonly" class="ht-file-placeholder readonly-empty">
<span class="placeholder-text">未上传</span>
</div>
<div v-for="(u, i) in modelList" :key="u" class="ht-file-info multi-item" @click.stop>
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<span class="file-name">{{ fileNameOf(u) }}</span>
<span class="file-type">{{ typeOf(u) }}</span>
</div>
<div class="file-actions">
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemoveAt(i)">移除</el-link>
<a class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a>
</div>
</div>
<div v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick">
<el-icon :size="16"><Plus /></el-icon> 继续添加
</div>
</template>
</template>
<!-- 单文件模式 (原逻辑) -->
<template v-else>
<div v-if="uploading" class="ht-file-progress">
<span class="progress-label">上传中</span>
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
</div>
</div>
<div v-else-if="!modelValue && !readonly" class="ht-file-placeholder">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
<span class="placeholder-text">未上传</span>
</div>
<div v-else-if="modelValue" class="ht-file-info">
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<a class="file-name" :href="openHref" @click.stop.prevent="handleOpen">{{ fileName }}</a>
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
</div>
<div class="file-actions">
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
<a class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a>
</div>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box">
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
<a v-else :href="openHref" class="preview-file" @click.stop.prevent="handleOpen">
<el-icon :size="28"><Document /></el-icon>
<span class="preview-text">{{ getFileType(modelValue) }}</span>
</a>
</div>
</div>
</template>
</div>
<input
type="file"
ref="fileInput"
:accept="accept"
:multiple="multiple"
style="display:none"
@change="onFileChange"
/>
@@ -53,7 +92,7 @@
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Document } from '@element-plus/icons-vue'
import { Document, Plus } from '@element-plus/icons-vue'
import { uploadToOss } from '@/utils/oss'
const props = defineProps({
@@ -63,36 +102,59 @@ const props = defineProps({
placeholder: { type: String, default: '点击上传文件' },
hint: { type: String, default: '' },
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
maxSize: { type: Number, default: 10 }, // MB
maxSize: { type: Number, default: 200 }, // MB
block: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
showPreview: { type: Boolean, default: true } // 右侧预览框 (图片/PDF/其他)
showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他)
multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串
})
const emit = defineEmits(['update:modelValue', 'update:name'])
const emit = defineEmits(['update:modelValue', 'update:name', 'preview'])
const fileInput = ref(null)
const uploading = ref(false)
const progress = ref(0)
const fileName = computed(() => {
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
if (props.name) return props.name
if (!props.modelValue) return ''
// 取 URL 最后一段, 去 query 参数; 上传时已对路径做了 encodeURIComponent, 这里先还原
const url = props.modelValue.split('?')[0]
let last = url.substring(url.lastIndexOf('/') + 1)
/** 从 OSS URL 还原原文件名 (前端 key 格式 原名_时间戳_随机串.扩展名 → 原名.扩展名; 旧 key 原样返回末段) */
function nameOfUrl(url) {
if (!url) return ''
const u = url.split('?')[0]
let last = u.substring(u.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 fileName = computed(() => {
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
if (props.name) return props.name
return nameOfUrl(props.modelValue)
})
const ext = computed(() => {
const n = fileName.value
const i = n.lastIndexOf('.')
return i >= 0 ? n.substring(i + 1) : ''
})
// ===== 多文件模式 =====
/** 解析 modelValue 为 URL 列表 (兼容单 URL 字符串 / JSON 数组字符串 / 数组) */
const modelList = computed(() => {
if (!props.modelValue) return []
if (Array.isArray(props.modelValue)) return props.modelValue
try {
const arr = JSON.parse(props.modelValue)
return Array.isArray(arr) ? arr : [props.modelValue]
} catch (e) {
return [props.modelValue]
}
})
const hasValue = computed(() => props.multiple ? modelList.value.length > 0 : !!props.modelValue)
function fileNameOf(url) { return nameOfUrl(url) }
function typeOf(url) {
const n = nameOfUrl(url)
const i = n.lastIndexOf('.')
return (i >= 0 ? n.substring(i + 1) : '').toUpperCase()
}
const fileSizeText = computed(() => '已上传')
// 图片扩展名 (用于 el-image 预览)
@@ -102,6 +164,10 @@ const isImage = computed(() => {
})
// PDF: 点击不下载, 走 /common/oss/proxy 后端代理重写 Content-Disposition 为 inline, 新窗口内嵌预览
const isPdf = computed(() => ext.value.toLowerCase() === 'pdf')
// Office/文本 (可页内预览的扩展名): 点击 emit preview, 由父组件弹 Preview 对话框; 老 .doc/.ppt 仍走下载
const isOffice = computed(() => ['docx', 'xlsx', 'xls', 'pptx', 'txt'].includes(ext.value.toLowerCase()))
// zip: 点击 emit preview (父组件 Preview 服务端解压列目录), 不直接下载
const isZip = computed(() => ext.value.toLowerCase() === 'zip')
// OSS 代理预览 (与 Preview.vue / PublicityDetail 同技术): bucket 默认 attachment, 直接打开会强制下载
function proxyUrl(url) {
if (!url) return url
@@ -112,6 +178,14 @@ function proxyUrl(url) {
}
// 文件名/预览框点击目标: PDF → 代理预览(新窗口), 其余 → 原 URL (图片 el-image 预览, 其他下载)
const openHref = computed(() => isPdf.value ? proxyUrl(props.modelValue) : props.modelValue)
// 点击文件名/预览框: Office/文本 → emit preview 弹对话框; 其余 → 新窗口打开 openHref
function handleOpen() {
if (isOffice.value || isZip.value) {
emit('preview', props.modelValue)
return
}
window.open(openHref.value, '_blank', 'noopener')
}
// 文件类型标签 (PDF / DOC / XLS / ZIP 等)
const typeLabelMap = {
pdf: 'PDF', doc: 'DOC', docx: 'DOCX',
@@ -130,11 +204,8 @@ function handleClick() {
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
// 类型校验 (按 accept 列表)
/** 类型/大小校验, 不通过返回 false (内部已 toast) */
function validateFile(file) {
const allowed = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (allowed.length) {
const lower = file.name.toLowerCase()
@@ -143,11 +214,45 @@ async function onFileChange(e) {
if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))
return file.type === rule
})
if (!ok) return ElMessage.warning('文件类型不符, 允许: ' + props.accept)
if (!ok) { ElMessage.warning('文件类型不符, 允许: ' + props.accept); return false }
}
if (file.size / 1024 / 1024 > props.maxSize) {
return ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
return false
}
return true
}
async function onFileChange(e) {
const files = Array.from(e.target.files || [])
e.target.value = ''
if (!files.length) return
// 多文件模式: 逐个上传, 追加进 modelValue 列表
if (props.multiple) {
const list = [...modelList.value]
let added = 0
for (const file of files) {
if (!validateFile(file)) continue
uploading.value = true
progress.value = 0
try {
const url = await uploadToOss(file, props.dir, (p) => { progress.value = p })
list.push(url)
added++
} catch (err) {
ElMessage.error(err.message || '上传失败')
}
}
uploading.value = false
if (added) {
emit('update:modelValue', list.length === 1 ? list[0] : JSON.stringify(list))
ElMessage.success(`已上传 ${added} 个文件`)
}
return
}
// 单文件模式 (原逻辑)
const file = files[0]
if (!validateFile(file)) return
uploading.value = true
progress.value = 0
try {
@@ -166,6 +271,17 @@ function onRemove() {
emit('update:modelValue', '')
emit('update:name', '')
}
/** 多文件模式: 移除第 i 个文件 */
function onRemoveAt(i) {
const list = [...modelList.value]
list.splice(i, 1)
if (!list.length) {
emit('update:modelValue', '')
} else {
emit('update:modelValue', list.length === 1 ? list[0] : JSON.stringify(list))
}
}
</script>
<style scoped>
@@ -187,6 +303,17 @@ function onRemove() {
.ht-file-upload.readonly { cursor: default; }
.ht-file-upload.uploading { cursor: default; }
/* 多文件模式: 纵向堆叠列表 */
.ht-file-upload.multiple { flex-direction: column; align-items: stretch; gap: 8px; }
.ht-file-upload.multiple .ht-file-info.multi-item { padding-bottom: 6px; border-bottom: 1px dashed #f0f0f0; }
.ht-file-upload.multiple .ht-file-info.multi-item:last-of-type { border-bottom: none; }
.ht-file-add {
display: flex; align-items: center; justify-content: center; gap: 4px;
border: 1px dashed #d9d9d9; border-radius: 4px; padding: 8px;
color: var(--brand-primary); font-size: 13px; cursor: pointer;
}
.ht-file-add:hover { border-color: var(--brand-primary); }
/* 上传进度条 */
.ht-file-progress {
flex: 1;
@@ -220,6 +347,9 @@ function onRemove() {
.file-name:hover { color: var(--brand-primary); text-decoration: underline; }
.file-type { font-size: 12px; color: #909399; }
.remove-btn { flex-shrink: 0; }
.file-actions { display: flex; flex-direction: row; align-items: center; gap: 12px; flex-shrink: 0; }
.download-btn { color: var(--brand-primary); text-decoration: none; }
.download-btn:hover { text-decoration: underline; }
/* 右侧预览框 (方形, 64x64) */
.preview-box {
+2 -2
View File
@@ -69,8 +69,8 @@ async function onFileChange(e) {
ElMessage.warning('只能上传图片文件')
return
}
if (file.size / 1024 / 1024 > 10) {
ElMessage.warning('图片大小不能超过 10MB')
if (file.size / 1024 / 1024 > 200) {
ElMessage.warning('图片大小不能超过 200MB')
return
}
uploading.value = true
+1 -1
View File
@@ -126,7 +126,7 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
// 当前已在目标页时点导航 = 仅回顶, 否则跳转
function goHome() { if (route.path !== '/') router.push('/'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goPublicity() { if (route.path !== '/publicity') router.push('/publicity'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goLogin() { router.push('/login') }
function goLogin() { router.push({ name: 'login', query: { redirect: route.fullPath } }) }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
+182 -6
View File
@@ -1,5 +1,5 @@
<template>
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
<el-dialog v-model="open" :title="title" width="1200" top="6vh" :close-on-click-modal="false">
<!-- 多图 (身份证正反面等): 并排缩略图, 点开走 el-image 全屏轮播 -->
<div v-if="isMulti" class="preview-gallery">
<el-image
@@ -17,7 +17,46 @@
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
<!-- PDFPDF.js canvas 渲染微信/移动端浏览器无 Chrome 内置 PDF viewer -->
<PdfViewer v-else-if="kind === 'pdf'" :url="primary" class="preview-pdf" />
<!-- 其它doc/xls/zip 给下载链接 -->
<!-- Office: Word(docx) / Excel(xlsx,xls) / PPT(pptx), 按需懒加载渲染 -->
<div v-else-if="isOfficeKind && !officeError" class="office-wrap">
<VueOfficeDocx v-if="kind === 'docx'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
<VueOfficeExcel v-else-if="kind === 'excel'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
<VueOfficePptx v-else-if="kind === 'pptx'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
<div v-if="officeLoading" class="office-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在加载文档文件较大可能需要一点时间</p>
</div>
</div>
<!-- txt: 直接拉取文本展示 -->
<pre v-else-if="kind === 'txt'" class="txt-wrap">{{ txtContent || '加载中...' }}</pre>
<!-- zip: 服务端解压列目录, 点文件内嵌预览 (pdf/图片) 或下载 -->
<div v-else-if="kind === 'zip'" class="zip-wrap">
<div v-if="zipLoading" class="zip-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在读取压缩包目录</p>
</div>
<div v-else-if="zipError" class="zip-error">{{ zipError }}</div>
<template v-else>
<div class="zip-toolbar"> {{ zipEntries.length }} 个文件</div>
<ul class="zip-list">
<li v-for="(f, i) in zipEntries" :key="i" class="zip-item">
<span class="zip-name" :title="f.path">{{ f.name }}</span>
<span class="zip-size">{{ fmtSize(f.size) }}</span>
<el-button v-if="isZipPreviewable(f.kind)" link type="primary" class="zip-act" @click="zipCurrent = f">预览</el-button>
<a class="zip-download" :href="zipFileUrl(f.path, true)" target="_blank" rel="noopener">下载</a>
</li>
</ul>
<div v-if="zipCurrent" class="zip-preview">
<div class="zip-preview-head">
<span class="zip-preview-name">{{ zipCurrent.name }}</span>
<el-button link @click="zipCurrent = null">关闭预览</el-button>
</div>
<PdfViewer v-if="zipCurrent.kind === 'pdf'" :url="zipFileUrl(zipCurrent.path, false)" class="preview-pdf" />
<img v-else-if="zipCurrent.kind === 'image'" :src="zipFileUrl(zipCurrent.path, false)" class="preview-img" :alt="zipCurrent.name" />
</div>
</template>
</div>
<!-- 其它doc/ppt Office 渲染失败给下载链接 -->
<div v-else class="preview-fallback">
<el-icon class="fallback-icon"><Document /></el-icon>
<p>该文件类型暂不支持页内预览,请点击下方按钮下载/在新窗口打开查看</p>
@@ -34,10 +73,17 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ref, computed, watch, defineAsyncComponent } from 'vue'
import { Document, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PdfViewer from '@/components/PdfViewer.vue'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
// Office 预览组件按需懒加载 (打开对应类型才拉取 chunk, 不拖慢首屏; pptx 无独立 css)
const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx'))
const VueOfficeExcel = defineAsyncComponent(() => import('@vue-office/excel'))
const VueOfficePptx = defineAsyncComponent(() => import('@vue-office/pptx'))
const props = defineProps({
modelValue: Boolean,
@@ -63,11 +109,96 @@ 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'
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(x)) return 'image'
if (/\.pdf$/.test(x)) return 'pdf'
if (/\.zip$/.test(x)) return 'zip'
if (/\.docx$/.test(x)) return 'docx'
if (/\.(xlsx|xls)$/.test(x)) return 'excel'
if (/\.pptx$/.test(x)) return 'pptx'
if (/\.txt$/.test(x)) return 'txt'
return 'other'
}
const kind = computed(() => kindOf(primary.value))
const isOfficeKind = computed(() => ['docx', 'excel', 'pptx'].includes(kind.value))
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
// Office/txt 预览需要拿到字节, 统一走 /common/oss/proxy 后端代理 (同源无跨域、inline)
function proxyPlain(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url)
}
return url
}
const plainUrl = computed(() => proxyPlain(primary.value))
// Office 渲染失败 → 回退到下载/新窗口兜底; officeLoading 控制加载态 (大文件下载+解析耗时较长)
const officeError = ref(false)
const officeLoading = ref(false)
function onOfficeRendered() { officeLoading.value = false }
function onOfficeError() { officeError.value = true; officeLoading.value = false }
// txt: 拉取文本展示 (按 UTF-8 读; GBK 老 txt 会乱码, 暂不做转码)
const txtContent = ref('')
// zip: 服务端解压列目录 + 单文件预览 (方案 B, 不整包下载到浏览器)
const zipEntries = ref([])
const zipLoading = ref(false)
const zipError = ref('')
const zipCurrent = ref(null)
watch(primary, async (url) => {
officeError.value = false
officeLoading.value = ['docx', 'excel', 'pptx'].includes(kindOf(url))
txtContent.value = ''
zipEntries.value = []
zipError.value = ''
zipCurrent.value = null
const k = kindOf(url)
if (k === 'txt') {
try {
const res = await fetch(proxyPlain(url))
if (!res.ok) throw new Error('HTTP ' + res.status)
txtContent.value = await res.text()
} catch (e) {
txtContent.value = '文本加载失败: ' + (e?.message || '')
}
} else if (k === 'zip') {
await loadZipEntries(url)
}
}, { immediate: true })
// zip 文件清单 (服务端解压); 仅 OSS bucket 支持在线查看
async function loadZipEntries(url) {
if (!url || !url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
zipError.value = '仅支持 OSS 压缩包在线预览'
return
}
zipLoading.value = true
try {
const res = await fetch(import.meta.env.VITE_APP_BASE_API + '/common/oss/zip/entries?url=' + encodeURIComponent(url))
if (!res.ok) throw new Error('HTTP ' + res.status)
const j = await res.json()
zipEntries.value = (j && j.data) || []
if (!zipEntries.value.length) zipError.value = '压缩包为空'
} catch (e) {
zipError.value = '压缩包目录读取失败: ' + (e?.message || '')
} finally {
zipLoading.value = false
}
}
// zip 内单文件代理 URL (download=true 附件下载, false 内联预览)
function zipFileUrl(path, download) {
if (!primary.value) return ''
const base = import.meta.env.VITE_APP_BASE_API + '/common/oss/zip/file?url=' + encodeURIComponent(primary.value) + '&entry=' + encodeURIComponent(path)
return download ? base + '&download=true' : base
}
function isZipPreviewable(kind) { return kind === 'pdf' || kind === 'image' }
function fmtSize(n) {
if (!n || n <= 0) return ''
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(1) + ' MB'
}
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
// 「在新窗口打开」走 /common/oss/proxy 后端代理重写为 inline (页内 PDF 预览已改用 PdfViewer/pdf.js)
@@ -98,6 +229,51 @@ async function copyUrl() {
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.preview-pdf { width: 100%; }
.office-wrap { position: relative; width: 100%; aspect-ratio: 4 / 3; }
.office-loading {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 12px;
background: rgba(255, 255, 255, 0.85);
color: #909399; font-size: 14px;
z-index: 2;
}
.office-loading .is-loading { font-size: 32px; animation: preview-rotate 1s linear infinite; }
@keyframes preview-rotate { to { transform: rotate(360deg); } }
.txt-wrap {
width: 100%;
max-height: 70vh;
min-height: 320px;
overflow: auto;
padding: 16px;
background: #f7f8fa;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
color: #303133;
margin: 0;
}
.zip-wrap { width: 100%; max-height: 70vh; overflow: auto; }
.zip-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 0; color: #909399; font-size: 14px; }
.zip-loading .is-loading { font-size: 32px; animation: preview-rotate 1s linear infinite; }
.zip-error { padding: 48px 0; text-align: center; color: #f56c6c; }
.zip-toolbar { font-size: 13px; color: #909399; padding: 4px 0 8px; }
.zip-list { list-style: none; margin: 0; padding: 0; border: 1px solid #ebeef5; border-radius: 4px; }
.zip-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.zip-item:last-child { border-bottom: none; }
.zip-name { flex: 1; min-width: 0; font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zip-size { font-size: 12px; color: #909399; flex-shrink: 0; }
.zip-act { flex-shrink: 0; }
.zip-download { flex-shrink: 0; font-size: 13px; color: var(--brand-primary); text-decoration: none; }
.zip-download:hover { text-decoration: underline; }
.zip-preview { margin-top: 12px; border: 1px solid #ebeef5; border-radius: 4px; padding: 8px; }
.zip-preview-head { display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px; }
.zip-preview-name { font-size: 13px; color: #303133; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zip-preview .preview-pdf { width: 100%; }
.zip-preview .preview-img { max-width: 100%; max-height: 55vh; object-fit: contain; display: block; margin: 0 auto; }
.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; }
+2 -2
View File
@@ -59,8 +59,8 @@ const editorConfig = {
return
}
// 大小校验 (5MB)
if (file.size / 1024 / 1024 > 5) {
ElMessage.warning('图片大小不能超过 5MB')
if (file.size / 1024 / 1024 > 200) {
ElMessage.warning('图片大小不能超过 200MB')
return
}
try {
+24 -4
View File
@@ -76,7 +76,7 @@
<el-dropdown trigger="click" @command="onCommand">
<span class="user-name">
<el-icon class="user-icon"><UserFilled /></el-icon>
<span>{{ store.user?.userName || store.user?.displayName }}</span>
<span>{{ store.user?.name || store.user?.nickName }}</span>
<el-icon class="caret"><CaretBottom /></el-icon>
</span>
<template #dropdown>
@@ -92,6 +92,9 @@
<router-view />
</el-main>
</el-container>
<!-- 登录后密码为空 强制设置密码 (不限角色) -->
<ForcePasswordDialog />
</el-container>
</template>
@@ -99,13 +102,14 @@
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { logout as logoutApi, getInfo } 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, ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
import ForcePasswordDialog from '@/components/ForcePasswordDialog.vue'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -171,6 +175,19 @@ async function loadExpertAuditStatus() {
}
}
// 刷新当前用户 isPasswordEmpty (后端 getInfo 才是权威, 本地 localStorage 可能是设置密码前的旧值)
// 否则"设置密码后刷新页面"仍会读旧缓存误弹"请设置登录密码"
async function refreshPasswordEmpty() {
try {
const info = await getInfo()
if (info && info.user && info.user.userId) {
store.setUser({ ...store.user, isPasswordEmpty: !!info.isPasswordEmpty })
}
} catch (e) {
// getInfo 失败静默 (401 由拦截器跳登录)
}
}
// 登录后启动 SSE 订阅, 组件卸载时关闭 (单例连接, 不重复开)
let unsubscribeNewMessage = null
let stopWatchRoute = null
@@ -179,6 +196,7 @@ onMounted(() => {
sseStart()
loadInitialUnread()
loadExpertAuditStatus()
refreshPasswordEmpty()
// SSE 推送触发 navbar 角标实时更新 + 全局 ElNotification 弹窗 + 医生审核状态刷新
// (审核通过事件 = user 立刻看到完整菜单/首页; 业务事件 = 弹右上角通知)
// 弹/不弹由后端 silent flag 决定: false=业务事件触发 (insert) → 弹; true=用户主动操作 (markRead/markAllRead) → 静默
@@ -210,7 +228,7 @@ onBeforeUnmount(() => {
if (stopWatchRoute) stopWatchRoute()
})
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行', sponsor: '支持方', leader: '项目负责人' }
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行', sponsor: '支持方', leader: '项目负责人' }
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
const MENU = {
@@ -246,6 +264,8 @@ const MENU = {
{ path: '/manager/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
// 发送邀请: 功能未完成, 暂隐藏菜单 (继续开发时取消注释即可)
// { path: '/manager/invites', title: '发送邀请', icon: Promotion },
{ path: '/manager/messages', title: '消息通知', icon: Bell },
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
],
+6 -1
View File
@@ -10,6 +10,7 @@ const routes = [
{ path: 'publicity/:projectId', name: 'publicity-detail', component: () => import('@/views/portal/PublicityDetail.vue'), meta: { title: '公示详情' } },
{ path: 'support-letter/:annId', name: 'support-letter', component: () => import('@/views/portal/SupportLetter.vue'), meta: { title: '支持函详情' } },
{ path: 'invitation/:annId', name: 'invitation', component: () => import('@/views/portal/Invitation.vue'), meta: { title: '邀请函详情' } },
{ path: 'invite/:token', name: 'portal-invite', component: () => import('@/views/portal/InviteView.vue'), meta: { title: '会议邀请' } },
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '支持方注册' } },
@@ -94,7 +95,10 @@ const routes = [
{ path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } },
{ path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } },
{ path: 'messages', name: 'manager-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } }
{ path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } },
{ path: 'invites', name: 'manager-invites', component: () => import('@/views/manager/InviteList.vue'), meta: { title: '发送邀请' } },
{ path: 'invites/new', name: 'manager-invites-new', component: () => import('@/views/manager/InviteNew.vue'), meta: { title: '新建邀请' } },
{ path: 'invites/detail/:id', name: 'manager-invites-detail', component: () => import('@/views/manager/InviteDetail.vue'), meta: { title: '邀请详情' } }
]
},
{ path: '/doctor', component: AdminLayout, meta: { role: 'doctor' }, children: [
@@ -102,6 +106,7 @@ const routes = [
{ path: 'home', name: 'doctor-home', component: () => import('@/views/doctor/Home.vue'), meta: { title: '首页' } },
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
{ path: 'projects/detail/:projectId', name: 'doctor-projects-detail', component: () => import('@/views/doctor/ProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
+2 -2
View File
@@ -23,7 +23,7 @@
<el-tab-pane label="修改密码" name="password">
<el-form :model="pwd" label-width="100px" class="account-form">
<el-form-item label="旧密码">
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" />
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" autocomplete="new-password" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwd.newPassword" type="password" show-password placeholder="请输入新密码" />
@@ -50,7 +50,7 @@ const store = useUserStore()
const tab = ref('profile')
const loading = ref(false)
const profile = ref({})
const ROLE_LABEL = { admin: '后台管理员', manager: '合规人员', doctor: '评审专家', executor: '执行', sponsor: '支持方' }
const ROLE_LABEL = { admin: '后台管理员', manager: '合规人员', doctor: '评审专家', executor: '执行', sponsor: '支持方' }
const roleLabel = computed(() => ROLE_LABEL[store.role] || store.role || '后台管理员')
const form = reactive({ nickName: '', phonenumber: '', email: '' })
+6 -6
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 协议管理</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="类型">
<el-select v-model="q.type" placeholder="全部" clearable style="width:160px">
<el-option label="用户协议" value="agreement" />
@@ -15,8 +15,8 @@
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
<el-option label="启用" value="Y" />
<el-option label="停用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
@@ -34,14 +34,14 @@
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
<el-tag :type="row.status === 'Y' ? 'success' : 'danger'">{{ row.status === 'Y' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" size="small" type="primary" @click="$router.push('/admin/article/edit/' + row.id)">编辑</el-link>
<el-link :underline="false" size="small" type="primary" :disabled="row.status !== '0'" @click="preview(row)" target="_blank">预览</el-link>
<el-link :underline="false" size="small" type="primary" :disabled="row.status !== 'Y'" @click="preview(row)" target="_blank">预览</el-link>
</div></template>
</el-table-column>
</el-table>
+3 -3
View File
@@ -20,8 +20,8 @@
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
<el-radio value="Y">启用</el-radio>
<el-radio value="N">停用</el-radio>
</el-radio-group>
</el-form-item>
@@ -61,7 +61,7 @@ const form = reactive({
title: '',
type: 'agreement',
content: '',
status: '0',
status: 'Y',
remark: ''
})
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 网站管理 / 专项计划管理</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="标题">
<el-input v-model="q.title" placeholder="输入标题" clearable />
</el-form-item>
@@ -44,7 +44,7 @@
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" size="small" type="primary" @click="$router.push('/admin/special-plan/edit/' + row.id)">编辑</el-link>
<el-link :underline="false" size="small" type="primary" :disabled="row.status !== '0'" @click="preview(row)">预览</el-link>
@@ -19,7 +19,7 @@
</el-form-item>
<el-form-item label="文件" :required="form.contentType === 'file'" v-else>
<OssFileUploader v-model="form.fileUrl" dir="ry8080/special-plan/" accept=".pdf,.png" placeholder="点击上传 PDF / PNG" hint="仅支持 PDF 或 PNG, ≤10MB" />
<OssFileUploader v-model="form.fileUrl" dir="ry8080/special-plan/" accept=".pdf,.png" placeholder="点击上传 PDF / PNG" hint="仅支持 PDF 或 PNG, ≤200MB" />
</el-form-item>
<el-form-item label="排序">
+13 -13
View File
@@ -3,12 +3,12 @@
<div class="breadcrumb">首页 / 科室管理</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="科室名称"><el-input v-model="q.name" placeholder="输入名称模糊搜索" clearable /></el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部状态" clearable>
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
<el-option label="启用" value="Y" />
<el-option label="停用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
@@ -25,15 +25,15 @@
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'info'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
<el-tag :type="row.status === 'Y' ? 'success' : 'info'">{{ row.status === 'Y' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="openEdit(row)">编辑</el-link>
<el-link :underline="false" :type="row.status === '0' ? 'warning' : 'success'" @click="toggleStatus(row)">
{{ row.status === '0' ? '停用' : '启用' }}
<el-link :underline="false" :type="row.status === 'Y' ? 'warning' : 'success'" @click="toggleStatus(row)">
{{ row.status === 'Y' ? '停用' : '启用' }}
</el-link>
<el-link :underline="false" type="danger" @click="removeOne(row)">删除</el-link>
</div></template>
@@ -63,8 +63,8 @@
</el-form-item>
<el-form-item v-if="isEdit" label="状态">
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
<el-radio value="Y">启用</el-radio>
<el-radio value="N">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
@@ -103,11 +103,11 @@ function reset() {
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const form = reactive({ deptId: null, name: '', sort: 0, status: '0' })
const form = reactive({ deptId: null, name: '', sort: 0, status: 'Y' })
function openAdd() {
isEdit.value = false
Object.assign(form, { deptId: null, name: '', sort: 0, status: '0' })
Object.assign(form, { deptId: null, name: '', sort: 0, status: 'Y' })
dialogVisible.value = true
}
@@ -135,10 +135,10 @@ async function save() {
}
async function toggleStatus(row) {
const newStatus = row.status === '0' ? '1' : '0'
const newStatus = row.status === 'Y' ? 'N' : 'Y'
try {
await updateDepartment({ deptId: row.deptId, name: row.name, sort: row.sort, status: newStatus })
ElMessage.success('已' + (newStatus === '0' ? '启用' : '停用'))
ElMessage.success('已' + (newStatus === 'Y' ? '启用' : '停用'))
load()
} catch (e) {
ElMessage.error(e?.msg || '操作失败')
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 网站管理 / 劳务协议配置</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="模板名">
<el-input v-model="q.templateName" placeholder="输入模板名" clearable />
</el-form-item>
@@ -42,7 +42,7 @@
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" size="small" type="primary" @click="goEdit(row)">编辑</el-link>
<el-link :underline="false" size="small" type="warning" :disabled="row.defaultFlag === 'Y'" @click="setDefault(row)">设为默认</el-link>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="page-card admin-project-category">
<div class="breadcrumb">首页 / 资料库管理 / 项目类别管理</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="类别名称"><el-input v-model="q.dictLabel" placeholder="输入名称模糊搜索" clearable /></el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部状态" clearable style="width:140px">
@@ -27,7 +27,7 @@
</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="130" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="openEdit(row)">编辑</el-link>
<el-link :underline="false" :type="row.status === '0' ? 'warning' : 'success'" @click="toggleStatus(row)">
+45 -19
View File
@@ -35,6 +35,18 @@
</div></template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="pageNum"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</template>
</div>
</template>
@@ -46,40 +58,49 @@ import request from '@/utils/request'
import { ElMessage } from 'element-plus'
const ROLE_LABEL = {
admin: '后台管理员', manager: '合规人员',
doctor: '评审专家', executor: '执行', sponsor: '支持方'
admin: '后台管理员', manager: '合规人员', leader: '项目负责人',
doctor: '评审专家', executor: '执行', sponsor: '支持方'
}
// 卡片展示的角色 (隐藏 admin)
const VISIBLE_ROLES = ['manager', 'doctor', 'executor', 'sponsor']
const VISIBLE_ROLES = ['manager', 'leader', 'doctor', 'executor', 'sponsor']
const roleStats = ref([])
const selected = ref('')
const rows = ref([])
const loading = ref(false)
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(20)
async function loadStats() {
const arr = []
for (const code of VISIBLE_ROLES) {
// 4 个角色计数并行发起, 不再串行 await (串行会叠加 4 次网络往返)
const results = await Promise.all(VISIBLE_ROLES.map(async (code) => {
try {
const r = await listUser({ pageNum: 1, pageSize: 1, roleType: code })
arr.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || r.total || 0 })
return { code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || r.total || 0 }
} catch (e) {
arr.push({ code, label: ROLE_LABEL[code], count: 0 })
return { code, label: ROLE_LABEL[code], count: 0 }
}
}
roleStats.value = arr
// 默认选中第一个角色并加载列表
if (arr.length && !selected.value) {
await selectRole(arr[0].code)
}
}))
roleStats.value = results
}
async function selectRole(code) {
selected.value = code
function selectRole(code) {
// 切换角色时重置回第 1 页; 点当前角色则原地刷新
if (selected.value !== code) {
selected.value = code
pageNum.value = 1
}
loadList()
}
async function loadList() {
if (!selected.value) return
loading.value = true
try {
const r = await listUser({ pageNum: 1, pageSize: 200, roleType: code })
const r = await listUser({ pageNum: pageNum.value, pageSize: pageSize.value, roleType: selected.value })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally {
loading.value = false
}
@@ -94,15 +115,19 @@ async function toggleStatus(row) {
data: { userId: row.userId, status: newStatus }
})
ElMessage.success('已' + (newStatus === '0' ? '启用' : '停用'))
// 刷新当前角色列表 + 顶部卡片计数
await selectRole(selected.value)
// 刷新当前角色列表(保持当前页) + 顶部卡片计数
await loadList()
await loadStats()
} catch (e) {
ElMessage.error(e?.msg || '操作失败')
}
}
onMounted(loadStats)
onMounted(() => {
// 卡片计数 + 默认角色列表并行发起, 避免 5 个请求串行叠加延迟
loadStats()
selectRole(VISIBLE_ROLES[0])
})
</script>
<style scoped>
@@ -147,6 +172,7 @@ onMounted(loadStats)
line-height: 1;
}
.role-table { margin-top: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* ========================================
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 职称管理</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="职称名称"><el-input v-model="q.name" placeholder="输入名称模糊搜索" clearable /></el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部状态" clearable>
@@ -29,7 +29,7 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="openEdit(row)">编辑</el-link>
<el-link :underline="false" :type="row.status === '0' ? 'warning' : 'success'" @click="toggleStatus(row)">
+20 -6
View File
@@ -3,9 +3,10 @@
<div class="breadcrumb">首页 / 用户管理</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<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.phonenumber" placeholder="输入手机号" clearable maxlength="11" /></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">
@@ -34,12 +35,19 @@
:main-cols="['userName', 'nickName']"
>
<el-table-column prop="userId" label="用户ID" width="80" />
<el-table-column prop="userName" label="账号" width="120" />
<el-table-column prop="userName" label="用户名" width="120" />
<el-table-column prop="nickName" label="姓名" width="140" />
<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 label="管理员" width="80" align="center">
<template #default="{ row }">
<el-tag :type="isOrgAdmin(row) ? 'success' : 'danger'" disable-transitions>
{{ isOrgAdmin(row) ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<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">
@@ -48,7 +56,7 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="330" fixed="right">
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="openEdit(row)">编辑</el-link>
<el-link :underline="false" type="primary" @click="onResetPwd(row)">重置密码</el-link>
@@ -189,11 +197,17 @@ const userStore = useUserStore()
const ROLE_LABEL = {
admin: '后台管理员', manager: '合规人员',
doctor: '评审专家', executor: '执行', sponsor: '支持方', leader: '项目负责人'
doctor: '评审专家', executor: '执行', sponsor: '支持方', leader: '项目负责人'
}
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
const q = reactive({ userName: '', nickName: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
// 机构管理员 = 执行方/支持方单位的主账号。
// admin/manager/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员
function isOrgAdmin(row) {
return (row.roleType === 'executor' || row.roleType === 'sponsor') && row.accountType === 'MAIN'
}
const q = reactive({ userName: '', nickName: '', phonenumber: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
@@ -212,7 +226,7 @@ async function load() {
}
function reset() {
q.userName = ''; q.nickName = ''; q.orgName = ''; q.roleType = ''; q.status = ''
q.userName = ''; q.nickName = ''; q.phonenumber = ''; q.orgName = ''; q.roleType = ''; q.status = ''
q.pageNum = 1
load()
}
+5 -5
View File
@@ -24,7 +24,7 @@
<div class="stat-value">{{ stats.totalProjects }}</div>
<div class="stat-extra">查看详情 </div>
</router-link>
<router-link class="stat-card" to="/admin/projects?isFinished=1">
<router-link class="stat-card" to="/admin/projects?isFinished=Y">
<div class="stat-bar"></div>
<div class="stat-label">已结题项目</div>
<div class="stat-value">{{ stats.finishedProjects }}</div>
@@ -104,14 +104,14 @@ async function load() {
const u = await listUser({ pageNum: 1, pageSize: 1 })
stats.value.totalUsers = (u.data && u.data.total) || 0
// 角色类型数 = 业务角色 4 类 (不含 admin)
stats.value.roleCount = 4
// 角色类型数 = 业务角色 5 类 (manager/leader/doctor/executor/sponsor, 不含 admin)
stats.value.roleCount = 5
// 项目数
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
stats.value.totalProjects = ps.total || (ps.data && ps.data.total) || 0
// 已结题
const fp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' })
const fp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: 'Y' })
stats.value.finishedProjects = fp.total || (fp.data && fp.data.total) || 0
// 会议数
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
@@ -200,7 +200,7 @@ onMounted(load)
margin: 0 0 16px;
padding-left: 10px;
border-left: 4px solid var(--brand-primary);
line-height: 1.2;
line-height: 1;
}
/* —— 消息通知卡片 —— */
+6 -3
View File
@@ -257,10 +257,12 @@ async function afterLogin(token, displayName) {
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
name: info.name || '',
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
role,
isPasswordEmpty: !!info.isPasswordEmpty
})
} catch {
// /getInfo 失败: 不存残缺 user, 让用户重新登录
@@ -350,8 +352,9 @@ const roleHome = {
/** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */
function redirectBelongsToRole(path, role) {
if (!path) return false
// 公开门户详情页 (专项计划详情) 允许回跳: 未登录点击计划详情 → 登录后回详情
// 公开门户详情页 (专项计划详情 / 公示详情) 允许回跳: 未登录点击详情 → 登录后回详情
if (path.startsWith('/special-plan/')) return true
if (path.startsWith('/publicity/')) return true
// role=doctor, path=/doctor/xxx → true
// role=doctor, path=/manager/xxx → false
return path === roleHome[role] || path.startsWith('/' + role + '/')
@@ -374,7 +377,7 @@ function confirmRegister() {
// 执行方(供应商)注册: 跳转到独立供应商注册系统, 不走本系统注册页
if (registerUserType.value === 'executor') {
showRoleModal.value = false
window.location.href = 'https://zbsupplier.guojustar.com/'
window.location.href = import.meta.env.VITE_APP_EXECUTOR_REGISTER_URL || 'https://zbsupplier.guojustar.com/'
return
}
const routeMap = {
+7 -7
View File
@@ -16,7 +16,7 @@
<template v-if="step === 0">
<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" label-position="right">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" />
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" autocomplete="off" />
</el-form-item>
<el-form-item label="企业名称" prop="unitName">
@@ -34,12 +34,12 @@
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" autocomplete="off" />
</el-form-item>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" />
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
</el-button>
@@ -47,11 +47,11 @@
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" />
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" autocomplete="new-password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" autocomplete="new-password" />
</el-form-item>
<el-form-item>
@@ -72,7 +72,7 @@
<!-- ============ Step 1: 注册成功 ============ -->
<template v-else>
<el-result icon="success" title="注册成功" sub-title="我们会在 3 个工作日内完成审核">
<el-result icon="success" title="注册成功">
<template #extra>
<el-button type="primary" @click="$router.push('/login')">前往登录</el-button>
</template>
@@ -198,7 +198,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; border-left: none; padding-left: 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
+5 -5
View File
@@ -26,21 +26,21 @@
<DoctorTitleSelect v-model="form.doctorTitle" value-field="label" placeholder="请输入职称" />
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" autocomplete="off" />
</el-form-item>
<el-form-item label="短信验证码" prop="code">
<div class="sms-row">
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" />
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" />
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" autocomplete="new-password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" autocomplete="new-password" />
</el-form-item>
<el-form-item label="执业证书/证明" prop="licenseCertUrl">
<OssImageUploader v-model="form.licenseCertUrl" dir="ry8080/cert/license/" placeholder="+" />
@@ -187,7 +187,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; border-left: none; padding-left: 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
+7 -7
View File
@@ -15,7 +15,7 @@
<template v-if="step === 0">
<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" label-position="right">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" />
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" autocomplete="off" />
</el-form-item>
<el-form-item label="企业名称" prop="orgId">
@@ -42,12 +42,12 @@
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" autocomplete="off" />
</el-form-item>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" />
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
</el-button>
@@ -55,11 +55,11 @@
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" />
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" autocomplete="new-password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" autocomplete="new-password" />
</el-form-item>
<el-form-item>
@@ -80,7 +80,7 @@
<!-- ============ Step 1: 注册成功 ============ -->
<template v-else>
<el-result icon="success" title="注册成功" sub-title="我们会在 3 个工作日内完成审核">
<el-result icon="success" title="注册成功">
<template #extra>
<el-button type="primary" @click="$router.push('/login')">前往登录</el-button>
</template>
@@ -218,7 +218,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; border-left: none; padding-left: 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
+35 -73
View File
@@ -16,7 +16,7 @@
<el-tab-pane label="个人信息" name="profile">
<div class="card">
<!-- 基本资料 (2 ) -->
<div class="section-title">基本资料</div>
<div class="new-card-title">基本资料</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="专家姓名" :required="true">
<el-input v-model="form.name" placeholder="请填写姓名" />
@@ -47,7 +47,7 @@
</el-form>
<!-- 银行信息 -->
<div class="section-title">银行信息</div>
<div class="new-card-title">银行信息</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="开户姓名" :required="true">
<el-input v-model="form.bankName" placeholder="请填写开户姓名" />
@@ -64,7 +64,7 @@
</el-form>
<!-- 资质证件 -->
<div class="section-title">资质证件</div>
<div class="new-card-title">资质证件</div>
<el-form :model="form" class="form-grid form-grid-single" label-position="right" label-width="120px">
<el-form-item label="身份证照片">
<id-card-uploader
@@ -101,7 +101,7 @@
<div class="card">
<el-form :model="pwd" label-position="right" label-width="120px" class="password-form">
<el-form-item label="原密码" :required="true">
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" />
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" autocomplete="new-password" />
</el-form-item>
<el-form-item label="新密码" :required="true">
<el-input v-model="pwd.new" type="password" show-password placeholder="请输入新密码" />
@@ -134,11 +134,11 @@
</div>
</el-form-item>
<div class="form-error">{{ phoneError }}</div>
<template #footer>
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
</template>
</el-form>
<template #footer>
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -393,7 +393,7 @@ onUnmounted(() => {
</script>
<style scoped>
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; }
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 8px; }
.breadcrumb .link { color: var(--brand-primary); cursor: pointer; }
.breadcrumb .link:hover { text-decoration: underline; }
@@ -411,14 +411,16 @@ onUnmounted(() => {
margin-top: 16px;
}
.section-title {
.new-card-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin: 16px 0 12px;
padding-left: 8px;
border-left: 3px solid var(--brand-primary);
line-height: 1;
}
.new-card-title:first-child { margin-top: 0; }
.form-grid {
display: grid;
@@ -454,82 +456,42 @@ onUnmounted(() => {
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
- form-grid 双列 → 单列
- form-item: label 左 + 输入框右, 内容自适应剩余宽度
- 底部按钮等宽并排
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
.page-title { font-size: 18px !important; margin-bottom: 12px !important; }
/* 工具栏: 横向滚动 */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* 内容卡片 padding 收窄 */
.card { padding: 14px 12px !important; margin-top: 12px !important; }
.new-card-title { font-size: 13px !important; margin: 12px 0 10px !important; }
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
/* form-grid: 双列 → 单列 (gap 清 0, 间距交给 form-item 的 margin-bottom, 避免双倍堆叠) */
.form-grid { grid-template-columns: 1fr !important; gap: 0 !important; }
/* form-item: 横向 (label 左 + 内容右), 内容自适应剩余宽度 */
.form-grid :deep(.el-form-item),
.password-form :deep(.el-form-item) {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
align-items: flex-start !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
margin-bottom: 14px !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
.form-grid :deep(.el-form-item__content),
.password-form :deep(.el-form-item__content) {
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽 */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
/* 密码表单全宽 */
.password-form { max-width: none !important; }
/* 底部按钮: 等宽并排 */
.form-actions { gap: 10px !important; margin-top: 12px !important; }
.form-actions :deep(.el-button) { flex: 1 1 0 !important; width: 0 !important; }
}
</style>
+34 -18
View File
@@ -18,23 +18,33 @@
<div class="section" v-if="store.expertAuditApproved">
<h2 class="section-title">
待签署的协议
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
<a class="more" @click.prevent="$router.push('/doctor/meetings?signedStatus=unsigned')">更多 </a>
</h2>
<ul class="simple-list">
<li
class="simple-item"
:class="{ disabled: s.isEsigned !== 1 }"
v-for="(s, idx) in pendingAgreements"
:key="s.id"
@click="s.isEsigned === 1 && showQrcode(s, idx)"
@click="showQrcode(s, idx)"
>
<div class="item-main">
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div>
<span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
<span class="item-status">待签署</span>
</li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul>
<el-pagination
v-if="pendingTotal > page.pageSize"
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:page-sizes="[10, 20, 50]"
:total="pendingTotal"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 12px; text-align: right;"
@current-change="load"
@size-change="load"
/>
</div>
<!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
@@ -65,7 +75,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
@@ -76,6 +86,9 @@ import NoticeList from '@/components/NoticeList.vue'
const store = useUserStore()
const pendingAgreements = ref([])
const pendingTotal = ref(0)
// 待签署列表分页 (后端 RuoYi TableDataInfo, 返回 {rows, total}; 翻页 = 重新拉一次)
const page = reactive({ pageNum: 1, pageSize: 5 })
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
@@ -149,20 +162,23 @@ async function load() {
expertName.value = data?.name || ''
} catch (e) { expertName.value = '' }
// 待签署协议: 仅审核通过的医生才拉 (未通过时 panel 隐藏)
if (store.expertAuditApproved) {
try {
const { data } = await listUnsignedMeetingProtocols()
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
id: s.id,
meetingId: s.meetingId,
meetingName: s.meetingName,
startTime: s.startTime,
isEsigned: s.isEsigned
}))
} catch (e) { pendingAgreements.value = [] }
} else {
// 待签署协议: 无条件拉, v-if 控制显示。
// 不能用 store.expertAuditApproved 守门, 否则会与 AdminLayout.loadExpertAuditStatus 形成时序竞争:
// Home.vue onMounted 触发 load() 时 store.expertAuditApproved 仍是 false (AdminLayout 异步拉审核状态还没回),
// API 不调 → pendingAgreements=[] → 之后 store.expertAuditApproved=true 让 v-if 显示 section, 但数据永远为空。
try {
const { data } = await listUnsignedMeetingProtocols(page.pageNum, page.pageSize)
pendingAgreements.value = (data?.rows || []).map(s => ({
id: s.id,
meetingId: s.meetingId,
meetingName: s.meetingName,
startTime: s.startTime,
isEsigned: s.isEsigned
}))
pendingTotal.value = data?.total || 0
} catch (e) {
pendingAgreements.value = []
pendingTotal.value = 0
}
// 通知列表已抽到 <NoticeList> 组件, 本页不再处理
}
+18 -2
View File
@@ -2,7 +2,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / 我参与的会议</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
<el-form-item label="当前阶段">
@@ -10,6 +10,12 @@
<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-select v-model="signedStatus" placeholder="全部" clearable>
<el-option label="待签署" value="unsigned" />
<el-option label="已签署" value="signed" />
</el-select>
</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>
@@ -51,6 +57,7 @@
</el-table>
<el-pagination
v-if="page.total > page.pageSize"
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
@@ -107,12 +114,18 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
import PdfViewer from '@/components/PdfViewer.vue'
// Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议
// 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL
const route = useRoute()
const signedStatus = ref(String(route.query.signedStatus || '') || '')
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
const rows = ref([])
@@ -131,7 +144,9 @@ const signQrLoading = ref(false)
async function load() {
loading.value = true
try {
const { data } = await bizList('meeting', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
const params = { ...q, pageNum: page.pageNum, pageSize: page.pageSize }
if (signedStatus.value) params.signedStatus = signedStatus.value
const { data } = await bizList('meeting', params)
rows.value = data?.rows || []
page.total = data?.total || 0
} catch (e) {
@@ -144,6 +159,7 @@ function reset() {
q.projectNo = ''
q.meetingName = ''
q.currentStage = ''
signedStatus.value = ''
page.pageNum = 1
load()
}
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 我报名的项目 / <span class="current">项目详情</span></div>
<!-- 项目信息 (只读) -->
<div class="new-card-title">项目信息</div>
<el-form v-if="row && row.projectId" label-width="120px" class="info-form">
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="项目编号"><span class="info-value">{{ row.projectNo || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目名称"><span class="info-value">{{ row.projectName || '-' }}</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.projectForm || '-' }}</span></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-tag :type="signed ? 'success' : 'info'" effect="light">{{ signed ? '已报名' : '未报名' }}</el-tag>
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-empty v-else-if="loaded" description="项目不存在或无权限" />
<!-- 邀请函 (页内预览) -->
<div class="new-card-title">邀请函</div>
<template v-if="invitationUrl">
<div class="file-toolbar">
<el-button type="primary" size="small" @click="download">下载</el-button>
</div>
<div class="invite-preview">
<PdfViewer v-if="kind === 'pdf'" :url="invitationUrl" />
<img v-else-if="kind === 'image'" :src="invitationUrl" class="invite-img" :alt="invitationName || '邀请函'" />
<div v-else class="invite-fallback">
<p>该文件类型暂不支持页内预览,请下载后查看</p>
<el-button type="primary" @click="download">下载</el-button>
</div>
</div>
</template>
<div v-else class="file-empty">未上传</div>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizGet } from '@/api/public'
import request from '@/utils/request'
import PdfViewer from '@/components/PdfViewer.vue'
const route = useRoute()
const router = useRouter()
const projectId = ref('')
const row = ref({})
const loaded = ref(false)
const signed = ref(false)
const invitationUrl = computed(() => row.value?.invitationUrl || '')
const invitationName = computed(() => fileNameFromUrl(invitationUrl.value))
// 邀请函文件类型: 仅 pdf / image 支持页内预览, 其它走下载兜底
const kind = computed(() => kindOf(invitationUrl.value))
// 从 OSS URL 提取并解码文件名 (中文文件名是 URL 编码的, 需 decodeURIComponent 还原)
function fileNameFromUrl(url) {
if (!url) return ''
let last
try {
const path = String(url).split('?')[0]
last = decodeURIComponent(path.split('/').pop() || '')
} catch (e) {
last = String(url).split('?')[0].split('/').pop() || ''
}
const m = last.match(/^(.+)_\d{13}_[a-z0-9]{6}(\.[^.]+)?$/)
if (m) return m[1] + (m[2] || '')
return last
}
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'
}
async function load() {
projectId.value = route.params.projectId
if (!projectId.value) { loaded.value = true; return }
try {
const resp = await bizGet('project', projectId.value)
row.value = (resp && resp.data) || resp || {}
} catch (e) {
console.error('[doctor-project-detail] load failed', e)
row.value = {}
} finally {
loaded.value = true
}
// 报名状态: del_flag='0' 的真报名才算"已报名", 幽灵占位(del_flag='1')算"未报名"
try {
const res = await request.get('/business/executionIntent/hasSigned', { params: { projectId: projectId.value } })
signed.value = res?.data === true
} catch (e) { signed.value = false }
}
function download() {
const url = invitationUrl.value
if (!url) return
// OSS bucket Content-Disposition: attachment, 直接访问即触发下载
const a = document.createElement('a')
a.href = url
a.download = invitationName.value || '邀请函'
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function goBack() {
router.push('/doctor/projects')
}
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; }
.new-card-title {
font-size: 14px; font-weight: 600; color: #262626;
margin: 16px 0 12px; padding-left: 8px;
border-left: 3px solid var(--brand-primary); line-height: 1;
}
.new-card-title:first-child { margin-top: 0; }
.info-form { padding-top: 4px; }
.info-form :deep(.el-form-item) { margin-bottom: 8px; }
.info-form :deep(.el-form-item__label) {
font-size: 14px; color: #606266; width: 120px; padding-right: 16px;
}
.info-value { font-size: 14px; color: #262626; font-weight: 400; }
/* 邀请函预览 */
.file-toolbar {
display: flex; align-items: center; gap: 12px;
padding: 8px 0;
}
.file-empty { color: #909399; font-size: 13px; padding: 6px 0; }
.invite-preview { margin-top: 4px; }
.invite-img { max-width: 100%; max-height: 72vh; object-fit: contain; border: 1px solid #ebeef5; border-radius: 4px; }
.invite-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.invite-fallback p { margin: 12px 0 24px; }
.form-actions { margin-top: 24px; display: flex; gap: 8px; }
/* ========================================
移动端适配 (≤768px)
======================================== */
@media (max-width: 768px) {
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
.new-card-title { font-size: 13px !important; margin: 12px 0 10px !important; padding-left: 6px !important; }
.info-form :deep(.el-form-item) {
display: block !important;
margin-bottom: 10px !important;
}
.info-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
text-align: left !important;
padding: 0 0 4px !important;
font-size: 13px !important;
color: var(--el-text-color-secondary) !important;
}
.info-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 1.6 !important;
}
.info-form :deep(.el-row) { margin: 0 !important; }
.info-form :deep(.el-col) { padding: 0 !important; margin-bottom: 0 !important; }
.invite-img { max-height: 60vh !important; }
.form-actions { flex-direction: column !important; }
.form-actions .el-button { width: 100% !important; }
}
</style>
+5 -22
View File
@@ -2,7 +2,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / 我报名的项目</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
@@ -45,28 +45,12 @@
@size-change="load"
style="margin-top: 12px; text-align: right;"
/>
<el-dialog v-model="detailOpen" title="项目详情" width="560px">
<el-descriptions :column="2" border>
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="支持方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额">{{ detail.totalAmount || 0 }}</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">{{ detail.endTime || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { bizList, bizAdd } from '@/api/public'
import request from '@/utils/request'
@@ -78,8 +62,6 @@ const q = reactive({ projectNo: '', projectName: '', meetingName: '' })
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
const rows = ref([])
const loading = ref(false)
const detail = ref({})
const detailOpen = ref(false)
async function load() {
loading.value = true
@@ -106,8 +88,9 @@ function hasFile(row) {
}
function onView(row) {
detail.value = row
detailOpen.value = true
const id = row.projectId || row.id
if (!id) { ElMessage.warning('项目信息缺失'); return }
router.push(`/doctor/projects/detail/${id}`)
}
function onPublicity(row) {
+3 -1
View File
@@ -191,10 +191,12 @@ async function ensureLogin() {
userId: u.userId,
userName: u.userName || u.nickName || '',
nickName: u.nickName || '',
name: info.name || '',
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
role,
isPasswordEmpty: !!info.isPasswordEmpty
})
return true
} catch (e) {
+2 -2
View File
@@ -45,8 +45,8 @@
v-model="form.designFileUrl"
dir="ry8080/submission/design/"
placeholder="点击上传设计文件"
hint="支持 PDF / 图片, 单文件 ≤ 20MB"
:max-size="20"
hint="支持 PDF / 图片, 单文件 ≤ 200MB"
:max-size="200"
block
/>
</el-form-item>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / 我的项目策划方案</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="策划方案名称"><el-input v-model="q.planName" placeholder="输入策划方案名称" clearable style="width: 200px" /></el-form-item>
<el-form-item label="项目方向">
<el-select v-model="q.planDirectionId" placeholder="请选择" clearable style="width: 200px">
@@ -12,7 +12,7 @@
<div class="breadcrumb">首页 / 执行单位管理</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="单位名称">
<el-input v-model="q.orgName" placeholder="输入单位名称" clearable style="width:200px" />
</el-form-item>
@@ -53,7 +53,7 @@
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" :width="isAdmin ? 240 : 180" fixed="right">
<el-table-column label="操作" :width="isAdmin ? 200 : 150" fixed="right">
<template #default="{ row }"><div class="table-actions">
<!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) -->
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
@@ -11,7 +11,7 @@
</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
</el-form-item>
@@ -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="180" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }"><div class="table-actions">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-link :underline="false" type="primary" @click="goView(row)">查看</el-link>
@@ -77,6 +77,8 @@
</el-link>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-link :underline="false" v-if="row.userId" type="primary" @click="onResetPwd(row)">重置密码</el-link>
<!-- 删除 (软删除): 软删关联登录账号 sys_user.del_flag, 机构管理员(MAIN)需先更换管理员 -->
<el-link :underline="false" type="danger" @click="onDelete(row)">删除</el-link>
</div></template>
</el-table-column>
</el-table>
@@ -125,7 +127,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { bizList, bizUpdate, bizDelete, 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'
@@ -237,21 +239,42 @@ async function onResetPwd(row) {
}
}
// ========== 删除 (软删除) ==========
async function onDelete(row) {
// 机构管理员 (MAIN) 不能直接删除: 删了会导致机构无主账号, 需先更换管理员
if (row.accountType === 'MAIN') { ElMessage.warning('机构管理员不能删除,请先更换管理员'); return }
try {
await ElMessageBox.confirm(
`确定删除「${row.name}」吗?删除后其登录账号将一并停用`,
'删除确认',
{ type: 'warning' }
)
} catch { return }
try {
await bizDelete('person', row.personId)
ElMessage.success('已删除')
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
// ========== 跳转 (按角色) ==========
// 统一携带 orgId/orgName, 保证 列表→编辑/详情→返回 全程参数不丢 (否则编辑/查看后回跳列表会丢失筛选上下文)
function listQuery() {
return {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
}
function goNew() {
router.push({
path: listBasePath.value + '/new',
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
router.push({ path: listBasePath.value + '/new', query: listQuery() })
}
function goEdit(row) {
router.push(listBasePath.value + '/edit/' + row.personId)
router.push({ path: listBasePath.value + '/edit/' + row.personId, query: listQuery() })
}
function goView(row) {
router.push(listBasePath.value + '/view/' + row.personId)
router.push({ path: listBasePath.value + '/view/' + row.personId, query: listQuery() })
}
// ========== 批量导入 ==========
@@ -66,6 +66,8 @@ const personId = route.params.id
// 角色感知: admin/manager 都能跳到此页, 回跳路径按 route 区分
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-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({
@@ -83,7 +85,14 @@ const form = reactive({
})
function goBack() {
router.push(listBasePath.value)
// 回跳列表时带 orgId/orgName, 避免从列表页带参数跳进详情页后, 返回丢失筛选上下文
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
@@ -92,7 +92,14 @@ const rules = {
}
function goBack() {
router.push(listBasePath.value)
// 回跳列表时带 orgId/orgName, 否则从列表页 (带 ?orgId=..&orgName=..) 跳进编辑页后, 返回会丢失筛选上下文
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
+15 -3
View File
@@ -5,8 +5,11 @@
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<el-form-item label="单位名称">
<el-input v-model="form.orgName" disabled placeholder="-" />
</el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" autocomplete="new-password" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入新密码" /></el-form-item>
<div class="filter-tip">* 密码长度 6-20 ,支持数字字母特殊字符;留空表示不修改密码</div>
@@ -20,8 +23,9 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
const form = reactive({ nickName: '', phonenumber: '', oldPassword: '', newPassword: '', confirmPassword: '' })
const form = reactive({ orgName: '', nickName: '', phonenumber: '', oldPassword: '', newPassword: '', confirmPassword: '' })
const profile = ref({})
const org = ref({})
const saving = ref(false)
const formRef = ref(null)
@@ -46,6 +50,14 @@ async function loadProfile() {
} catch { ElMessage.error('获取个人信息失败') }
}
async function loadMyCompany() {
try {
const { data } = await request({ url: '/business/org/myCompany', method: 'get' })
org.value = data || {}
form.orgName = org.value.orgName || ''
} catch { /* 静默, 不影响其他字段回显 */ }
}
async function onSave() {
if (form.newPassword && form.newPassword !== form.confirmPassword) {
ElMessage.error('两次输入密码不一致'); return
@@ -70,7 +82,7 @@ function onCancel() {
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
}
onMounted(loadProfile)
onMounted(() => { loadProfile(); loadMyCompany() })
</script>
<style scoped>
+4 -4
View File
@@ -10,7 +10,7 @@
</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="会议名称"><el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" /></el-form-item>
<el-form-item label="专家姓名"><el-input v-model="q.expertName" clearable placeholder="输入专家姓名" style="width:160px" /></el-form-item>
<el-form-item>
@@ -83,7 +83,7 @@
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将付款凭证 PDF 拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">仅支持 PDF, 20MB</div>
<div class="el-upload__tip" slot="tip">仅支持 PDF, 200MB</div>
</el-upload>
</el-form-item>
</el-form>
@@ -153,8 +153,8 @@ function previewVoucher(url) {
}
function beforeVoucherUpload(file) {
const max = 20 * 1024 * 1024
if (file.size > max) { ElMessage.error('文件不能超过 20MB'); return false }
const max = 200 * 1024 * 1024
if (file.size > max) { ElMessage.error('文件不能超过 200MB'); return false }
if (!file.name.toLowerCase().endsWith('.pdf')) { ElMessage.error('仅支持 PDF 格式'); return false }
return true
}
+8 -2
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 会议列表</div>
<!-- ========== 筛选区 (仿 manager/meetings/Meetings.vue: 项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:170px" /></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>
@@ -72,7 +72,7 @@
<template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
<el-link :underline="false" type="primary" @click="onEdit(row)">编辑</el-link>
<el-link :underline="false" type="primary" @click="onUpload(row)">上传材料</el-link>
<el-link :underline="false" type="primary" :disabled="isFrozen(row)" @click="onUpload(row)">上传材料</el-link>
<el-link :underline="false" type="primary" @click="onCopy(row)">复制</el-link>
</div></template>
</el-table-column>
@@ -149,6 +149,12 @@ function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
// 冻结中 → 上传材料置灰不可点 (仅 executor 列表; 解冻后 isFrozen 翻 0 自动恢复)
// is_frozen 是数值列 (SQL 用 is_frozen=0/1), BizMeeting.isFrozen 是 Integer, 后端判据 getIsFrozen()==1
function isFrozen(row) {
return row?.isFrozen === 1
}
function onEdit(row) {
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
}
+2 -2
View File
@@ -12,7 +12,7 @@
<div class="stat-label">已结算数量</div>
<div class="stat-value">{{ stats.settledProjects }}</div>
</router-link>
<router-link class="stat-card" to="/executor/projects?isFinished=1">
<router-link class="stat-card" to="/executor/projects?isFinished=Y">
<div class="stat-label">已结题数量</div>
<div class="stat-value">{{ stats.completedProjects }}</div>
</router-link>
@@ -54,7 +54,7 @@ async function loadStats() {
}
stats.value.totalProjects = await projectCount()
stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: '1' })
stats.value.completedProjects = await projectCount({ isFinished: 'Y' })
// 会议统计走 /business/meeting/list (后端已按 roleType MAIN/SUB 隔离)
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
const meetingTotal = ms.total || ms.data?.total || 0
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 人员管理</div>
<!-- ========== 筛选区 (按原型 人员管理_1.html top=162) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="loadList">
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" /></el-form-item>
<el-form-item label="所属公司"><el-input v-model="q.orgName" placeholder="所属公司" clearable style="width:160px" /></el-form-item>
+8 -7
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 项目列表</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="query" class="filter-form">
<el-form inline :model="query" class="filter-form" @keyup.enter="loadList">
<el-form-item label="项目编号"><el-input v-model="query.projectNo" clearable placeholder="项目编号" style="width:160px" /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="query.projectName" clearable placeholder="项目名称" style="width:200px" /></el-form-item>
<el-form-item label="项目时间">
@@ -11,8 +11,8 @@
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="query.isFinished" clearable placeholder="请选择" style="width:140px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
<el-option label="已结题" value="Y" />
<el-option label="未结题" value="N" />
</el-select>
</el-form-item>
<el-form-item label="是否结算">
@@ -81,8 +81,8 @@
</el-table-column>
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isFinished === '1' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
{{ row.isFinished === '1' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
<el-tag :type="row.isFinished === 'Y' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
{{ row.isFinished === 'Y' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
</el-tag>
</template>
</el-table-column>
@@ -187,7 +187,7 @@ async function loadList() {
finally { loading.value = false }
}
// 读 URL query 写入 query (Overview KPI 卡跳转时带 ?isSettled=Y / ?isFinished=1, 让列表页自动应用筛选)
// 读 URL query 写入 query (Overview KPI 卡跳转时带 ?isSettled=Y / ?isFinished=Y, 让列表页自动应用筛选)
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
const router = useRouter()
@@ -211,6 +211,7 @@ function viewDetail(row) { router.push(`/executor/projects/detail/${row.projectI
// 点击「总场次/已执行/未执行」列 → 跳 executor 会议列表, 按项目 + 阶段筛选
// 数据权限完全交给会议列表后端: MAIN → execution_unit_id 本公司; SUB → create_by 本人, 跳转不新增任何越权参数
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中) 的其余 stage (含 FROZEN); 未执行 = NOT_STARTED
function goMeetings(row, scope) {
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
@@ -296,7 +297,7 @@ function atCap(row) {
return built >= assigned
}
// 结题项目不显示建会入口 (与 manager/项目管理 页一致)
function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === '已结题' }
function isFinishedRow(row) { return row.isFinished === 'Y' || row.isFinished === '已结题' }
function onCreateMeeting(row) {
router.push(`/executor/meetings/new?projectId=${row.projectId}`)
+2 -2
View File
@@ -12,7 +12,7 @@
<div class="breadcrumb">首页 / {{ isAdmin ? '专家管理' : '专家审核' }}</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable style="width:160px" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="输入手机号" clearable maxlength="11" style="width:160px" /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="q.workUnit" placeholder="输入工作单位" clearable style="width:200px" /></el-form-item>
@@ -98,7 +98,7 @@
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
</el-table-column>
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
<el-table-column label="操作" :width="isAdmin ? 320 : 130" fixed="right">
<el-table-column label="操作" :width="isAdmin ? 180 : 130" fixed="right">
<template #default="{ row }"><div class="table-actions">
<!-- 审核 (通过/拒绝): 两角色都有, manager 主业, admin 可兜底 -->
<el-link :underline="false" type="primary" @click="onAudit(row)">审核</el-link>
+1 -1
View File
@@ -23,7 +23,7 @@
<el-tab-pane label="修改密码" name="password">
<el-form :model="pwd" label-width="100px" class="account-form">
<el-form-item label="旧密码">
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" />
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" autocomplete="new-password" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwd.newPassword" type="password" show-password placeholder="请输入新密码" />
+4 -4
View File
@@ -19,7 +19,7 @@
<div class="stat-value">{{ stats.settledProjects }}</div>
<div class="stat-desc">查看已结算项目</div>
</router-link>
<router-link class="stat-card" to="/leader/projects?isFinished=1">
<router-link class="stat-card" to="/leader/projects?isFinished=Y">
<div class="stat-label">已结题数量</div>
<div class="stat-value">{{ stats.completedProjects }}</div>
<div class="stat-desc">查看已结题项目</div>
@@ -52,9 +52,9 @@ import NoticeList from '@/components/NoticeList.vue'
const stats = ref({
totalProjects: 0,
settledProjects: 0, // 项目级 isSettled=Y
completedProjects: 0, // 项目级 isFinished=1
completedProjects: 0, // 项目级 isFinished=Y
pendingMeetings: 0, // 未执行 = NOT_STARTED
executedMeetings: 0 // 已执行 = 排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN)
executedMeetings: 0 // 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中) 的其余 stage (含 FROZEN)
})
async function loadStats() {
@@ -66,7 +66,7 @@ async function loadStats() {
}
stats.value.totalProjects = await projectCount()
stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: '1' })
stats.value.completedProjects = await projectCount({ isFinished: 'Y' })
// 会议统计走 /business/meeting/stageStats (后端已按 leader 隔离), 返回 { stage: cnt }
const ss = await request.get('/business/meeting/stageStats')
const stageMap = ss?.data || {}
+1 -1
View File
@@ -6,7 +6,7 @@
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" maxlength="11" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" autocomplete="new-password" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入新密码" /></el-form-item>
<div class="filter-tip">* 密码长度 6-20 ,支持数字字母特殊字符;留空表示不修改密码</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 执行意向</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable /></el-form-item>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="page-card manager-invite-detail">
<div class="breadcrumb">首页 / 发送邀请 / 邀请详情</div>
<div class="head-bar">
<el-button @click="goBack">返回</el-button>
<el-button type="success" @click="onSend">发送</el-button>
</div>
<el-descriptions v-if="invite" :column="2" border class="desc">
<el-descriptions-item label="项目编号">{{ invite.projectNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="会议名称">{{ invite.meetingName || '-' }}</el-descriptions-item>
<el-descriptions-item label="期数">{{ invite.periodNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="会议时间">{{ fmt(invite.meetingTime) }}</el-descriptions-item>
<el-descriptions-item label="发送渠道">{{ channelLabel(invite.sendChannel) }}</el-descriptions-item>
<el-descriptions-item label="发送时间">{{ invite.sendTime ? fmt(invite.sendTime) : '未发送' }}</el-descriptions-item>
<el-descriptions-item label="邀请专家数">{{ invite.recipientCount ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="已确认数">{{ invite.confirmedCount ?? '-' }}</el-descriptions-item>
<el-descriptions-item v-if="invite.redheadFileUrl" label="红头文件" :span="2">
<el-link type="primary" :href="invite.redheadFileUrl" target="_blank">点击查看</el-link>
</el-descriptions-item>
</el-descriptions>
<div class="content-block">
<div class="content-title">邀请内容</div>
<div class="content-body" v-html="invite?.content || ''"></div>
</div>
<div class="recipient-block">
<div class="content-title">收件专家</div>
<el-table :data="recipients" v-loading="loading" border>
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column label="来源" width="80" align="center">
<template #default="{ row }">{{ row.source === 'E' ? '专家库' : '导入' }}</template>
</el-table-column>
<el-table-column label="发送状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="sendTag(row.sendStatus)" size="small">{{ sendLabel(row.sendStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="确认状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.confirmStatus === '1' ? 'success' : 'info'" size="small">
{{ row.confirmStatus === '1' ? '已确认' : '未确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="failReason" label="失败原因" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInvite, sendInvite } from '@/api/business/invite'
const router = useRouter()
const route = useRoute()
const invite = ref(null)
const recipients = ref([])
const loading = ref(false)
async function load() {
loading.value = true
try {
const { data } = await getInvite(route.params.id)
invite.value = data?.invite || null
recipients.value = data?.recipients || []
} catch {
invite.value = null; recipients.value = []
} finally {
loading.value = false
}
}
function goBack() { router.push('/manager/invites') }
function fmt(t) { return t ? new Date(t.replace(/-/g, '/')).toLocaleString('zh-CN') : '' }
function channelLabel(c) {
if (c === 'sms') return '短信'
if (c === 'email') return '邮件'
return '短信+邮件'
}
function sendLabel(s) {
if (s === '1') return '成功'
if (s === '2') return '失败'
return '未发送'
}
function sendTag(s) {
if (s === '1') return 'success'
if (s === '2') return 'danger'
return 'info'
}
async function onSend() {
try {
await ElMessageBox.confirm('确认向收件专家发送邀请短信/邮件?', '提示', { type: 'warning' })
} catch { return }
try {
const { data } = await sendInvite(route.params.id)
ElMessage.success(`已发送 ${data?.sent ?? 0} 条邀请`)
load()
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
}
onMounted(load)
</script>
<style scoped>
.manager-invite-detail { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.head-bar { display: flex; justify-content: space-between; margin-bottom: 12px; }
.desc { background: #fff; padding: 8px; border-radius: 6px; }
.content-block, .recipient-block { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #f0f0f0; margin-top: 12px; }
.content-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
.content-body { line-height: 1.7; color: #333; }
.content-body :deep(img) { max-width: 100%; }
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="page-card manager-invite-list">
<div class="breadcrumb">首页 / 发送邀请</div>
<el-form inline :model="q" class="filter-form" @keyup.enter="onSearch">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="success" @click="goNew">新建邀请</el-button>
</el-form-item>
</el-form>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="140" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="periodNo" label="期数" width="80" align="center" />
<el-table-column label="会议时间" width="160" align="center">
<template #default="{ row }">{{ fmt(row.meetingTime) }}</template>
</el-table-column>
<el-table-column label="发送渠道" width="100" align="center">
<template #default="{ row }">{{ channelLabel(row.sendChannel) }}</template>
</el-table-column>
<el-table-column prop="recipientCount" label="邀请专家数" width="100" align="center" />
<el-table-column prop="confirmedCount" label="已确认数" width="90" align="center" />
<el-table-column label="发送时间" width="160" align="center">
<template #default="{ row }">{{ row.sendTime ? fmt(row.sendTime) : '未发送' }}</template>
</el-table-column>
<el-table-column label="操作" width="170" align="center" fixed="right">
<template #default="{ row }">
<el-link :underline="false" type="primary" @click="goDetail(row)">详情</el-link>
<el-link :underline="false" type="success" @click="onSend(row)">发送</el-link>
<el-link :underline="false" type="danger" @click="onDelete(row)">删除</el-link>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total"
:page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
@current-change="load" @size-change="load" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listInvites, sendInvite, deleteInvite } from '@/api/business/invite'
const router = useRouter()
const q = ref({ projectNo: '', meetingName: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
async function load() {
loading.value = true
try {
const { data } = await listInvites({ ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch {
rows.value = []; page.total = 0
} finally {
loading.value = false
}
}
function onSearch() { page.pageNum = 1; load() }
function onReset() { q.value = { projectNo: '', meetingName: '' }; onSearch() }
function fmt(t) { return t ? new Date(t.replace(/-/g, '/')).toLocaleString('zh-CN') : '' }
function channelLabel(c) {
if (c === 'sms') return '短信'
if (c === 'email') return '邮件'
return '短信+邮件'
}
function goNew() { router.push('/manager/invites/new') }
function goDetail(row) { router.push(`/manager/invites/detail/${row.id}`) }
async function onSend(row) {
try {
await ElMessageBox.confirm(`确认向「${row.meetingName || row.projectNo || ''}」的专家发送邀请短信/邮件?`, '提示', { type: 'warning' })
} catch { return }
try {
const { data } = await sendInvite(row.id)
ElMessage.success(`已发送 ${data?.sent ?? 0} 条邀请`)
load()
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm(`确认删除该邀请?`, '提示', { type: 'warning' })
} catch { return }
try {
await deleteInvite([row.id])
ElMessage.success('已删除')
load()
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
}
}
onMounted(load)
</script>
<style scoped>
.manager-invite-list { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; align-items: center; margin-top: 12px; }
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="page-card manager-invite-new">
<div class="breadcrumb">
<a @click="goBack">发送邀请</a><span class="sep">&gt;</span><span class="current">新建邀请</span>
</div>
<el-form :model="form" label-width="100px" class="invite-form">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item label="项目编号"><el-input v-model="form.projectNo" placeholder="手输项目编号" /></el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="会议名称"><el-input v-model="form.meetingName" placeholder="手输会议名称" /></el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="期数"><el-input v-model="form.periodNo" placeholder="手输期数" /></el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="会议时间">
<el-date-picker v-model="form.meetingTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择会议时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="发送渠道">
<el-select v-model="form.sendChannel" style="width: 100%">
<el-option label="短信" value="sms" />
<el-option label="邮件" value="email" />
<el-option label="短信+邮件" value="both" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="红头文件">
<div class="redhead-row">
<el-input v-model="form.redheadFileUrl" placeholder="红头文件 URL" clearable />
<el-upload :show-file-list="false" :auto-upload="false" :on-change="onRedheadChange"
accept="image/*,.pdf,.doc,.docx" style="display:inline-block;margin-left:8px">
<el-button>上传</el-button>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="邀请内容">
<RichEditor v-model="form.content" placeholder="输入邀请内容(富文本)" style="width:100%" />
</el-form-item>
</el-form>
<!-- 专家列表 -->
<div class="recipient-section">
<div class="section-head">
<span class="section-title">专家列表</span>
<div>
<el-button size="small" @click="openExpertDialog">从专家库选择</el-button>
<el-button size="small" @click="openImportDialog">批量导入</el-button>
</div>
</div>
<el-table :data="recipients" border size="small">
<el-table-column label="姓名" min-width="110">
<template #default="{ row }"><el-input v-model="row.name" size="small" placeholder="姓名" /></template>
</el-table-column>
<el-table-column label="手机号" min-width="150">
<template #default="{ row }"><el-input v-model="row.phone" size="small" placeholder="手机号" /></template>
</el-table-column>
<el-table-column label="邮箱" min-width="180">
<template #default="{ row }"><el-input v-model="row.email" size="small" placeholder="邮箱(邮件渠道用)" /></template>
</el-table-column>
<el-table-column label="来源" width="80" align="center">
<template #default="{ row }">{{ row.source === 'E' ? '专家库' : '导入' }}</template>
</el-table-column>
<el-table-column label="发送" width="70" align="center">
<template #default="{ row }"><el-checkbox v-model="row.send" /></template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }"><el-link :underline="false" type="danger" @click="removeRecipient(row)">删除</el-link></template>
</el-table-column>
</el-table>
</div>
<div class="footer-bar">
<el-button @click="goBack">返回</el-button>
<el-button type="primary" @click="onSave(false)">保存</el-button>
<el-button type="success" @click="onSave(true)">保存并发送</el-button>
</div>
<!-- 从专家库选择 -->
<el-dialog v-model="expertDialogOpen" title="从专家库选择" width="720" top="6vh">
<el-form inline :model="expertQ">
<el-form-item label="姓名"><el-input v-model="expertQ.name" placeholder="姓名" clearable /></el-form-item>
<el-form-item label="手机号"><el-input v-model="expertQ.phone" placeholder="手机号" clearable /></el-form-item>
<el-form-item><el-button type="primary" @click="loadExperts">查询</el-button></el-form-item>
</el-form>
<el-table :data="expertRows" v-loading="expertLoading" border size="small" @selection-change="onExpertSelect">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="expertPage.pageNum" v-model:page-size="expertPage.pageSize"
:total="expertPage.total" :page-sizes="[10,20]" layout="total, prev, pager, next"
@current-change="loadExperts" @size-change="loadExperts" style="margin-top:10px;text-align:right" />
<template #footer>
<el-button @click="expertDialogOpen = false">取消</el-button>
<el-button type="primary" @click="addSelectedExperts">添加所选</el-button>
</template>
</el-dialog>
<!-- 批量导入 -->
<el-dialog v-model="importDialogOpen" title="批量导入专家" width="520" top="10vh">
<p class="import-tip">每行一位专家格式姓名,手机号,邮箱邮箱可省略</p>
<el-input v-model="importText" type="textarea" :rows="10" placeholder="张三,13800000000,zhangsan@xx.com&#10;李四,13900000000" />
<template #footer>
<el-button @click="importDialogOpen = false">取消</el-button>
<el-button type="primary" @click="doImport">导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import RichEditor from '@/components/RichEditor.vue'
import { createInvite, sendInvite } from '@/api/business/invite'
import { bizList } from '@/api/public'
import { uploadToOss } from '@/utils/oss'
const router = useRouter()
const form = reactive({
projectNo: '', meetingName: '', periodNo: '', meetingTime: '',
sendChannel: 'both', redheadFileUrl: '', content: ''
})
const recipients = ref([])
function goBack() { router.push('/manager/invites') }
// ===== 红头文件上传 =====
async function onRedheadChange(file) {
try {
const url = await uploadToOss(file.raw, 'ry8080/invite/redhead/')
form.redheadFileUrl = url
ElMessage.success('红头文件上传成功')
} catch (e) {
ElMessage.error(e?.message || '上传失败')
}
}
// ===== 收件人操作 =====
function removeRecipient(row) {
recipients.value = recipients.value.filter(r => r !== row)
}
// ===== 从专家库选择 =====
const expertDialogOpen = ref(false)
const expertQ = reactive({ name: '', phone: '' })
const expertRows = ref([])
const expertLoading = ref(false)
const expertPage = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const selectedExperts = ref([])
function openExpertDialog() { expertDialogOpen.value = true; loadExperts() }
async function loadExperts() {
expertLoading.value = true
try {
const { data } = await bizList('expert', { ...expertQ.value, auditStatus: '2', pageNum: expertPage.pageNum, pageSize: expertPage.pageSize })
expertRows.value = data?.rows || []
expertPage.total = data?.total || 0
} catch {
expertRows.value = []; expertPage.total = 0
} finally {
expertLoading.value = false
}
}
function onExpertSelect(rows) { selectedExperts.value = rows }
function addSelectedExperts() {
const existing = new Set(recipients.value.map(r => r.phone))
let added = 0
selectedExperts.value.forEach(e => {
if (e.phone && !existing.has(e.phone)) {
recipients.value.push({ name: e.name, phone: e.phone, email: e.email || '', source: 'E', send: true })
existing.add(e.phone)
added++
}
})
ElMessage.success(`已添加 ${added} 位专家`)
expertDialogOpen.value = false
}
// ===== 批量导入 =====
const importDialogOpen = ref(false)
const importText = ref('')
function openImportDialog() { importText.value = ''; importDialogOpen.value = true }
function doImport() {
const lines = importText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
if (!lines.length) { ElMessage.warning('请输入专家信息'); return }
const existing = new Set(recipients.value.map(r => r.phone))
let added = 0
lines.forEach(line => {
const parts = line.split(/[,\t]/).map(s => s.trim()).filter(Boolean)
if (!parts.length) return
const name = parts[0] || ''
const phone = parts[1] || ''
const email = parts[2] || ''
if (!name || !phone) return
if (existing.has(phone)) return
recipients.value.push({ name, phone, email, source: 'I', send: true })
existing.add(phone)
added++
})
ElMessage.success(`已导入 ${added} 位专家`)
importDialogOpen.value = false
}
// ===== 保存 / 发送 =====
function buildPayload() {
return {
projectNo: form.projectNo, meetingName: form.meetingName, periodNo: form.periodNo,
meetingTime: form.meetingTime, sendChannel: form.sendChannel,
redheadFileUrl: form.redheadFileUrl, content: form.content,
recipients: recipients.value.map(r => ({
name: r.name, phone: r.phone, email: r.email, source: r.source,
remark: r.send ? '1' : ''
}))
}
}
async function onSave(sendNow) {
if (!form.meetingName) { ElMessage.warning('请填写会议名称'); return }
if (!form.content) { ElMessage.warning('请填写邀请内容'); return }
if (sendNow) {
const toSend = recipients.value.filter(r => r.send)
if (!toSend.length) { ElMessage.warning('请选择至少一位要发送的专家'); return }
}
const payload = buildPayload()
try {
const { data } = await createInvite(payload)
const id = data?.id
if (sendNow && id) {
const res = await sendInvite(id)
ElMessage.success(`已保存并发送 ${res?.data?.sent ?? 0} 条邀请`)
} else {
ElMessage.success('已保存')
}
router.push('/manager/invites')
} catch (e) {
ElMessage.error(e?.msg || '操作失败')
}
}
</script>
<style scoped>
.manager-invite-new { max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.invite-form { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #f0f0f0; }
.redhead-row { display: flex; align-items: center; width: 100%; }
.recipient-section { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #f0f0f0; margin-top: 12px; }
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.section-title { font-size: 15px; font-weight: 600; }
.footer-bar { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
.import-tip { color: #909399; font-size: 12px; margin: 0 0 8px; }
</style>
@@ -59,7 +59,8 @@
<span :class="{ 'is-over': singleAmountOver }">已分配金额: <b>¥ {{ assignedAmount }}</b></span>
<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="{ 'is-over': availableAmountOver }">可用金额: <b>¥ {{ availableAmount }}</b></span>
<span v-if="availableAmountOver" class="over-warn"> 已超额 ¥ {{ Math.abs(availableAmount) }}</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身校验 -->
<div v-else class="assign-sessions-bar">
@@ -218,6 +219,8 @@ const availableAmount = computed(() => {
const avail = total - manageFeeDisplay.value - assignedAmount.value
return Math.round(avail * 100) / 100
})
// 可用金额超额 (即: 已分配金额 + 管理费 > 总金额) — 顶部红字告警 + 保存时兜底拦截
const availableAmountOver = computed(() => !batchMode && availableAmount.value < 0)
function makeEmptyExecRow() {
return { _loading: false, _timer: null, _options: [], executionUnitId: null, sessions: 0, amount: 0, remark: '' }
@@ -409,7 +412,8 @@ async function loadBatchProjects() {
sponsorOrgId: p.sponsorOrgId,
sponsorAdminUserName: p.sponsorAdminUserName,
totalSessions: Number(p.totalSessions || 0),
totalAmount: Number(p.totalAmount || 0)
totalAmount: Number(p.totalAmount || 0),
manageFee: Number(p.manageFee || 0)
})
} else {
failed.push({ projectId: pid, reason: '返回数据为空' })
@@ -462,30 +466,36 @@ function confirmCancel() {
// ========== 保存 ==========
async function submitAssign() {
const valid = assignForm.execRows.filter(r => r.executionUnitId && Number(r.sessions) > 0)
// 场次/金额不能为负 (el-input-number 的 :min=0 只约束 spinner, 手动输入负数也能漏进来, 保存前兜底拦截)
if (assignForm.execRows.some(r => r.executionUnitId && Number(r.sessions) < 0)) return ElMessage.warning('场次不能为负数, 请调整')
if (valid.some(r => Number(r.amount || 0) < 0)) return ElMessage.warning('金额不能为负数, 请调整')
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
const amount = Math.round(valid.reduce((s, r) => s + Number(r.amount || 0), 0) * 100) / 100
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额 (扣除管理费及税金后的可用金额不能为负)
if (batchMode) {
for (const p of assignBatchProjects.value) {
const capS = Number(p.totalSessions || 0)
const capA = Number(p.totalAmount || 0)
const mf = Number(p.manageFee || 0)
const avail = Math.round((capA - mf - amount) * 100) / 100
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`项目 ${p.projectNo} 分配 ${sessions} 场, 超过总场次 ${capS}, 请调整`)
return ElMessage.warning(`项目 ${p.projectNo} 分配数据有误, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`项目 ${p.projectNo} 分配 ¥${amount}, 超过总金额 ¥${capA}, 请调整`)
if (capA > 0 && avail < 0) {
return ElMessage.warning(`项目 ${p.projectNo} 分配数据有误, 请调整`)
}
}
} else {
const capS = Number(assignForm.totalSessions || 0)
const capA = Number(assignForm.totalAmount || 0)
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`已分配 ${sessions} 场, 超过项目总场次 ${capS}, 请调整`)
return ElMessage.warning('分配数据有误, 请调整')
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`已分配 ¥${amount}, 超过项目总金额 ¥${capA}, 请调整`)
// 可用金额 = 总金额 - 管理费及税金 - 已分配金额; < 0 即超额分配
if (availableAmount.value < 0) {
return ElMessage.warning('分配数据有误, 请调整')
}
}
+6 -13
View File
@@ -7,7 +7,7 @@
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" class="plan-form">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目规划">
<el-form-item label="项目方向">
<el-select v-model="form.planDirectionId" placeholder="请选择" clearable style="width:100%">
<el-option v-for="opt in planDirectionOptions" :key="opt.id" :label="opt.title" :value="opt.id" />
</el-select>
@@ -21,11 +21,6 @@
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="学科方向">
<el-input v-model="form.subjectDirection" placeholder="请输入学科方向" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="形式">
<el-select v-model="form.projectForm" placeholder="请选择" clearable style="width:100%">
@@ -36,14 +31,14 @@
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目类别">
<dict-select v-model="form.planCategory" dict-type="biz_project_category" value-field="label" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目编号">
<el-input v-model="form.projectNo" placeholder="请输入项目编号" disabled />
@@ -58,8 +53,8 @@
v-model="form.designFileUrl"
:dir="`ry8080/plan/${planId}/design/`"
placeholder="点击上传设计文件"
hint="支持 PDF / 图片, 单文件 ≤ 20MB"
:max-size="20"
hint="支持 PDF / 图片, 单文件 ≤ 200MB"
:max-size="200"
block
/>
</el-form-item>
@@ -115,7 +110,6 @@ const form = reactive({
planDirectionId: null,
planCategory: '',
projectForm: '',
subjectDirection: '',
designFileUrl: '',
remark: ''
})
@@ -137,7 +131,6 @@ async function loadDetail() {
form.planDirectionId = d.planDirectionId ?? null
form.planCategory = d.planCategory || ''
form.projectForm = d.projectForm || ''
form.subjectDirection = d.subjectDirection || ''
form.designFileUrl = d.designFileUrl || ''
form.remark = d.remark || ''
} catch (e) {
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 策划方案管理</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable @input="onProjectNoSearch" @clear="onProjectNoSearch" /></el-form-item>
<el-form-item label="策划方案名称"><el-input v-model="q.planName" placeholder="输入策划方案名称" clearable /></el-form-item>
<el-form-item label="项目方向">
+13 -12
View File
@@ -2,7 +2,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / {{ isLeader ? '项目列表' : '项目管理' }}</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="支持单位:">
<el-select
v-model="q.sponsorOrgIds"
@@ -52,7 +52,7 @@
</el-form-item>
<el-form-item label="是否结题:">
<el-select v-model="q.isFinished" placeholder="请选择" clearable style="width:120px">
<el-option label="已结题" value="1" /><el-option label="未结题" value="0" />
<el-option label="已结题" value="Y" /><el-option label="未结题" value="N" />
</el-select>
</el-form-item>
<el-form-item label="是否结算:">
@@ -72,7 +72,7 @@
<div v-if="!isLeader" class="toolbar">
<el-button class="action-btn" @click="openNewProject">新建项目</el-button>
<el-button @click="openAssign">项目分配</el-button>
<el-button @click="openBatch('score')">批量评分</el-button>
<el-button v-if="!isAdmin" @click="openBatch('score')">批量评分</el-button>
<el-button @click="openBatch('close')">批量结题</el-button>
<el-button @click="openBatch('activate')">批量开通</el-button>
<el-button v-if="canDelete" class="action-btn secondary" type="danger" :disabled="!selection.length" @click="onBatchDelete">
@@ -133,7 +133,7 @@
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.isFinished === '1' || row.isFinished === 1" type="success">已结题</el-tag>
<el-tag v-if="row.isFinished === 'Y'" type="success">已结题</el-tag>
<el-tag v-else type="warning">未结题</el-tag>
</template>
</el-table-column>
@@ -152,12 +152,12 @@
</el-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item v-if="row.isFinished !== 'Y'" command="close">结题</el-dropdown-item>
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item v-if="false" command="activate">开通</el-dropdown-item> <!-- 暂时隐藏开通功能 (勿删, 需要时去掉 v-if="false") -->
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item v-if="!isAdmin" command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -339,7 +339,7 @@ const canDelete = computed(() => {
return r === 'admin'
})
function doCreateMeeting(row) { router.push(`${basePath.value}/meetings/new?projectId=${row.projectId}`) }
function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === 1 }
function isFinishedRow(row) { return row.isFinished === 'Y' }
function doAssign(row) {
if (isFinishedRow(row)) return ElMessage.warning('已结题项目不能分配')
// 跳转独立子页面 /manager/projects/assign?projectId=X
@@ -389,7 +389,7 @@ async function onBatchDelete() {
function reset() { q.value = { sponsorOrgIds:[], execOrgIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'' }; page.pageNum = 1; load() }
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=1 / ?isSettled=Y 等, 让列表页自动应用筛选)
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=Y / ?isSettled=Y 等, 让列表页自动应用筛选)
// 注意: 只读不改 URL — 列表页内 reset()/search 不反向写 URL, Workbench 是 source of truth
function readQueryFromRoute() {
const q2 = route.query
@@ -460,7 +460,7 @@ async function doExportSignupExpert() {
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog ==========
function openClose(row) {
if (row.isFinished === '1' || row.isFinished === 1) {
if (row.isFinished === 'Y') {
return ElMessage.warning('该项目已结题,无需重复操作')
}
closeTargetRow.value = row
@@ -468,7 +468,7 @@ function openClose(row) {
}
function confirmClose() {
if (!closeTargetRow.value) return
bizUpdate('project', { projectId: closeTargetRow.value.projectId, isFinished: '1' })
bizUpdate('project', { projectId: closeTargetRow.value.projectId, isFinished: 'Y' })
.then(() => { ElMessage.success('结题成功'); closeModalOpen.value = false; load() })
.catch(() => ElMessage.error('结题失败'))
}
@@ -557,6 +557,7 @@ function viewDetail(row) {
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中) (含 FROZEN); 未执行 = NOT_STARTED
function goMeetings(row, scope) {
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
@@ -665,11 +666,11 @@ function openBatch(kind) {
async function confirmBatch() {
if (batchKind.value === 'close') {
// 过滤掉已结题项目, 避免无效请求
const targets = selection.value.filter(r => r.isFinished !== '1' && r.isFinished !== 1)
const targets = selection.value.filter(r => r.isFinished !== 'Y')
const skipCount = selection.value.length - targets.length
try {
batchSubmitting.value = true
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: '1' })))
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
+3 -3
View File
@@ -28,7 +28,7 @@
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目负责人">
<el-select v-model="form.leadUserId" placeholder="请选择项目负责人" filterable clearable :filter-method="searchLeaders" style="width:100%">
<el-select v-model="form.leadUserId" placeholder="请选择项目负责人" disabled style="width:100%">
<el-option v-for="u in leaderOptions" :key="u.userId"
:label="`${u.nickName || u.userName}${u.nickName && u.userName ? ' (' + u.userName + ')' : ''}`" :value="u.userId" />
</el-select>
@@ -403,9 +403,9 @@ async function submit(mode = 'save') {
else if (n.key === 'notice') payload.publishUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url
}
// 发布时设置 is_published='1' + publish_time (同步后端字段)
// 发布时设置 is_published='Y' + publish_time (同步后端字段)
if (mode === 'publish') {
payload.isPublished = '1'
payload.isPublished = 'Y'
payload.publishTime = nowLocalDatetime()
}
// 多执行方分配已移至"项目分配"页面单独管理
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="breadcrumb">首页 / 支持意向</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="意向项目名称"><el-input v-model="q.projectName" placeholder="输入意向项目名称" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable /></el-form-item>
+7 -7
View File
@@ -12,7 +12,7 @@
<div class="stat-label">已结算数量</div>
<div class="stat-value">{{ stats.settledProjects }}</div>
</router-link>
<router-link class="stat-card" to="/manager/projects?isFinished=1">
<router-link class="stat-card" to="/manager/projects?isFinished=Y">
<div class="stat-label">已结题数量</div>
<div class="stat-value">{{ stats.completedProjects }}</div>
</router-link>
@@ -28,7 +28,7 @@
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
<section class="section">
<h2 class="section-title">消息通知<a class="more" @click.prevent="$router.push('/manager/messages')">更多 </a></h2>
<h2 class="new-card-title workbench-title">消息通知<a class="more" @click.prevent="$router.push('/manager/messages')">更多 </a></h2>
<NoticeList :pageable="true" :show-header="false" />
</section>
</div>
@@ -50,9 +50,9 @@ async function loadStats() {
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
stats.value.totalProjects = ps.total || ps.data?.total || 0
// 已结题项目
const cp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' })
const cp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: 'Y' })
stats.value.completedProjects = cp.total || cp.data?.total || 0
// 会议统计口径 (与 sponsor/Home 对齐): 未执行=NOT_STARTED; 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN)
// 会议统计口径 (与 sponsor/Home 对齐): 未执行=NOT_STARTED; 已执行=排除 NOT_STARTED/IN_PROGRESS (含 FROZEN)
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStageNotIn: 'NOT_STARTED,IN_PROGRESS' })
stats.value.executedMeetings = em.total || em.data?.total || 0
const pm = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'NOT_STARTED' })
@@ -106,11 +106,11 @@ onMounted(() => { loadStats() })
padding: 20px 24px;
border: 1px solid #f0f0f0;
}
.section-title {
.new-card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16px;
margin: 16px 0 12px;
padding-left: 10px;
border-left: 3px solid var(--brand-primary);
line-height: 1;
@@ -141,6 +141,6 @@ onMounted(() => { loadStats() })
/* 消息通知卡片 padding 收窄 */
.section { padding: 14px 16px !important; }
.section-title { font-size: 14px !important; margin-bottom: 12px !important; }
.new-card-title { font-size: 14px !important; margin-bottom: 12px !important; }
}
</style>
+324 -66
View File
@@ -174,40 +174,62 @@
<div class="file-list" style="margin-top: 16px;">
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly && laborEditable" class="file-row">
<div v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" class="file-row">
<span class="file-label">劳务协议:</span>
<div class="agreement-actions">
<el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="agreementSubmitting" @click="onPickAgreementZip">上传协议</el-button>
<input ref="agreementZipInput" type="file" accept=".zip" style="display:none" @change="onAgreementZipChange" />
</div>
<div v-if="agreementSubmitting" class="upload-progress">
<el-progress :percentage="agreementProgress" :stroke-width="12" :show-text="false" />
<span class="upload-progress-status">{{ agreementStatus || '上传中' }}</span>
</div>
<div class="file-hint">下载空目录模板 (劳务协议/序号_姓名/), 放入各参会人协议后压缩上传回填</div>
</div>
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable" class="file-uploader" />
<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 || !laborEditable" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<template v-if="isSponsor && r.subType === 'L_ESIGN_IN'">
<div class="agreement-actions">
<el-button size="small" type="primary" :disabled="!esignMasked(r.subType)" @click="openEsignPreview(r.subType)">预览</el-button>
</div>
</template>
<template v-else>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
<template v-else-if="r.subType === 'L_ESIGN_IN'">
<div class="esign-actions">
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
<el-button v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" size="small" type="success" :loading="esignZipSubmitting" @click="onPickEsignZip">上传 zip</el-button>
<input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" />
</div>
</template>
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
</template>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
<!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly && laborEditable" class="file-row">
<div v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" class="file-row">
<span class="file-label">专家照片:</span>
<div class="agreement-actions">
<el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="expertPhotoSubmitting" @click="onPickExpertPhotoZip">上传照片</el-button>
<input ref="expertPhotoZipInput" type="file" accept=".zip" style="display:none" @change="onExpertPhotoZipChange" />
</div>
<div v-if="expertPhotoSubmitting" class="upload-progress">
<el-progress :percentage="expertPhotoProgress" :stroke-width="12" :show-text="false" />
<span class="upload-progress-status">{{ expertPhotoStatus || '上传中' }}</span>
</div>
<div class="file-hint">下载空目录模板 (专家照片/序号_姓名/), 放入各参会人照片后压缩上传回填</div>
</div>
<div class="file-row">
<span class="file-label">日程海报:</span>
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" />
<div class="file-hint">会议日程海报</div>
</div>
<div class="file-row">
<span class="file-label">邀请函:</span>
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" />
<div class="file-hint">会议邀请函</div>
</div>
<!-- 临时: manager / executor 详情页隐掉海报生成 ( 生成海报/预览海报) -->
@@ -248,7 +270,7 @@
</div>
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" accept=".pdf,.png,.jpg,.jpeg,.zip" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :accept="r.accept || SERVICE_ACCEPT_ALL" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable || serviceLocked" @preview="(url) => openPreview([url], r.label)" />
<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>
@@ -258,14 +280,18 @@
<el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag>
</div>
<!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) -->
<div v-if="!isSponsor && !isReadonly && serviceEditable" class="file-row">
<div v-if="!isSponsor && !isReadonly && serviceEditable && !serviceLocked" class="file-row">
<span class="file-label">打包上传:</span>
<div class="agreement-actions">
<el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="serviceSubmitting" @click="onPickServiceZip">上传会务材料</el-button>
<input ref="serviceZipInput" type="file" accept=".zip" style="display:none" @change="onServiceZipChange" />
</div>
<div class="file-hint">下载空目录模板 (会务材料/物料制作·酒店·大交通·小交通·执行费·设计费·其他·总结算单·总发票/), 放入材料文件后压缩上传回填; 单个目录多文件会自动打包为一个 zip</div>
<div v-if="serviceSubmitting" class="upload-progress">
<el-progress :percentage="serviceProgress" :stroke-width="12" :show-text="false" />
<span class="upload-progress-status">{{ serviceStatus || '上传中' }}</span>
</div>
<div class="file-hint">下载空目录模板 (会务材料/物料制作·酒店·大交通·小交通·执行费·设计费·其他·总结算单·总结算单盖章·总发票/), 放入材料文件后压缩上传回填; 单个目录多文件会自动打包为一个 zip</div>
</div>
</div>
</el-tab-pane>
@@ -318,12 +344,10 @@
<!-- 节点 1: 会议已执行 -->
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 材料审核: 单条时间轴, 按轮次循环 (提交 合规 监察 提交 ), 节点上标注 劳务/会务 -->
<div v-if="!timelineEvents.length && !pendingNode" class="timeline-item pending">
<div class="timeline-title">执行方提交材料</div>
<div class="timeline-desc pending-text">待执行人员提交</div>
</div>
<template v-for="(ev, idx) in timelineEvents" :key="'ev' + idx">
<div :class="['timeline-item', eventStatus(ev)]">
@@ -333,9 +357,6 @@
</div>
<div v-for="(e, i) in ev.entries" :key="i" class="timeline-meta">
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<span>{{ e.auditor || '—' }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(e.auditTime) }}</span>
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
<div v-for="(e, i) in ev.entries" :key="'op' + i">
@@ -348,16 +369,13 @@
{{ stepLabel(pendingNode.step) }}
<span v-for="(l, i) in trackParts(pendingNode)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
</div>
<div class="timeline-desc pending-text">{{ pendingDesc(pendingNode.step) }}</div>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
</div>
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
<div class="timeline-title">会议完结</div>
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
</div>
</div>
</div>
@@ -548,6 +566,21 @@
</el-dialog>
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
<!-- 电子签到表 (sponsor) 脱敏预览: 后端保存时解析+脱敏, 前端只读展示; 多文件时按 sheet 分页签 -->
<el-dialog v-model="esignPreview.visible" title="电子签到表" width="880px" append-to-body :close-on-click-modal="false">
<el-tabs v-if="esignPreview.sheets.length" v-model="esignPreview.activeName" type="card">
<el-tab-pane v-for="(s, si) in esignPreview.sheets" :key="si" :label="s.name || `签到表 ${si + 1}`" :name="'s' + si">
<el-table v-if="s.rows && s.rows.length" :data="s.rows" size="small" border max-height="480">
<el-table-column v-for="(h, hi) in s.headers" :key="hi" :label="h || `列${hi + 1}`" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row[hi] }}</template>
</el-table-column>
</el-table>
<div v-else class="text-muted" style="padding: 24px 0; text-align: center;">该签到表暂无数据</div>
</el-tab-pane>
</el-tabs>
<div v-else class="text-muted" style="padding: 24px 0; text-align: center;">暂无可预览的签到数据</div>
</el-dialog>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
@@ -597,6 +630,8 @@ const posterPreviewVisible = ref(false)
const previewOpen = ref(false)
const previewUrls = ref([])
const previewTitle = ref('附件预览')
/** 电子签到表 (sponsor) 脱敏预览 dialog 状态 */
const esignPreview = reactive({ visible: false, sheets: [], activeName: 's0' })
const loading = ref(false)
const activeTab = ref('labor')
const saving = ref(false)
@@ -613,8 +648,11 @@ const isSponsor = computed(() => currentRole.value === 'sponsor')
const isReadonly = computed(() => route.meta.readonly === true)
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
/** 凭证 tab 可见性: 合规/管理员随时可见 (需上传); 执行方/支持方等结算后 (is_settled=1) 才可见 */
const canSeeVouchers = computed(() => (isManager.value || isAdmin.value) || isOne(row.value.isSettled))
/** 凭证 tab 可见性: 合规/管理员/执行方/支持方 均可见; 其他角色 (项目负责人) 结算后 (is_settled=1) 才可见 */
const canSeeVouchers = computed(() => {
const r = currentRole.value
return r === 'manager' || r === 'admin' || r === 'executor' || r === 'sponsor' || isOne(row.value.isSettled)
})
// ===================== 工具 =====================
function pad(n) { return String(n).padStart(2, '0') }
@@ -673,24 +711,29 @@ function openOnSitePhotos(row) {
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '物料实物照片, 盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '酒店盖章版水单, 酒店发票(餐饮普票, 住宿场地费专票)' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '合同, 盖章版结算单, 发票, 其他材料' },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '设计稿, PPT' },
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '' },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '' },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '' },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '' },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
{ type: 'LABOR', subType: 'L_ESIGN_IN', label: '电子签到表', hint: '会议电子签到表', accept: '.xls,.xlsx,.csv' },
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
{ type: 'LABOR', subType: 'L_PANORAMA_BACK', label: '后全景', hint: '会议现场后全景 (不带定位框)' },
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
]
/** 会务材料上传默认扩展名 (含 Office/txt); 个别行 (如盖章结算单) 用 r.accept 覆盖 */
const SERVICE_ACCEPT_ALL = '.pdf,.png,.jpg,.jpeg,.zip,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.txt'
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_ESIGN_IN', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
const CAMERA_SUBTYPES = new Set(['L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
function isCameraSubType(subType) { return CAMERA_SUBTYPES.has(subType) }
@@ -841,6 +884,10 @@ const frozen = computed(() => isOne(row.value.isFrozen))
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
// 提交审核后该轨锁定 (manager/admin 也跟着锁), 让 OssFileUploader 移除按钮消失
// (仅控制 OssFileUploader 的 readonly; 不影响 laborEditable (下载模板/参会人列表的判定))
const laborLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.laborAuditStage))
const serviceLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.serviceAuditStage))
// 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改
const attendeeEditable = computed(() => {
if (isSponsor.value || isReadonly.value) return false
@@ -948,7 +995,7 @@ const meetingFee = computed(() => {
if (main && Number(main.amount) > 0) return { total: Number(main.amount), useMain: true }
const total = mats.reduce((s, m) => {
if (!m.subType || !m.subType.startsWith('M_')) return s
if (m.subType === 'M_INVOICE' || m.subType === 'M_SETTLEMENT') return s
if (m.subType === 'M_INVOICE' || m.subType === 'M_SETTLEMENT' || m.subType === 'M_SETTLEMENT_STAMP') return s
return s + (Number(m.amount) || 0)
}, 0)
return { total, useMain: false }
@@ -1521,6 +1568,8 @@ async function onExportAttendees() {
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
const agreementTplLoading = ref(false)
const agreementSubmitting = ref(false)
const agreementProgress = ref(0)
const agreementStatus = ref('')
const agreementZipInput = ref(null)
/** 下载"劳务协议"空目录模板 zip (劳务协议/{序号}_{姓名}/ 全空目录), 走 fetch 直接流式下载 */
@@ -1545,6 +1594,35 @@ async function onDownloadAgreementTemplate() {
}
}
// ===================== 批量上传进度轮询 (后端异步处理, 前端轮询 jobId) =====================
const uploadPollTimers = []
function pollUploadProgress(jobId, { onTick, onDone, onFail }) {
const timer = setInterval(async () => {
let p = null
try {
const resp = await request.get(`/business/upload/progress/${jobId}`, { __silentError: true })
p = (resp && resp.data) || null
} catch (e) {
clearInterval(timer)
onFail && onFail(e?.msg || '查询进度失败')
return
}
if (!p) { onTick && onTick({ done: 0, total: -1 }); return }
if (p.finished) {
clearInterval(timer)
if (p.error) onFail && onFail(p.message || '上传失败')
else onDone && onDone(p.result)
return
}
onTick && onTick(p)
}, 800)
uploadPollTimers.push(timer)
}
function clearUploadTimers() {
uploadPollTimers.forEach(t => clearInterval(t))
uploadPollTimers.length = 0
}
function onPickAgreementZip() {
agreementZipInput.value?.click()
}
@@ -1556,34 +1634,72 @@ async function onAgreementZipChange(ev) {
const fd = new FormData()
fd.append('file', file)
agreementSubmitting.value = true
agreementProgress.value = 0
agreementStatus.value = ''
let jobId = null
try {
const resp = await request.post(
`/business/meetingAttendee/uploadAgreements?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
{
timeout: 600000,
__silentError: true,
onUploadProgress: (e) => {
if (e.total) agreementProgress.value = Math.min(100, Math.round((e.loaded / e.total) * 100))
}
}
)
const data = (resp && resp.data) || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的劳务协议`)
}
loadAttendees()
jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
agreementSubmitting.value = false
agreementProgress.value = 0
agreementStatus.value = ''
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
agreementSubmitting.value = false
agreementProgress.value = 0
agreementStatus.value = ''
return
}
agreementProgress.value = 100
pollUploadProgress(jobId, {
onTick: (p) => {
agreementStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
},
onDone: (result) => {
const data = result || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的劳务协议`)
}
loadAttendees()
agreementSubmitting.value = false
agreementProgress.value = 0
agreementStatus.value = ''
},
onFail: (msg) => {
ElMessage.error(msg)
agreementSubmitting.value = false
agreementProgress.value = 0
agreementStatus.value = ''
}
})
}
// ===================== 专家照片 批量收发 (下载空目录模板 + 上传回填 on_site_photos) =====================
const expertPhotoTplLoading = ref(false)
const expertPhotoSubmitting = ref(false)
const expertPhotoProgress = ref(0)
const expertPhotoStatus = ref('')
const expertPhotoZipInput = ref(null)
/** 下载"专家照片"空目录模板 zip (专家照片/{序号}_{姓名}/ 全空目录) */
@@ -1619,29 +1735,65 @@ async function onExpertPhotoZipChange(ev) {
const fd = new FormData()
fd.append('file', file)
expertPhotoSubmitting.value = true
expertPhotoProgress.value = 0
expertPhotoStatus.value = ''
let jobId = null
try {
const resp = await request.post(
`/business/meetingAttendee/uploadExpertPhotos?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
{
timeout: 600000,
__silentError: true,
onUploadProgress: (e) => {
if (e.total) expertPhotoProgress.value = Math.min(100, Math.round((e.loaded / e.total) * 100))
}
}
)
const data = (resp && resp.data) || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的专家照片`)
}
loadAttendees()
jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
expertPhotoSubmitting.value = false
expertPhotoProgress.value = 0
expertPhotoStatus.value = ''
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
expertPhotoSubmitting.value = false
expertPhotoProgress.value = 0
expertPhotoStatus.value = ''
return
}
expertPhotoProgress.value = 100
pollUploadProgress(jobId, {
onTick: (p) => {
expertPhotoStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
},
onDone: (result) => {
const data = result || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的专家照片`)
}
loadAttendees()
expertPhotoSubmitting.value = false
expertPhotoProgress.value = 0
expertPhotoStatus.value = ''
},
onFail: (msg) => {
ElMessage.error(msg)
expertPhotoSubmitting.value = false
expertPhotoProgress.value = 0
expertPhotoStatus.value = ''
}
})
}
function onImportFileChange(uploadFile) {
@@ -1914,6 +2066,66 @@ function openPreview(urls, title) {
previewOpen.value = true
}
/** 该 subType 的电子签到表是否已有脱敏 JSON (sponsor 预览按钮可用态) */
function esignMasked(subType) {
const m = (materialsLoaded.value || []).find(x => x.subType === subType)
return !!(m && m.maskedJson)
}
/** sponsor 打开电子签到表脱敏预览: 解析后端存的 maskedJson → 单表 {headers,rows} 或 多表 {sheets:[...]} */
function openEsignPreview(subType) {
const m = (materialsLoaded.value || []).find(x => x.subType === subType)
if (!m || !m.maskedJson) { ElMessage.warning('暂无可预览的脱敏数据'); return }
let data
try { data = JSON.parse(m.maskedJson) } catch (e) { ElMessage.error('签到表数据解析失败'); return }
let sheets = []
if (Array.isArray(data && data.sheets) && data.sheets.length) {
sheets = data.sheets
} else if (Array.isArray(data && data.headers) && Array.isArray(data && data.rows)) {
sheets = [{ name: '', headers: data.headers, rows: data.rows }]
} else {
ElMessage.warning('签到表数据格式异常'); return
}
esignPreview.sheets = sheets
esignPreview.activeName = 's0'
esignPreview.visible = true
}
// ===================== 电子签到表 zip 上传 (同步, 后端解压分析所有 Excel 并替换 L_ESIGN_IN) =====================
const esignZipSubmitting = ref(false)
const esignZipInput = ref(null)
function onPickEsignZip() {
// ref 位于 v-for 内, Vue 会把它收集成数组 (此处只渲染一个 L_ESIGN_IN 行, 数组长度 1)
const el = Array.isArray(esignZipInput.value) ? esignZipInput.value[0] : esignZipInput.value
el?.click()
}
async function onEsignZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = ''
if (!file) return
const fd = new FormData()
fd.append('file', file)
esignZipSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingMaterial/uploadEsignIn?meetingId=${meetingId.value}`,
fd,
{ timeout: 600000, __silentError: true }
)
const data = (resp && resp.data) || {}
const count = data.count ?? 0
const total = data.total ?? count
ElMessage.success(`已追加 ${count} 个签到表, 共 ${total}`)
await loadMaterials()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
esignZipSubmitting.value = false
}
}
// 保存所有待落库内容 (材料/日程海报/邀请函/付款凭证); 成功返回 OCR 提交数, 失败抛错
async function doSave() {
const { ocrCount } = await saveMaterials()
@@ -2210,6 +2422,8 @@ function scheduleFeePoll() {
// ===================== 会务材料 打包上传 (下载空目录模板 + 上传回填 biz_meeting_material) =====================
const serviceTplLoading = ref(false)
const serviceSubmitting = ref(false)
const serviceProgress = ref(0)
const serviceStatus = ref('')
const serviceZipInput = ref(null)
/** 下载"会务材料"空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录) */
@@ -2245,22 +2459,58 @@ async function onServiceZipChange(ev) {
const fd = new FormData()
fd.append('file', file)
serviceSubmitting.value = true
serviceProgress.value = 0
serviceStatus.value = ''
let jobId = null
try {
const resp = await request.post(
`/business/meetingMaterial/uploadServiceMaterials?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
{
timeout: 600000,
__silentError: true,
onUploadProgress: (e) => {
if (e.total) serviceProgress.value = Math.min(100, Math.round((e.loaded / e.total) * 100))
}
}
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 类会务材料`)
loadMaterials()
loadInvoices()
refreshFee()
jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
serviceSubmitting.value = false
serviceProgress.value = 0
serviceStatus.value = ''
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
serviceSubmitting.value = false
serviceProgress.value = 0
serviceStatus.value = ''
return
}
serviceProgress.value = 100
pollUploadProgress(jobId, {
onTick: (p) => {
serviceStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
},
onDone: (result) => {
const n = (result != null) ? result : 0
ElMessage.success(`已回填 ${n} 类会务材料`)
loadMaterials()
loadInvoices()
refreshFee()
serviceSubmitting.value = false
serviceProgress.value = 0
serviceStatus.value = ''
},
onFail: (msg) => {
ElMessage.error(msg)
serviceSubmitting.value = false
serviceProgress.value = 0
serviceStatus.value = ''
}
})
}
// ===================== 导航 =====================
@@ -2272,7 +2522,10 @@ function goBack() {
// ===================== 初始化 =====================
onMounted(load)
onBeforeUnmount(stopFeePolling)
onBeforeUnmount(() => {
stopFeePolling()
clearUploadTimers()
})
</script>
<style scoped>
@@ -2281,7 +2534,7 @@ onBeforeUnmount(stopFeePolling)
.breadcrumb .current { color: #262626; font-weight: 500; }
.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; min-width: 0; }
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); line-height: 1; }
.text-muted { color: #8c8c8c; font-size: 13px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
@@ -2309,7 +2562,7 @@ onBeforeUnmount(stopFeePolling)
.material-tabs :deep(.el-tabs__item) { font-size: 14px; }
.file-list { display: flex; flex-direction: column; gap: 12px; }
.file-row { display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; align-items: center; padding: 4px 0; }
.file-row { display: grid; grid-template-columns: 130px 1fr; gap: 6px 12px; align-items: center; padding: 4px 0; }
.file-label { font-size: 14px; color: #595959; text-align: right; line-height: 28px; }
.fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 4px; }
.labor-fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 8px; }
@@ -2317,6 +2570,11 @@ onBeforeUnmount(stopFeePolling)
.fee-value { font-size: 16px; font-weight: 600; color: #f5222d; }.file-uploader { width: 100%; min-width: 0; }
.file-hint { grid-column: 2; font-size: 11px; color: #8c8c8c; line-height: 1.5; }
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* 电子签到表 多文件上传 + zip 按钮 纵向排列 */
.esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; }
.upload-progress .el-progress { flex: 1; }
.upload-progress-status { font-size: 12px; color: #8c8c8c; white-space: nowrap; }
.invoice-amount { grid-column: 2; justify-self: end; font-size: 13px; font-weight: 600; color: #f56c6c; white-space: nowrap; }
.invoice-table-wrap { margin: 2px 0 8px; overflow-x: auto; }
.invoice-table-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }
+13 -7
View File
@@ -3,8 +3,8 @@
<div class="breadcrumb">{{ breadcrumb }}</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:170px" /></el-form-item>
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:170px" @input="onProjectNoInput" /></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>
@@ -82,14 +82,14 @@
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" :width="isRole('sponsor') ? 140 : 260" fixed="right" align="center">
<el-table-column label="操作" :width="isRole('sponsor') ? 140 : isRole('manager') ? 250 : 200" fixed="right" align="center">
<template #default="{ row }"><div class="table-actions">
<!-- 查看: manager 直链 (admin/sponsor ); manager 折叠到更多 -->
<el-link :underline="false" v-if="!isRole('manager')" type="primary" @click="viewOnly(row)">查看</el-link>
<!-- manager: 审核 直链 (合规审核, 与结算互斥) -->
<el-link :underline="false" v-if="isRole('manager') && isCompliancePending(row)" type="warning" @click="onAudit(row)">审核</el-link>
<!-- 编辑材料: admin 直链; manager 折叠到更多 -->
<el-link :underline="false" v-if="isRole('admin')" type="primary" @click="viewDetail(row)">编辑材料</el-link>
<!-- 上传材料: admin 直链; manager 折叠到更多 -->
<el-link :underline="false" v-if="isRole('admin')" type="primary" @click="viewDetail(row)">上传材料</el-link>
<!-- 结算: 直链 (admin/manager, 两轨均审核通过才出现; 审核互斥) -->
<el-link :underline="false" v-if="isRole('admin', 'manager') && canSettleRow(row)" type="warning" @click="onSettle(row)">结算</el-link>
<!-- manager: 会务下载 / 劳务下载 更多拿出为直链 -->
@@ -102,10 +102,10 @@
</el-link>
<template #dropdown>
<el-dropdown-menu>
<!-- manager: 查看 / 编辑材料 折叠进来 -->
<!-- manager: 查看 / 上传材料 折叠进来 -->
<template v-if="isRole('manager')">
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="editMaterial">编辑材料</el-dropdown-item>
<el-dropdown-item command="editMaterial">上传材料</el-dropdown-item>
</template>
<el-dropdown-item command="edit">修改</el-dropdown-item>
<el-dropdown-item command="copy">复制</el-dropdown-item>
@@ -293,6 +293,12 @@ function onFilterByProject(row) {
load()
}
// 用户手动编辑「项目编号」输入框 = 放弃「总期数」带入的 projectId 精确过滤
// (否则清空项目编号后点查询, 仍按旧 projectId 查本项目)
function onProjectNoInput() {
q.value.projectId = ''
}
// ========== 删除会议 (软删除, 后端强校验 admin/manager) ==========
async function onDelete(row) {
try {
+1 -1
View File
@@ -3,7 +3,7 @@
<h1 class="page-title">邀请函</h1>
<p class="page-sub">查看项目邀请函含模板QR分享链接</p>
<el-form inline :model="q" class="filter-bar">
<el-form inline :model="q" class="filter-bar" @keyup.enter="onSearch">
<el-form-item label="项目编号:">
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable />
</el-form-item>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="portal-invite-view">
<div v-if="loading" class="state-tip">加载中</div>
<div v-else-if="errorMsg" class="state-tip">{{ errorMsg }}</div>
<template v-else>
<div class="invite-card">
<div class="invite-title">{{ invite?.meetingName || '会议邀请' }}</div>
<div class="invite-meta">
<span v-if="invite?.projectNo">项目编号{{ invite.projectNo }}</span>
<span v-if="invite?.periodNo">期数{{ invite.periodNo }}</span>
<span v-if="invite?.meetingTime">会议时间{{ fmt(invite.meetingTime) }}</span>
</div>
<div class="invite-content" v-html="invite?.content || ''"></div>
<div v-if="recipient?.name" class="invite-greet">受邀人{{ recipient.name }}</div>
</div>
<!-- 粘在底部的确认按钮 -->
<div class="confirm-bar">
<el-button v-if="confirmed" type="success" size="large" disabled style="width:100%">已确认</el-button>
<el-button v-else type="primary" size="large" :loading="confirming" style="width:100%" @click="onConfirm">确认参加</el-button>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPublicInvite, confirmPublicInvite } from '@/api/business/invite'
const route = useRoute()
const invite = ref(null)
const recipient = ref(null)
const loading = ref(true)
const confirming = ref(false)
const errorMsg = ref('')
const confirmed = computed(() => recipient.value?.confirmStatus === '1')
function fmt(t) { return t ? new Date(t.replace(/-/g, '/')).toLocaleString('zh-CN') : '' }
async function load() {
loading.value = true
errorMsg.value = ''
try {
const { data } = await getPublicInvite(route.params.token)
invite.value = data?.invite || null
recipient.value = data?.recipient || null
if (!data?.invite) errorMsg.value = '邀请不存在'
} catch (e) {
errorMsg.value = e?.msg || '邀请链接无效'
} finally {
loading.value = false
}
}
async function onConfirm() {
confirming.value = true
try {
await confirmPublicInvite(route.params.token)
recipient.value = { ...recipient.value, confirmStatus: '1' }
ElMessage.success('确认成功')
} catch (e) {
ElMessage.error(e?.msg || '确认失败')
} finally {
confirming.value = false
}
}
onMounted(load)
</script>
<style scoped>
.portal-invite-view { min-height: 100vh; padding: 16px 16px 88px; box-sizing: border-box; background: #f5f6f8; }
.state-tip { text-align: center; color: #909399; padding: 60px 0; }
.invite-card { background: #fff; border-radius: 8px; padding: 24px; max-width: 720px; margin: 0 auto; }
.invite-title { font-size: 20px; font-weight: 600; text-align: center; margin-bottom: 8px; }
.invite-meta { text-align: center; color: #909399; font-size: 13px; margin-bottom: 20px; }
.invite-meta span { margin: 0 10px; }
.invite-content { line-height: 1.8; color: #333; }
.invite-content :deep(img) { max-width: 100%; }
.invite-greet { margin-top: 20px; padding-top: 12px; border-top: 1px dashed #e5e5e5; color: #606266; font-size: 14px; }
.confirm-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 12px 16px; background: #fff;
box-shadow: 0 -2px 8px rgba(0,0,0,0.06); max-width: 720px; margin: 0 auto; box-sizing: border-box; }
</style>
+24 -52
View File
@@ -74,70 +74,40 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request'
import { bizList } from '@/api/public'
import { listPublicProjects } from '@/api/public'
import PortalFooter from '@/components/PortalFooter.vue'
import PortalNavbar from '@/components/PortalNavbar.vue'
const router = useRouter()
const PAGE_SIZE = 10
const ALL_NOTICES = []
const currentKeyword = ref('')
const currentPage = ref(1)
const allRows = ref([])
const loading = ref(false)
const rows = ref([])
const total = ref(0)
async function load() {
loading.value = true
try {
// 项目公示 = biz_project 表 is_published='1' 的项目
const res = await request({ url: '/business/public/announcements', method: 'get' })
const list = res.data?.data || res.data || []
allRows.value = list.map(r => {
return {
annId: r.projectId,
publishTime: r.publishTime,
type: '公示',
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
fileUrl: r.invitationUrl || r.supportLetterUrl || r.publishUrl || r.scheduleUrl || ''
}
})
// 项目公示 = biz_project 表 is_published='Y' 的项目; 后端分页 + 项目名模糊, 只查展示列 (不再全查/跑关联子查询)
const res = await listPublicProjects({ pageNum: currentPage.value, pageSize: PAGE_SIZE, keyword: currentKeyword.value || undefined })
const data = res.data || {}
rows.value = (data.rows || []).map(r => ({
annId: r.projectId,
publishTime: r.publishTime,
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
}))
total.value = data.total || 0
} catch (e) {
console.error('[publicity] load failed', e)
allRows.value = []
rows.value = []
total.value = 0
}
finally { loading.value = false }
}
load()
function detectType(title) {
if (title.includes('【邀请函】')) return '邀请函'
if (title.includes('【支持函】')) return '支持函'
if (title.includes('【通知】')) return '通知'
if (title.includes('【日程】')) return '日程'
if (title.includes('【会议通知】')) return '通知'
return '其他'
}
const filteredList = computed(() => {
let list = allRows.value.map(n => ({ ...n, type: n.type || detectType(n.title) }))
if (currentKeyword.value) {
const kw = currentKeyword.value.toLowerCase()
list = list.filter(n => n.title.toLowerCase().includes(kw))
}
return list
})
const total = computed(() => filteredList.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const pageItems = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return filteredList.value.slice(start, start + PAGE_SIZE)
})
const pageItems = computed(() => rows.value)
const pageButtons = computed(() => {
const buttons = []
@@ -156,11 +126,11 @@ const pageButtons = computed(() => {
return result
})
function applyFilter() { currentPage.value = 1 }
function resetFilter() { currentKeyword.value = ''; currentPage.value = 1 }
function prevPage() { if (currentPage.value > 1) { currentPage.value--; window.scrollTo({ top: 0, behavior: 'smooth' }) } }
function nextPage() { if (currentPage.value < totalPages.value) { currentPage.value++; window.scrollTo({ top: 0, behavior: 'smooth' }) } }
function goPage(p) { currentPage.value = p; window.scrollTo({ top: 0, behavior: 'smooth' }) }
function applyFilter() { currentPage.value = 1; load() }
function resetFilter() { currentKeyword.value = ''; currentPage.value = 1; load() }
function prevPage() { if (currentPage.value > 1) { currentPage.value--; load(); window.scrollTo({ top: 0, behavior: 'smooth' }) } }
function nextPage() { if (currentPage.value < totalPages.value) { currentPage.value++; load(); window.scrollTo({ top: 0, behavior: 'smooth' }) } }
function goPage(p) { currentPage.value = p; load(); window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goDetail(n) {
if (!n || !n.annId) return
@@ -175,6 +145,8 @@ function fmtDate(d) {
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
load()
</script>
<style scoped>
+178 -52
View File
@@ -28,11 +28,12 @@
v-for="t in tabList"
:key="t.key"
:class="['ann-menu-item', { active: activeTab === t.key, skeleton: t._skeleton }]"
@click="!t._skeleton && (activeTab = t.key)"
>
<i v-if="!t._skeleton" class="dot"></i>
<span v-if="!t._skeleton">{{ t.label }}</span>
<span v-else class="skeleton-bar" :style="{ width: 60 + (t.key.length * 4) + 'px' }"></span>
<div class="ann-menu-main" @click="!t._skeleton && (activeTab = t.key)">
<i v-if="!t._skeleton" class="dot"></i>
<span v-if="!t._skeleton">{{ t.label }}</span>
<span v-else class="skeleton-bar" :style="{ width: 60 + (t.key.length * 4) + 'px' }"></span>
</div>
</li>
<li v-if="!loading && !tabList.filter(t=>!t._skeleton).length" class="ann-menu-empty">暂无公告文件</li>
</ul>
@@ -107,7 +108,7 @@
</div>
<!-- 分享二维码弹窗 (el-dialog 自带右上角 X 关闭按钮 + Esc + 遮罩点击) -->
<el-dialog v-model="showQr" title="分享本页" width="420px" align-center destroy-on-close>
<el-dialog v-model="showQr" :title="qrDialogTitle" width="420px" align-center destroy-on-close>
<div class="modal-qr-wrap">
<span class="qr-corner qr-corner-tl"></span>
<span class="qr-corner qr-corner-tr"></span>
@@ -122,12 +123,20 @@
</div>
<div class="qr-brand">北京整合医学学会</div>
</div>
<p class="modal-tip">扫码查看{{ ann?.projectName || '项目公示' }}公示详情</p>
<el-link class="modal-copy-link" :underline="false" @click="copyLink">复制链接</el-link>
<template #footer>
<el-button type="primary" :disabled="!qrUrl" @click="onDownloadQr">下载二维码</el-button>
</template>
</el-dialog>
<!-- 微信分享引导遮罩: 微信无法程序化弹出分享面板, 只能引导用户点右上角 -->
<div v-if="showShareGuide" class="wx-share-guide" @click="showShareGuide = false">
<div class="wx-guide-tip">
<p class="wx-guide-title">点击右上角···</p>
<p class="wx-guide-sub">即可分享给朋友 / 分享到朋友圈</p>
</div>
</div>
<!-- ========== 匿名意向收集 dialog (未登录时弹出, 已登录时直接提交) ========== -->
<el-dialog
v-model="guestDialog.open"
@@ -180,6 +189,7 @@ import QRCode from 'qrcode'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
import loginBg from '@/assets/login.jpg'
const route = useRoute()
const router = useRouter()
@@ -208,28 +218,21 @@ const canShowSignupBtn = computed(() => {
})
const showQr = ref(false)
// 微信分享引导遮罩 (微信内无法程序化弹出分享面板, 只能提示用户点右上角「…」)
const showShareGuide = ref(false)
const qrUrl = ref('')
// 分享 URL: 当前页面地址 (window.location.href)
const shareUrl = computed(() => window.location.href)
// 是否微信内置浏览器 (MicroMessenger UA)
const isWechat = computed(() => /MicroMessenger/i.test(navigator.userAgent))
// 打开二维码弹窗时生成二维码 (避免页面加载时就调 QRCode.toDataURL)
watch(showQr, async (v) => {
if (v && !qrUrl.value) {
try {
qrUrl.value = await QRCode.toDataURL(shareUrl.value, {
width: 260,
margin: 2,
// 深色用品牌主色 (#1f2937 深灰), 让二维码更精致
color: { dark: '#1f2937', light: '#ffffff' },
errorCorrectionLevel: 'H'
})
} catch (e) {
console.error('二维码生成失败', e)
}
}
})
// 加载图片 (Image → Promise)
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('图片加载失败'))
img.src = src
})
}
// 公示详情核心数据
const ann = ref(null)
@@ -243,6 +246,61 @@ const TAB_DEFS = [
{ key: 'schedule', label: '日程', urlKey: 'scheduleUrl' }
]
// QR 弹窗标题: 当前激活 tab 的中文名 (右侧分享按钮永远分享"当前正在看的"那个 tab)
const qrDialogTitle = computed(() => {
const def = TAB_DEFS.find(t => t.key === activeTab.value)
return def ? `分享${def.label}` : '分享本页'
})
// 分享 URL: 当前激活 tab 的详情页 URL (带 ?tab=<key>)
// QR / WeChat 分享时, 对方扫码/点击进入后, 会自动跳到对应 tab (由 load() 读 query.tab 实现)
const shareUrl = computed(() => {
const tab = activeTab.value
const base = `${window.location.origin}${window.location.pathname}#${route.path}`
return tab ? `${base}?tab=${tab}` : base
})
// 生成当前 shareUrl 的二维码 (含 logo), 写到 qrUrl
async function regenerateQr() {
try {
const qrDataUrl = await QRCode.toDataURL(shareUrl.value, {
width: 260,
margin: 2,
// 深色用品牌主色 (#1f2937 深灰), 让二维码更精致
color: { dark: '#1f2937', light: '#ffffff' },
errorCorrectionLevel: 'H'
})
// 中间叠加 logo (logo 加载失败则退回纯二维码)
try {
const [qrImg, logo] = await Promise.all([
loadImage(qrDataUrl),
loadImage(window.location.origin + (import.meta.env.VITE_BASE || '/') + 'logo.png')
])
const size = 260
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
ctx.drawImage(qrImg, 0, 0, size, size)
const logoSize = 48
const pad = 6
const x = (size - logoSize) / 2
const y = (size - logoSize) / 2
// 白色底垫, 避免 logo 被二维码模块干扰
ctx.fillStyle = '#ffffff'
ctx.fillRect(x - pad, y - pad, logoSize + pad * 2, logoSize + pad * 2)
ctx.drawImage(logo, x, y, logoSize, logoSize)
qrUrl.value = canvas.toDataURL('image/png')
} catch (e) {
console.warn('[publicity-detail] logo 加载失败, 使用纯二维码', e)
qrUrl.value = qrDataUrl
}
} catch (e) {
console.error('二维码生成失败', e)
}
}
// 打开弹窗 + shareUrl 变化 (切 tab) 都触发重新生成
watch([showQr, shareUrl], ([opened]) => { if (opened) regenerateQr() })
// 只显示项目里"实际有文件 URL"的 tab
// 加载中时显示 4 个骨架 tab (灰色块) 让左侧菜单先出现
const tabList = computed(() => {
@@ -426,9 +484,16 @@ async function load() {
const res = await request({ url: `/business/public/project/${projectId}`, method: 'get' })
const project = res.data?.data || res.data
ann.value = project
// 默认 tab: 取第一个有文件的 tab
// 默认 tab: 优先 URL ?tab=XXX (扫码/分享进入, 跳到指定 tab), 否则取第一个有文件的 tab
const validKeys = TAB_DEFS.map(t => t.key)
const queryTab = String(route.query.tab || '')
const firstKey = TAB_DEFS.find(t => project?.[t.urlKey])?.key
if (firstKey) activeTab.value = firstKey
const targetKey = validKeys.includes(queryTab) && project?.[TAB_DEFS.find(t => t.key === queryTab)?.urlKey]
? queryTab
: firstKey
if (targetKey) activeTab.value = targetKey
// 项目数据就绪后自动配置微信分享卡片 (微信内点右上角「…」即可看到带标题/缩略图的卡片)
setupWechatShare()
// 项目加载完, 同步查"是否已报名"
checkSigned()
refreshIntentStatus()
@@ -458,7 +523,7 @@ async function onSignup() {
if (!canShowSignupBtn.value) return // 防御: 防止 v-if 被绕过
if (!loggedIn.value) {
ElMessage.warning('请先登录系统')
router.push('/login')
router.push({ name: 'login', query: { redirect: route.fullPath } })
return
}
if (signing.value || signed.value) return
@@ -581,7 +646,7 @@ function onExecutionIntent() {
if (!canShowExecutionBtn.value) return // 防御
if (!loggedIn.value) {
ElMessage.warning('请先登录系统')
router.push('/login')
router.push({ name: 'login', query: { redirect: route.fullPath } })
return
}
if (executionSubmitting.value || executionSubmitted.value) return
@@ -665,6 +730,29 @@ function onDownloadQr() {
document.body.removeChild(a)
}
// 复制链接: 点击"复制链接"复制当前页面地址到剪贴板
async function copyLink() {
const url = shareUrl.value
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
ElMessage.success('链接已复制')
} catch (e) {
console.error('[publicity-detail] copy link failed', e)
ElMessage.error('复制失败,请手动复制')
}
}
// ============ 微信 JS-SDK 分享 (仅微信环境) ============
let wxSdkPromise = null
function loadWxSdk() {
@@ -680,16 +768,16 @@ function loadWxSdk() {
return wxSdkPromise
}
// 分享入口: 微信环境走微信分享, 否则弹二维码
// 分享入口: 微信环境弹引导遮罩 (微信内无法程序化弹出分享面板), 否则弹二维码
// 分享链接始终是当前激活 tab 对应的详情页 URL (带 ?tab=xxx), 对方扫码进入会自动跳到此 tab
function onShare() {
if (isWechat.value) onWechatShare()
if (isWechat.value) showShareGuide.value = true
else showQr.value = true
}
const wxSharing = ref(false)
async function onWechatShare() {
if (wxSharing.value) return
wxSharing.value = true
// 页面加载完成后自动配置微信分享卡片 (无需用户点按钮; 微信分享面板只能靠点右上角「…」触发)
async function setupWechatShare() {
if (!isWechat.value) return
try {
const wx = await loadWxSdk()
// 微信签名只校验不含 # fragment 的 url (hash 路由下 split('#')[0] 会丢失路由, 但签名必须这样)
@@ -698,13 +786,15 @@ async function onWechatShare() {
const res = await request({ url: '/business/public/wx/jssdk', method: 'get', params: { url }, __silentError: true })
const cfg = res?.data || res
if (!cfg || !cfg.signature) {
ElMessage.error(res?.msg || '微信分享配置获取失败')
console.error('[wx-share] 签名获取失败', res?.msg)
return
}
const title = ann.value?.projectName || '项目公示'
const desc = '北京整合医学学会 · 项目公示'
const link = window.location.href
const imgUrl = window.location.origin + (import.meta.env.VITE_BASE || '/') + 'logo.png'
const desc = '北京整合医学学会合规系统 · 项目公示'
// 微信分享 link 带 ?tab=xxx, 对方点进来会落到对应 tab (load() 读 query.tab)
const link = shareUrl.value
// 分享缩略图用登录页那张 banner 图 (login.jpg, 微信会抓这个图生成卡片缩略图)
const imgUrl = window.location.origin + loginBg
wx.config({
debug: false,
@@ -712,31 +802,27 @@ async function onWechatShare() {
timestamp: cfg.timestamp,
nonceStr: cfg.nonceStr,
signature: cfg.signature,
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData']
// 只用旧版 API (同 hwt-web 参考实现): 新版 updateAppMessageShareData 部分机型静默失效,
// 且新旧混用会让旧 API 被抢占成 no-op, 导致卡片配不上去 → 分享只剩裸链接
jsApiList: ['onMenuShareAppMessage', 'onMenuShareTimeline']
})
wx.ready(function () {
wx.updateAppMessageShareData({
title,
desc,
link,
imgUrl,
success: () => ElMessage.success('已配置分享,请点击右上角「…」分享'),
fail: () => ElMessage.warning('分享配置失败,请重试')
})
wx.updateTimelineShareData({ title, link, imgUrl })
wx.onMenuShareAppMessage({ title, desc, link, imgUrl })
wx.onMenuShareTimeline({ title, link, imgUrl })
})
wx.error(function (err) {
console.error('[wx-share] config error', err)
ElMessage.error('微信分享初始化失败,请确认已配置 JS 安全域名')
})
} catch (e) {
console.error('[wx-share] failed', e)
ElMessage.error(e?.msg || '微信分享失败,请重试')
} finally {
wxSharing.value = false
}
}
// 切换 tab 时 (微信内), 重新注册微信分享卡片, 让 link 带上新的 ?tab=xxx
watch(activeTab, () => {
if (isWechat.value) setupWechatShare()
})
onMounted(() => {
window.addEventListener('keydown', onKeydown)
load()
@@ -792,6 +878,7 @@ onBeforeUnmount(() => {
margin-bottom: 4px;
transition: all 0.2s;
}
.ann-menu-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.ann-menu-item:hover { background: #f5f7fa; color: var(--brand-primary); }
.ann-menu-item.active { background: #e6f4ff; color: var(--brand-primary); font-weight: 600; }
.ann-menu-item .dot {
@@ -1462,4 +1549,43 @@ a { color: inherit; text-decoration: none; }
text-align: center;
}
/* 复制链接 (替代原"扫码查看..."提示文字) */
.modal-copy-link {
display: flex;
justify-content: center;
margin-top: 4px;
font-size: 12px;
}
/* 微信分享引导遮罩 */
.wx-share-guide {
position: fixed;
inset: 0;
z-index: 3000;
background: rgba(0, 0, 0, 0.78);
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 4px;
}
.wx-guide-tip {
background: #ffffff;
border-radius: 10px;
padding: 18px 22px;
margin-right: 0;
text-align: center;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.wx-guide-title {
font-size: 17px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.wx-guide-sub {
font-size: 14px;
color: #6b7280;
margin: 8px 0 0;
}
</style>
+1 -1
View File
@@ -3,7 +3,7 @@
<h1 class="page-title">支持函</h1>
<p class="page-sub">查看项目支持函 PDF 文件下载/预览</p>
<el-form inline :model="q" class="filter-bar">
<el-form inline :model="q" class="filter-bar" @keyup.enter="onSearch">
<el-form-item label="项目编号:">
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable />
</el-form-item>
@@ -11,7 +11,7 @@
<div class="breadcrumb">首页 / 支持单位管理</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="单位名称">
<el-input v-model="q.orgName" placeholder="输入单位名称" clearable style="width:200px" />
</el-form-item>
@@ -53,7 +53,7 @@
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" :width="isAdmin ? 240 : 180" fixed="right">
<el-table-column label="操作" :width="isAdmin ? 200 : 150" fixed="right">
<template #default="{ row }"><div class="table-actions">
<!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) -->
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
@@ -11,7 +11,7 @@
</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
</el-form-item>
@@ -77,6 +77,8 @@
<el-dropdown-item :command="isDisabled(row) ? 'enable' : 'disable'">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-dropdown-item>
<!-- 删除 (软删除): 软删关联登录账号 sys_user.del_flag, 机构管理员(MAIN)需先更换管理员 -->
<el-dropdown-item command="delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -128,7 +130,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { bizList, bizUpdate, bizDelete, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, ArrowDown } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
@@ -269,11 +271,32 @@ function goView(row) {
})
}
// 操作列 "更多" 下拉: 分发编辑/重置密码/启用/禁用
// 操作列 "更多" 下拉: 分发编辑/重置密码/启用/禁用/删除
function onMoreAction(cmd, row) {
if (cmd === 'edit') return goEdit(row)
if (cmd === 'resetPwd') return onResetPwd(row)
if (cmd === 'enable' || cmd === 'disable') return onToggleStatus(row)
if (cmd === 'delete') return onDelete(row)
}
// ========== 删除 (软删除) ==========
async function onDelete(row) {
// 机构管理员 (MAIN) 不能直接删除: 删了会导致机构无主账号, 需先更换管理员
if (row.accountType === 'MAIN') { ElMessage.warning('机构管理员不能删除,请先更换管理员'); return }
try {
await ElMessageBox.confirm(
`确定删除「${row.name}」吗?删除后其登录账号将一并停用`,
'删除确认',
{ type: 'warning' }
)
} catch { return }
try {
await bizDelete('person', row.personId)
ElMessage.success('已删除')
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
// ========== 批量导入 ==========
@@ -13,7 +13,7 @@
<el-card v-loading="loading" shadow="never" class="form-card nested">
<el-form :model="form" label-width="100px" disabled>
<el-form-item label="用户名">
<el-input v-model="form.userName" />
<el-input v-model="form.account" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.name" />
@@ -69,7 +69,7 @@ const orgName = computed(() => route.query.orgName || '')
const loading = ref(false)
const form = reactive({
personId: null,
userName: '',
account: '',
name: '',
phone: '',
orgName: '',
@@ -103,7 +103,7 @@ async function loadDetail() {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
account: d.account || '',
name: d.name || '',
phone: d.phone || '',
orgName: d.orgName || '',
+15 -3
View File
@@ -5,8 +5,11 @@
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<el-form-item label="单位名称">
<el-input v-model="form.orgName" disabled placeholder="-" />
</el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" autocomplete="new-password" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入新密码" /></el-form-item>
<div class="filter-tip">* 密码长度 6-20 ,支持数字字母特殊字符;留空表示不修改密码</div>
@@ -20,8 +23,9 @@ import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
const form = reactive({ nickName: '', phonenumber: '', oldPassword: '', newPassword: '', confirmPassword: '' })
const form = reactive({ orgName: '', nickName: '', phonenumber: '', oldPassword: '', newPassword: '', confirmPassword: '' })
const profile = ref({})
const org = ref({})
const saving = ref(false)
const formRef = ref(null)
@@ -46,6 +50,14 @@ async function loadProfile() {
} catch { ElMessage.error('获取个人信息失败') }
}
async function loadMyCompany() {
try {
const { data } = await request({ url: '/business/org/myCompany', method: 'get' })
org.value = data || {}
form.orgName = org.value.orgName || ''
} catch { /* 静默, 不影响其他字段回显 */ }
}
async function onSave() {
if (form.newPassword && form.newPassword !== form.confirmPassword) {
ElMessage.error('两次输入密码不一致'); return
@@ -70,7 +82,7 @@ function onCancel() {
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
}
onMounted(loadProfile)
onMounted(() => { loadProfile(); loadMyCompany() })
</script>
<style scoped>
+3 -3
View File
@@ -20,7 +20,7 @@
<div class="stat-value">{{ stats.settledProjects }}</div>
<div class="stat-desc">查看已结算项目</div>
</router-link>
<router-link class="stat-card" to="/sponsor/projects?isFinished=1">
<router-link class="stat-card" to="/sponsor/projects?isFinished=Y">
<div class="stat-label">已结题的项目</div>
<div class="stat-value">{{ stats.completedProjects }}</div>
<div class="stat-desc">查看已结题项目</div>
@@ -54,7 +54,7 @@ import NoticeList from '@/components/NoticeList.vue'
const stats = ref({
totalProjects: 0,
settledProjects: 0, // 项目级 isSettled=Y
completedProjects: 0, // 项目级 isFinished=1
completedProjects: 0, // 项目级 isFinished=Y
executedMeetings: 0, // 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中) 的其余 stage (含 FROZEN)
pendingMeetings: 0 // 未执行 = NOT_STARTED
})
@@ -68,7 +68,7 @@ async function loadStats() {
}
stats.value.totalProjects = await projectCount()
stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: '1' })
stats.value.completedProjects = await projectCount({ isFinished: 'Y' })
// 会议统计走 /business/meeting/stageStats (后端已按 sponsor MAIN/SUB 隔离), 返回 { stage: cnt }
const ss = await request.get('/business/meeting/stageStats')
const stageMap = ss?.data || {}
+12 -12
View File
@@ -13,7 +13,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / 项目管理</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号">
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width: 160px" />
</el-form-item>
@@ -30,8 +30,8 @@
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width: 120px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
<el-option label="已结题" value="Y" />
<el-option label="未结题" value="N" />
</el-select>
</el-form-item>
<el-form-item label="是否结算">
@@ -90,8 +90,8 @@
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column label="是否结题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.isFinished === '1' ? 'success' : 'info'" size="small" disable-transitions>
{{ row.isFinished === '1' ? '已结题' : '未结题' }}
<el-tag :type="row.isFinished === 'Y' ? 'success' : 'info'" size="small" disable-transitions>
{{ row.isFinished === 'Y' ? '已结题' : '未结题' }}
</el-tag>
</template>
</el-table-column>
@@ -201,8 +201,8 @@
<el-descriptions-item label="已支付劳务费">¥{{ formatMoney(detail.paidLaborAmount) }}</el-descriptions-item>
<el-descriptions-item label="已支付会务费">¥{{ formatMoney(detail.paidMeetingAmount) }}</el-descriptions-item>
<el-descriptions-item label="是否结题">
<el-tag :type="detail.isFinished === '1' ? 'success' : 'info'" disable-transitions>
{{ detail.isFinished === '1' ? '已结题' : '未结题' }}
<el-tag :type="detail.isFinished === 'Y' ? 'success' : 'info'" disable-transitions>
{{ detail.isFinished === 'Y' ? '已结题' : '未结题' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
@@ -303,7 +303,7 @@ async function loadPersons() {
} catch (e) { personList.value = [] }
}
// 读 URL query 写入 q (Home KPI 卡跳转时带 ?isSettled=Y / ?isFinished=1, 让列表页自动应用筛选)
// 读 URL query 写入 q (Home KPI 卡跳转时带 ?isSettled=Y / ?isFinished=Y, 让列表页自动应用筛选)
// 只读不改 URL — Home 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
function readQueryFromRoute() {
@@ -432,7 +432,7 @@ async function onBatchFinish() {
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量结题吗?`, '批量结题', { type: 'warning' }) } catch { return }
let ok = 0
for (const r of selected.value) {
try { await bizUpdate('project', { projectId: r.projectId, isFinished: '1' }); ok++ } catch {}
try { await bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' }); ok++ } catch {}
}
ElMessage.success(`已批量结题 ${ok}`)
load()
@@ -443,7 +443,7 @@ async function onBatchOpen() {
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量开通吗?`, '批量开通', { type: 'warning' }) } catch { return }
let ok = 0
for (const r of selected.value) {
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1', publishTime: nowLocalDatetime() }); ok++ } catch {}
try { await bizUpdate('project', { projectId: r.projectId, isPublished: 'Y', publishTime: nowLocalDatetime() }); ok++ } catch {}
}
ElMessage.success(`已批量开通 ${ok}`)
load()
@@ -452,7 +452,7 @@ async function onBatchOpen() {
async function onFinish(row) {
try { await ElMessageBox.confirm(`确定将「${row.projectName}」结题吗?`, '结题', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isFinished: '1' })
await bizUpdate('project', { projectId: row.projectId, isFinished: 'Y' })
ElMessage.success('已结题')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
@@ -461,7 +461,7 @@ async function onFinish(row) {
async function onOpen(row) {
try { await ElMessageBox.confirm(`确定开通「${row.projectName}」吗?`, '开通', { type: 'warning' }) } catch { return }
try {
await bizUpdate('project', { projectId: row.projectId, isPublished: '1', publishTime: nowLocalDatetime() })
await bizUpdate('project', { projectId: row.projectId, isPublished: 'Y', publishTime: nowLocalDatetime() })
ElMessage.success('已开通')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="page-card">
<div class="breadcrumb">首页 / 人员管理</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="输入姓名" clearable style="width: 160px" />
</el-form-item>
@@ -4,14 +4,14 @@
容器: <div class="page-card"> ( People.vue / NewPerson.vue 同款)
面包屑: <div class="breadcrumb">首页 / 我的项目</div>
1:1 抄原型: proto/html/components/sponsor-projects.html
15 : checkbox + 项目编号 + 项目名称 + 总场次/已执行/未执行 + 4 金额 + 开始/结束时间 + 是否结题 + 执行单位评价 + 操作
14 : checkbox + 项目编号 + 项目名称 + 总场次/已执行/未执行 + 4 金额 + 开始/结束时间 + 是否结题 + 执行单位评价 + 操作
Modal 1: 项目评价 (4 维度星级 1-5 + 备注) -> POST /business/project/rate [hmr fix]
Modal 2: 项目分配 (监察员 chip + 说明 + 要点) -> POST /business/project/sponsorAssign
-->
<div class="page-card">
<div class="breadcrumb">首页 / 我的项目</div>
<el-form inline :model="q" class="filter-form">
<el-form inline :model="q" class="filter-form" @keyup.enter="load">
<el-form-item label="项目编号">
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width: 160px" />
</el-form-item>
@@ -31,8 +31,8 @@
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width: 120px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
<el-option label="已结题" value="Y" />
<el-option label="未结题" value="N" />
</el-select>
</el-form-item>
<el-form-item>
@@ -95,7 +95,7 @@
</el-table-column>
<el-table-column label="是否结题" width="90" align="center">
<template #default="{ row }">
<span>{{ row.isFinished === '1' ? '已结题' : '未结题' }}</span>
<span>{{ row.isFinished === 'Y' ? '已结题' : '未结题' }}</span>
</template>
</el-table-column>
<el-table-column label="执行单位评价(支持方)" width="180" align="center">
@@ -276,7 +276,7 @@ function onView(row) {
// 点击「总场次/已执行/未执行」列 → 跳 sponsor 会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限).
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行) — 与 manager/Projects.vue 口径一致:
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中); 未执行 = NOT_STARTED.
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中) (含 FROZEN); 未执行 = NOT_STARTED.
function goMeetings(row, scope) {
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'