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:
@@ -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`)
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,13 +21,23 @@
|
||||
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
|
||||
<el-icon><CameraFilled /></el-icon> 扫码拍照
|
||||
</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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
<!-- PDF:PDF.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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
],
|
||||
|
||||
@@ -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: '新建项目策划方案' } },
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="排序">
|
||||
|
||||
@@ -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 || '操作失败')
|
||||
|
||||
@@ -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,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)">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* —— 消息通知卡片 —— */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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> 组件, 本页不再处理
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 || '' } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="请输入新密码" />
|
||||
|
||||
@@ -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 || {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="page-card manager-invite-new">
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">发送邀请</a><span class="sep">></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 李四,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('分配数据有误, 请调整')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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="项目方向">
|
||||
|
||||
@@ -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} 个项目`)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
// 多执行方分配已移至"项目分配"页面单独管理
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || {}
|
||||
|
||||
@@ -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 || '操作失败') }
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user