feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)
- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境 - 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost - 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配 - 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度 - 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver - 清理 biz_support_intent 旧表 (6 Java/XML 删除) - ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传 - 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 当前登录用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空)
|
||||
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...]
|
||||
* 当前登录用户的"待签署协议"会议列表 (已推送电子签 is_esigned=1 且 任一未签)
|
||||
* 返回 [{id, meetingId, meetingName, startTime, isEsigned, ...}, ...]
|
||||
*/
|
||||
export function listUnsignedMeetingProtocols() {
|
||||
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户的"待参加"会议列表 (已邀请参会 is_invited=1)
|
||||
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...]
|
||||
*/
|
||||
export function listInvitedMeetings() {
|
||||
return request({ url: '/business/meetingAttendee/invited', method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新手写签名 (Base64 字符串, 直接存 DB longtext)
|
||||
* @param {number|string} id 中间表主键
|
||||
|
||||
@@ -23,6 +23,33 @@ export function sponsorAssignProject(data) {
|
||||
return request({ url: '/business/project/sponsorAssign', method: 'post', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行方分配执行人 (写 biz_project_executor_assign)
|
||||
* POST /business/project/executorAssign
|
||||
* body: { projectId, staffUserIds: [Long, ...] }
|
||||
*/
|
||||
export function executorAssignProject(data) {
|
||||
return request({ url: '/business/project/executorAssign', method: 'post', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目已分配的执行人列表 (dialog 重开回显用)
|
||||
* GET /business/project/{projectId}/executorAssigns
|
||||
* 返回 BizProjectExecutorAssign[] 含 { staffUserId, staffUserName, ... }
|
||||
*/
|
||||
export function getExecutorAssigns(projectId) {
|
||||
return request({ url: `/business/project/${projectId}/executorAssigns`, method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目已分配的监察员列表 (dialog 重开回显用)
|
||||
* GET /business/project/{projectId}/sponsorAssigns
|
||||
* 返回 BizProjectSponsorAssign[] 含 { monitorUserId, monitorUserName, ... }
|
||||
*/
|
||||
export function getSponsorAssigns(projectId) {
|
||||
return request({ url: `/business/project/${projectId}/sponsorAssigns`, method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持方批量分配 (多个项目同一个监察员 + 同一份说明)
|
||||
* body: [{projectId, monitorUserId, assignDesc, assignPoints}, ...]
|
||||
|
||||
@@ -8,6 +8,9 @@ import request from '@/utils/request'
|
||||
export function getSignInfo(attendeeId) {
|
||||
return request({ url: '/business/sign/info', method: 'get', params: { attendeeId } })
|
||||
}
|
||||
export function resolveSign(meetingId) {
|
||||
return request({ url: '/business/sign/resolve', method: 'get', params: { meetingId }, __silentError: true })
|
||||
}
|
||||
export function saveSignProfile(attendeeId, form) {
|
||||
return request({ url: '/business/sign/saveProfile', method: 'post', params: { attendeeId }, data: form, __silentError: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
扫码拍照上传控件 (会议现场照片: 签到表 / 前全景 / 后全景)
|
||||
区别于 OssFileUploader: 不本地选文件, 而是点按钮弹出二维码,
|
||||
手机扫二维码进入 ry-h5 相机页拍照, 拍照后直传 OSS 并回传 URL 到后端
|
||||
(后端 upsert 到 biz_meeting_material), 本控件轮询 material 列表,
|
||||
一旦该 subType 的 ossUrl 出现即回写 v-model (同步到页面 r.url).
|
||||
|
||||
用法:
|
||||
<CameraQrUpload
|
||||
v-model="r.url"
|
||||
:meeting-id="meetingId"
|
||||
:sub-type="r.subType"
|
||||
:label="r.label"
|
||||
:readonly="isSponsor"
|
||||
class="file-uploader"
|
||||
/>
|
||||
-->
|
||||
<template>
|
||||
<div class="camera-qr-upload" :class="{ readonly }">
|
||||
<div class="camera-row">
|
||||
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
|
||||
<el-icon><CameraFilled /></el-icon> 扫码拍照
|
||||
</el-button>
|
||||
<a v-if="modelValue" :href="modelValue" target="_blank" class="cam-link">查看照片</a>
|
||||
<span v-else class="cam-empty">未上传</span>
|
||||
<span v-if="polling" class="cam-polling">等待手机回传…</span>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`扫码拍照 - ${label}`"
|
||||
width="400px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div class="qr-wrap">
|
||||
<div v-if="qrLoading" class="qr-loading" v-loading="true"></div>
|
||||
<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 v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开…</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CameraFilled } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
meetingId: { type: [Number, String], default: '' },
|
||||
subType: { type: String, required: true },
|
||||
label: { type: String, default: '现场照片' },
|
||||
readonly: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/** subType → ry-h5 页面路由 (ry-h5 hash router, base=/camera/) */
|
||||
const PAGE_PATH = {
|
||||
L_SIGN_IN: '/pages/signin/index',
|
||||
L_PANORAMA_FRONT: '/pages/panorama/index',
|
||||
L_PANORAMA_BACK: '/pages/panorama/index'
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const qrUrl = ref('')
|
||||
const qrLoading = ref(false)
|
||||
const polling = ref(false)
|
||||
let pollTimer = null
|
||||
let baseline = ''
|
||||
|
||||
/**
|
||||
* 生成二维码并开始轮询.
|
||||
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
|
||||
* apiBase 用当前站点 origin + VITE_APP_BASE_API (与 request.js 一致), 手机端据此调后端接口.
|
||||
* ⚠️ 开发环境要求: 执行方须用「局域网 IP」打开 ry-vue3 (如 http://192.168.x.x:5173),
|
||||
* 否则二维码里的 apiBase 是 localhost, 手机扫出来 localhost 指向手机自身, 无法回传.
|
||||
*/
|
||||
async function openDialog() {
|
||||
baseline = props.modelValue || ''
|
||||
visible.value = true
|
||||
qrLoading.value = true
|
||||
qrUrl.value = ''
|
||||
try {
|
||||
const cfg = await request.get('/common/camera/config')
|
||||
const base = (cfg && cfg.baseUrl) || ''
|
||||
if (!base) throw new Error('相机网页地址未配置 (ruoyi.camera.base-url)')
|
||||
const page = PAGE_PATH[props.subType] || '/pages/index/index'
|
||||
const apiBase = encodeURIComponent(window.location.origin + import.meta.env.VITE_APP_BASE_API)
|
||||
const target = `${base}#${page}?meetingId=${props.meetingId}&subType=${props.subType}&apiBase=${apiBase}`
|
||||
qrUrl.value = await QRCode.toDataURL(target, {
|
||||
width: 240, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' }
|
||||
})
|
||||
startPolling()
|
||||
} catch (e) {
|
||||
console.error('[camera-qr] gen qr failed', e)
|
||||
ElMessage.error(e?.msg || e?.message || '二维码生成失败')
|
||||
visible.value = false
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
polling.value = true
|
||||
pollTimer = setInterval(pollOnce, 3000)
|
||||
pollOnce()
|
||||
}
|
||||
|
||||
/** 拉 material 列表, 该 subType 出现新 ossUrl (≠ 打开时基线) 即回写并关闭 */
|
||||
async function pollOnce() {
|
||||
try {
|
||||
const resp = await request.get(`/business/meetingMaterial/${props.meetingId}`)
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
const row = (Array.isArray(list) ? list : []).find(m => m.subType === props.subType)
|
||||
const url = row && row.ossUrl ? row.ossUrl : ''
|
||||
if (url && url !== baseline) {
|
||||
emit('update:modelValue', url)
|
||||
ElMessage.success(`${props.label}已回传`)
|
||||
stopPolling()
|
||||
visible.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
/* 静默重试, 弹窗保持打开 */
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
polling.value = false
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-qr-upload { width: 100%; min-width: 0; }
|
||||
.camera-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.cam-link { color: var(--brand-primary); text-decoration: none; font-size: 13px; }
|
||||
.cam-link:hover { text-decoration: underline; }
|
||||
.cam-empty { color: #c0c4cc; font-size: 13px; }
|
||||
.cam-polling { color: #e6a23c; font-size: 12px; }
|
||||
|
||||
.qr-wrap { text-align: center; padding: 8px 0 4px; }
|
||||
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||
.qr-img { width: 240px; height: 240px; }
|
||||
.qr-error { width: 240px; height: 240px; margin: 0 auto; display: flex; align-items: center; justify-content: center; color: #f56c6c; font-size: 13px; }
|
||||
.qr-tip { font-size: 14px; color: #1a1a1a; margin-top: 14px; }
|
||||
.qr-tip-sub { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
.qr-status { font-size: 12px; color: #e6a23c; margin-top: 10px; }
|
||||
</style>
|
||||
@@ -64,8 +64,8 @@ const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
dir: { type: String, default: 'ry8080/idcard/' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
frontPlaceholder: { type: String, default: '/images/id-front.png' },
|
||||
backPlaceholder: { type: String, default: '/images/id-back.png' }
|
||||
frontPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-front.png' },
|
||||
backPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-back.png' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<!--
|
||||
共享通知列表组件
|
||||
------------------------------------------------------------
|
||||
自包含: 数据拉取 / SSE 实时刷新 / 详情弹窗 / 全部已读 全部封装在内.
|
||||
任何角色页面 (workbench / home / 独立 messages 页) 只需:
|
||||
<NoticeList :limit="5" /> // 嵌入式小列表 (默认有"全部已读"顶部按钮)
|
||||
<NoticeList :limit="50" :show-category="true" /> // 完整页列表 (显示分类标签)
|
||||
<NoticeList :limit="5" :show-header="false" /> // 嵌入式,父 section-title 自己带按钮
|
||||
|
||||
Props:
|
||||
limit Number 拉取条数,默认 5
|
||||
showCategory Boolean 是否显示 [通知/待办/系统] 分类标签,默认 false
|
||||
showHeader Boolean 是否显示组件自己的"全部已读"顶部按钮,默认 true
|
||||
emptyText String 空列表文案,默认 '暂无通知'
|
||||
|
||||
复用: 调用方不再写 notice-item / dot / SSE 订阅 / 详情 dialog,3 处变 1 处.
|
||||
-->
|
||||
<template>
|
||||
<div class="notice-list-wrap">
|
||||
<div v-if="showHeader" class="notice-list-header">
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
|
||||
</div>
|
||||
<ul class="notice-list">
|
||||
<li
|
||||
v-for="n in rows"
|
||||
:key="n.id"
|
||||
class="notice-item"
|
||||
:class="{ read: n.read }"
|
||||
@click="openDetail(n)"
|
||||
>
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">
|
||||
<span v-if="showCategory && n.category" class="cat" :class="catClass(n)">{{ n.category }}</span>
|
||||
{{ n.title }}
|
||||
</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="!rows.length" class="empty">{{ emptyText }}</li>
|
||||
</ul>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailOpen"
|
||||
:title="detail.title || '消息详情'"
|
||||
width="520px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span v-if="detail.category" class="cat" :class="catClass(detail)">{{ detail.category }}</span>
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.content || detail.text || '-' }}</div>
|
||||
<div v-if="detailLink" class="detail-link">
|
||||
<img v-if="detailQrUrl" :src="detailQrUrl" class="detail-qr" alt="链接二维码" />
|
||||
<div class="detail-link-row">
|
||||
<el-link type="primary" :href="detailLink" target="_blank" :underline="false">打开链接</el-link>
|
||||
<el-link type="primary" :underline="false" @click="copyDetailLink">复制链接</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
|
||||
const props = defineProps({
|
||||
limit: { type: Number, default: 5 },
|
||||
showCategory: { type: Boolean, default: false },
|
||||
showHeader: { type: Boolean, default: true },
|
||||
emptyText: { type: String, default: '暂无通知' }
|
||||
})
|
||||
|
||||
const rows = ref([])
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref({})
|
||||
const detailLink = ref('')
|
||||
const detailQrUrl = ref('')
|
||||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||||
|
||||
let unsubscribeNewMessage = null
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: props.limit })
|
||||
rows.value = r.rows || []
|
||||
} catch (e) {
|
||||
rows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见) */
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: props.limit })
|
||||
rows.value = r.rows || []
|
||||
} catch (e) { /* swallow */ }
|
||||
}
|
||||
|
||||
/** 从消息正文里抽取第一个 http(s) 链接, 去尾随标点 */
|
||||
function extractLink(content) {
|
||||
if (!content) return ''
|
||||
const m = String(content).match(/https?:\/\/[^\s"'<>]+/)
|
||||
if (!m) return ''
|
||||
return m[0].replace(/[。,、;:,.!?;:))\]]+$/, '')
|
||||
}
|
||||
|
||||
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
|
||||
function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
const link = extractLink(n.content)
|
||||
detailLink.value = link
|
||||
detailQrUrl.value = ''
|
||||
if (link) {
|
||||
QRCode.toDataURL(link, { width: 200, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' } })
|
||||
.then(url => { detailQrUrl.value = url })
|
||||
.catch(() => { detailQrUrl.value = '' })
|
||||
}
|
||||
if (!n.read) {
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制消息正文中的链接 */
|
||||
async function copyDetailLink() {
|
||||
if (!detailLink.value) return
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(detailLink.value)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = detailLink.value
|
||||
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) {
|
||||
ElMessage.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已读: 后端 markAllRead (后端会推 silent=true SSE, 角标自动清零) + 本地乐观更新 */
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** type 字段 (后端 BizMessage.msgType): '1'通知 '2'待办 '3'系统 */
|
||||
function catClass(n) {
|
||||
if (n.type === '1') return 'cat-notif'
|
||||
if (n.type === '2') return 'cat-todo'
|
||||
if (n.type === '3') return 'cat-sys'
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
// SSE 全局总线 (AdminLayout 维护连接, 这里订阅刷新本组件列表即可)
|
||||
// toast 由 AdminLayout 统一弹, 这里只负责本组件 list 实时刷新
|
||||
unsubscribeNewMessage = onGlobal(() => {
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notice-list-wrap { display: flex; flex-direction: column; }
|
||||
.notice-list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* 列表 (跟 doctor/Home.vue 旧内联样式保持像素一致) */
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item:hover .title { color: var(--brand-primary); }
|
||||
.notice-item .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title {
|
||||
font-size: 13px;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.notice-item .time {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 分类标签 (按后端 msgType 染色: 1=通知蓝 2=待办橙 3=系统紫) */
|
||||
.cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
background: #f0f0f0;
|
||||
color: #595959;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-sys { background: #f9f0ff; color: #722ed1; }
|
||||
.cat-todo { background: #fff7e6; color: #fa8c16; }
|
||||
.cat-notif { background: #e6f7ff; color: var(--brand-primary); }
|
||||
|
||||
.empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 详情 dialog */
|
||||
.detail-body .meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.detail-body .meta .time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.detail-body .text {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-link {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
.detail-link .detail-qr {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.detail-link-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
<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"/>
|
||||
|
||||
@@ -78,9 +78,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<!--
|
||||
项目级角色多选控件 (带"其他"逃生口)
|
||||
|
||||
数据来源: 父组件传入 props.roles (biz_project.role_labor JSON, 形如 [{role, customName, amount}])
|
||||
v-model = 逗号分隔字符串 (如 "主席,主持,特约嘉宾"), "其他"自定义角色作为普通项存储.
|
||||
|
||||
行为契约:
|
||||
- 勾选项目角色 → 该项进入 modelValue (用角色名 label)
|
||||
- 勾选 "其他" (控件自己虚拟追加) → 下方 el-input 出现 → 输入内容作为普通项进入 modelValue
|
||||
- 回填: modelValue 里不在项目角色列表里的项 → 自动按 "其他 + input" 显示
|
||||
- 兼容旧数据: modelValue 若为 JSON 数组字符串 (["主席","__other__:xx"]) 也能解析
|
||||
|
||||
用法:
|
||||
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
|
||||
-->
|
||||
<div class="project-role-multi">
|
||||
<el-checkbox-group v-model="selectedKeys" @change="onToggle">
|
||||
<el-checkbox v-for="o in displayOptions" :key="o.key" :value="o.key" :label="o.label" />
|
||||
</el-checkbox-group>
|
||||
<el-input
|
||||
v-if="showOtherInput"
|
||||
v-model="otherText"
|
||||
placeholder="请输入其他角色"
|
||||
class="other-input"
|
||||
maxlength="50"
|
||||
@input="emitValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
|
||||
roles: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// "其他" 虚拟项内部 key — 不暴露给父组件, 仅用于区分选中状态
|
||||
const OTHER_KEY = '__other__'
|
||||
|
||||
// 把 props.roles 投影成 {key, label} 数组 (label = role 或"其他"的 customName)
|
||||
const projectOptions = computed(() => {
|
||||
return (props.roles || [])
|
||||
.filter(r => r && (r.role || r.customName))
|
||||
.map(r => {
|
||||
if (r.role === '其他') {
|
||||
const customName = (r.customName || '').trim()
|
||||
return { key: customName, label: customName || '其他' }
|
||||
}
|
||||
return { key: r.role, label: r.role }
|
||||
})
|
||||
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
|
||||
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
|
||||
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
|
||||
.filter(o => o.key !== OTHER_KEY)
|
||||
})
|
||||
|
||||
const selectedKeys = ref([])
|
||||
const otherText = ref('')
|
||||
|
||||
const showOtherInput = computed(() => selectedKeys.value.includes(OTHER_KEY))
|
||||
const displayOptions = computed(() => [...projectOptions.value, { key: OTHER_KEY, label: '其他' }])
|
||||
|
||||
/** 构建逗号分隔字符串 ("其他"自定义内容作为普通项) */
|
||||
function buildValue() {
|
||||
return selectedKeys.value
|
||||
.map(k => (k === OTHER_KEY ? (otherText.value || '').trim() : k))
|
||||
.filter(x => x !== '')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 解析 modelValue (兼容 JSON 数组 + 逗号分隔) → selectedKeys + otherText */
|
||||
function syncFromModelValue(v) {
|
||||
let items = []
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); if (Array.isArray(p)) items = p } catch (e) { items = [v] }
|
||||
} else if (v) {
|
||||
items = String(v).split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const selected = []
|
||||
let other = ''
|
||||
const known = new Set(projectOptions.value.map(o => o.key))
|
||||
items.forEach(it => {
|
||||
const s = String(it)
|
||||
if (s === OTHER_KEY || s.startsWith(OTHER_KEY + ':')) {
|
||||
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
|
||||
if (s.startsWith(OTHER_KEY + ':')) other = s.substring((OTHER_KEY + ':').length)
|
||||
} else if (known.has(s)) {
|
||||
if (!selected.includes(s)) selected.push(s)
|
||||
} else {
|
||||
// 不在项目角色列表里 → "其他"自定义
|
||||
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
|
||||
other = s
|
||||
}
|
||||
})
|
||||
selectedKeys.value = selected
|
||||
otherText.value = other
|
||||
}
|
||||
|
||||
const lastEmitted = ref('')
|
||||
function emitValue() {
|
||||
const v = buildValue()
|
||||
lastEmitted.value = v
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
function onToggle() { emitValue() }
|
||||
|
||||
// 初始化 + 响应外部变化 (跳过自己 emit 的回显, 避免重解析覆盖用户输入)
|
||||
syncFromModelValue(props.modelValue)
|
||||
watch(() => props.modelValue, v => {
|
||||
if (v === lastEmitted.value) return
|
||||
syncFromModelValue(v)
|
||||
})
|
||||
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
|
||||
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-role-multi { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.other-input { width: 100%; }
|
||||
</style>
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<!--
|
||||
项目级角色下拉控件 (带"其他"逃生口)
|
||||
|
||||
数据来源: 父组件传入 props.roles (来自 biz_project.role_labor JSON, 形如 [{role, customName, amount}])
|
||||
- role === '其他' → 显示 customName 作为 label
|
||||
- role !== '其他' → 显示 role 作为 label
|
||||
|
||||
行为契约 (前端控件自管理, 父组件无感知):
|
||||
- 标准项目角色: 用户从下拉选 → v-model = 该角色名
|
||||
- 选 "其他" (控件自己虚拟追加, 不来自项目定义): 下方出现 el-input → v-model = input 内容
|
||||
- 回填: modelValue 不在项目角色列表里 → 自动按 "其他 + input" 显示, input 预填原值
|
||||
|
||||
用法:
|
||||
<project-role-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
|
||||
-->
|
||||
<div class="project-role-select">
|
||||
<el-select
|
||||
v-model="selectedKey"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
style="width: 100%"
|
||||
@change="handleSelectChange"
|
||||
@clear="handleClear"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in displayOptions"
|
||||
:key="o.key"
|
||||
:label="o.label"
|
||||
:value="o.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="showOtherInput"
|
||||
v-model="otherText"
|
||||
:placeholder="otherPlaceholder"
|
||||
class="other-input"
|
||||
maxlength="50"
|
||||
@input="emitValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
|
||||
roles: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '请选择角色' },
|
||||
otherPlaceholder: { type: String, default: '请输入角色名' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
|
||||
const OTHER_KEY = '__other_role__'
|
||||
|
||||
// 把 props.roles 投影成 {key, label} 数组
|
||||
// - role === '其他' → label = customName (项目里实际填的"自定义角色名")
|
||||
// - role !== '其他' → label = role
|
||||
const projectOptions = computed(() => {
|
||||
return (props.roles || [])
|
||||
.filter(r => r && (r.role || r.customName))
|
||||
.map(r => {
|
||||
if (r.role === '其他') {
|
||||
const customName = (r.customName || '').trim()
|
||||
return { key: customName, label: customName || '其他' }
|
||||
}
|
||||
return { key: r.role, label: r.role }
|
||||
})
|
||||
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
|
||||
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
|
||||
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
|
||||
.filter(o => o.key !== OTHER_KEY)
|
||||
})
|
||||
|
||||
const selectedKey = ref(props.modelValue || null)
|
||||
const otherText = ref(props.modelValue || '')
|
||||
|
||||
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
|
||||
|
||||
// 给 el-select 渲染的 options = 项目角色 + 末尾虚拟"其他"
|
||||
const displayOptions = computed(() => [
|
||||
...projectOptions.value,
|
||||
{ key: OTHER_KEY, label: '其他' }
|
||||
])
|
||||
|
||||
function isValueInOptions(v) {
|
||||
if (v == null || v === '') return false
|
||||
return projectOptions.value.some(o => o.key === v)
|
||||
}
|
||||
|
||||
function syncFromModelValue(v) {
|
||||
if (v == null || v === '') {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
return
|
||||
}
|
||||
if (isValueInOptions(v)) {
|
||||
selectedKey.value = v
|
||||
otherText.value = ''
|
||||
} else {
|
||||
// 不在项目角色列表里 → 走 "其他" 路径
|
||||
selectedKey.value = OTHER_KEY
|
||||
otherText.value = v
|
||||
}
|
||||
}
|
||||
|
||||
function emitValue() {
|
||||
let v
|
||||
if (selectedKey.value === OTHER_KEY) {
|
||||
v = otherText.value || ''
|
||||
} else if (selectedKey.value == null) {
|
||||
v = ''
|
||||
} else {
|
||||
v = selectedKey.value
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
|
||||
function handleSelectChange(key) {
|
||||
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
|
||||
otherText.value = ''
|
||||
}
|
||||
selectedKey.value = key
|
||||
emitValue()
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
emitValue()
|
||||
}
|
||||
|
||||
// 初始化 + 响应外部变化
|
||||
syncFromModelValue(props.modelValue)
|
||||
watch(() => props.modelValue, v => syncFromModelValue(v))
|
||||
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
|
||||
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-role-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.other-input { width: 100%; }
|
||||
</style>
|
||||
@@ -94,11 +94,11 @@ async function loadInitialUnread() {
|
||||
|
||||
/** 各角色点 bell 跳到自己的消息页 */
|
||||
const NOTICE_PATH = {
|
||||
admin: '/admin/workbench', // admin 暂未建独立消息页, 落工作台
|
||||
manager: '/manager/workbench', // manager 同上 (Phase 7 补 /manager/messages)
|
||||
admin: '/admin/messages',
|
||||
manager: '/manager/messages',
|
||||
doctor: '/doctor/messages',
|
||||
executor: '/executor/overview', // executor 同 admin/manager 暂用工作台
|
||||
sponsor: '/sponsor/home' // sponsor 同上
|
||||
executor: '/executor/messages',
|
||||
sponsor: '/sponsor/messages'
|
||||
}
|
||||
function goNotice() {
|
||||
const p = NOTICE_PATH[role.value] || '/'
|
||||
@@ -177,6 +177,7 @@ const MENU = {
|
||||
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass },
|
||||
{ path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document }
|
||||
]},
|
||||
{ path: '/admin/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/admin/account', title: '账号信息', icon: User }
|
||||
],
|
||||
manager: [
|
||||
@@ -189,6 +190,7 @@ 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/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
|
||||
],
|
||||
doctor: [
|
||||
@@ -206,6 +208,7 @@ const MENU = {
|
||||
{ path: '/executor/projects', title: '项目列表', icon: Document },
|
||||
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
|
||||
{ path: '/executor/labor', title: '劳务凭证', icon: EditPen },
|
||||
{ path: '/executor/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/executor/account', title: '账号信息', icon: Setting }
|
||||
],
|
||||
sponsor: [
|
||||
@@ -213,6 +216,7 @@ const MENU = {
|
||||
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document },
|
||||
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
|
||||
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
|
||||
{ path: '/sponsor/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ const routes = [
|
||||
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
|
||||
{ path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } },
|
||||
{ path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } },
|
||||
{ path: 'messages', name: 'admin-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
|
||||
]
|
||||
},
|
||||
@@ -84,6 +85,7 @@ const routes = [
|
||||
{ path: 'executor-people/view/:id', name: 'manager-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
|
||||
{ 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: '账号管理' } }
|
||||
]
|
||||
},
|
||||
@@ -92,7 +94,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: 'messages', name: 'doctor-messages', component: () => import('@/views/doctor/Messages.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: '新建项目设计投稿' } },
|
||||
{ path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } },
|
||||
@@ -107,13 +109,17 @@ const routes = [
|
||||
{ path: '', redirect: { name: 'executor-overview' } },
|
||||
{ path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } },
|
||||
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } },
|
||||
{ path: 'meetings/new', name: 'executor-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
|
||||
{ path: 'meetings/detail/:meetingId', name: 'executor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
|
||||
{ path: 'projects', name: 'executor-projects', component: () => import('@/views/executor/Projects.vue'), meta: { title: '项目列表' } },
|
||||
{ path: 'projects/detail/:projectId', name: 'executor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||
{ path: 'people', name: 'executor-people', component: () => import('@/views/executor/People.vue'), meta: { title: '人员管理' } },
|
||||
{ path: 'people/new', name: 'executor-people-new', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '新建人员' } },
|
||||
{ path: 'people/edit/:id', name: 'executor-people-edit', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '编辑人员' } },
|
||||
{ path: 'people/detail/:id', name: 'executor-people-detail', component: () => import('@/views/executor/PersonDetail.vue'), meta: { title: '人员详情' } },
|
||||
{ path: 'account', name: 'executor-account', component: () => import('@/views/executor/Account.vue'), meta: { title: '账号信息' } },
|
||||
{ path: 'labor', name: 'executor-labor', component: () => import('@/views/executor/Labor.vue'), meta: { title: '劳务凭证' } }
|
||||
{ path: 'labor', name: 'executor-labor', component: () => import('@/views/executor/Labor.vue'), meta: { title: '劳务凭证' } },
|
||||
{ path: 'messages', name: 'executor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }
|
||||
]
|
||||
},
|
||||
{ path: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [
|
||||
@@ -121,12 +127,15 @@ const routes = [
|
||||
{ path: 'home', name: 'sponsor-home', component: () => import('@/views/sponsor/Home.vue'), meta: { title: '首页' } },
|
||||
{ path: 'projects', name: 'sponsor-projects', component: () => import('@/views/sponsor/Projects.vue'), meta: { title: '项目管理' } },
|
||||
{ path: 'my-projects', name: 'sponsor-my-projects', component: () => import('@/views/sponsor/SponsorProjects.vue'), meta: { title: '我的项目' } },
|
||||
{ path: 'meetings', name: 'sponsor-meetings', component: () => import('@/views/sponsor/Meetings.vue'), meta: { title: '会议列表' } },
|
||||
{ path: 'projects/detail/:projectId', name: 'sponsor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||
{ path: 'meetings', name: 'sponsor-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议列表' } },
|
||||
{ path: 'meetings/detail/:meetingId', name: 'sponsor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
|
||||
{ path: 'people', name: 'sponsor-people', component: () => import('@/views/sponsor/SponsorPeople.vue'), meta: { title: '人员管理' } },
|
||||
{ path: 'people/new', name: 'sponsor-people-new', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '新建人员' } },
|
||||
{ path: 'people/edit/:id', name: 'sponsor-people-edit', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '编辑人员' } },
|
||||
{ path: 'people/detail/:id', name: 'sponsor-people-detail', component: () => import('@/views/sponsor/PersonDetail.vue'), meta: { title: '人员详情' } },
|
||||
{ path: 'account', name: 'sponsor-account', component: () => import('@/views/sponsor/Account.vue'), meta: { title: '账号信息' } }
|
||||
{ path: 'account', name: 'sponsor-account', component: () => import('@/views/sponsor/Account.vue'), meta: { title: '账号信息' } },
|
||||
{ path: 'messages', name: 'sponsor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
@@ -143,6 +152,8 @@ router.beforeEach((to, from, next) => {
|
||||
if (!to.meta?.role) return next()
|
||||
// 需要登录的路由: 未登录跳登录 (带 redirect)
|
||||
const user = JSON.parse(localStorage.getItem('ry_user') || 'null')
|
||||
// 扫码带 token 直登 (签劳务): 放行, 由页面 onMounted 用 token 完成登录
|
||||
if (!user && to.query?.token) return next()
|
||||
if (!user) return next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
// 已登录: role 不匹配由后端 401 拦截, 不在前端强跳 (避免误判让用户卡死)
|
||||
next()
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像).
|
||||
*
|
||||
* biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished
|
||||
* + material_audit_stage / voucher_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
|
||||
* + material/voucher_compliance_approved (区分两级审核) + 审核时间.
|
||||
*
|
||||
* 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影.
|
||||
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
|
||||
*/
|
||||
|
||||
const H24 = 24 * 3600 * 1000
|
||||
|
||||
function isTrue(v) {
|
||||
return v === 1 || v === '1' || v === true
|
||||
}
|
||||
|
||||
/** 待结算: 材料+凭证都通过 且 最晚审核时间已超 24h */
|
||||
function settlementReady(row) {
|
||||
if (row.voucherAuditStage !== 'APPROVED') return false
|
||||
const mat = row.materialAuditTime ? new Date(row.materialAuditTime).getTime() : 0
|
||||
const vch = row.voucherAuditTime ? new Date(row.voucherAuditTime).getTime() : 0
|
||||
const later = Math.max(mat, vch)
|
||||
if (!later) return false
|
||||
return Date.now() - later >= H24
|
||||
}
|
||||
|
||||
/**
|
||||
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
|
||||
*/
|
||||
export function derivePhysicalStage(row) {
|
||||
if (!row) return 'NOT_STARTED'
|
||||
if (isTrue(row.isFrozen)) return 'FROZEN'
|
||||
if (isTrue(row.isFinished)) return 'FINISHED'
|
||||
if (isTrue(row.isSettled)) return 'SETTLED'
|
||||
const material = row.materialAuditStage
|
||||
if (material === 'REJECTED') return 'RECTIFYING'
|
||||
if (material === 'APPROVED') return settlementReady(row) ? 'AWAITING_SETTLEMENT' : 'SUPERVISION_APPROVED'
|
||||
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE'
|
||||
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
|
||||
}
|
||||
|
||||
/**
|
||||
* 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay).
|
||||
* role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性.
|
||||
*/
|
||||
export function deriveStage(role, row) {
|
||||
if (!row) return '-'
|
||||
if (isTrue(row.isFrozen)) return '冻结中'
|
||||
if (isTrue(row.isFinished)) return '已完结'
|
||||
if (isTrue(row.isSettled)) return '已结算'
|
||||
|
||||
const material = row.materialAuditStage
|
||||
|
||||
// 退回: 执行方看「已退回」, 其他方看「待整改」
|
||||
if (material === 'REJECTED') {
|
||||
return role === 'executor' ? '已退回' : '待整改'
|
||||
}
|
||||
|
||||
// 材料已支持方通过
|
||||
if (material === 'APPROVED') {
|
||||
return settlementReady(row) ? '待结算' : '审核通过'
|
||||
}
|
||||
|
||||
// 材料在审 (SUBMITTED)
|
||||
if (material === 'SUBMITTED') {
|
||||
if (isTrue(row.materialComplianceApproved)) {
|
||||
// 支持方审中
|
||||
return role === 'manager' ? '审核通过' : '待审核'
|
||||
}
|
||||
// 合规审中
|
||||
return role === 'sponsor' ? '已执行未传材料' : '待审核'
|
||||
}
|
||||
|
||||
// 未提交
|
||||
if (isTrue(row.isExecuted)) {
|
||||
return role === 'executor' ? '执行中' : '已执行未传材料'
|
||||
}
|
||||
return '未执行'
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧签名: stageLabel(role, row) — row 现在传完整会议对象 (含事实字段).
|
||||
*/
|
||||
export function stageLabel(role, row) {
|
||||
return deriveStage(role, row)
|
||||
}
|
||||
|
||||
/** 颜色 class (业务专用 tag), 基于物理阶段 */
|
||||
export function stageClass(row) {
|
||||
const s = derivePhysicalStage(row)
|
||||
if (s === 'NOT_STARTED') return 'pending'
|
||||
if (s === 'RUNNING') return 'running'
|
||||
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'reviewing'
|
||||
if (s === 'SUPERVISION_APPROVED') return 'done'
|
||||
if (s === 'RECTIFYING') return 'waiting'
|
||||
if (s === 'AWAITING_SETTLEMENT') return 'waiting'
|
||||
if (s === 'SETTLED') return 'done'
|
||||
if (s === 'FINISHED') return 'done'
|
||||
if (s === 'FROZEN') return 'frozen'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/** el-tag type (doctor 列表用), 基于物理阶段 */
|
||||
export function stageTag(row) {
|
||||
const s = derivePhysicalStage(row)
|
||||
if (s === 'NOT_STARTED') return 'info'
|
||||
if (s === 'RUNNING' || s === 'RECTIFYING') return 'primary'
|
||||
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'warning'
|
||||
if (s === 'SUPERVISION_APPROVED' || s === 'SETTLED' || s === 'FINISHED') return 'success'
|
||||
if (s === 'AWAITING_SETTLEMENT') return 'warning'
|
||||
if (s === 'FROZEN') return 'info'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* 物理状态机全量 10 值 (用于筛选下拉: label=中性名, value=physical code).
|
||||
* 筛选按物理状态精确匹配, 显示层才按角色折叠, 避免多值 IN 复杂化.
|
||||
*/
|
||||
export const STAGE_OPTIONS = [
|
||||
{ label: '未执行', value: 'NOT_STARTED' },
|
||||
{ label: '执行中', value: 'RUNNING' },
|
||||
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
|
||||
{ label: '待支持方审核', value: 'AWAITING_SUPERVISION' },
|
||||
{ label: '审核通过', value: 'SUPERVISION_APPROVED' },
|
||||
{ label: '待整改', value: 'RECTIFYING' },
|
||||
{ label: '待结算', value: 'AWAITING_SETTLEMENT' },
|
||||
{ label: '已结算', value: 'SETTLED' },
|
||||
{ label: '已完结', value: 'FINISHED' },
|
||||
{ label: '冻结中', value: 'FROZEN' },
|
||||
]
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/dev-api',
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ request.interceptors.response.use(
|
||||
localStorage.removeItem('ry_user')
|
||||
// 带 redirect 跳登录, 登录成功后回跳原页面
|
||||
const redirect = window.location.hash.slice(1) || ''
|
||||
window.location.href = '/#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '')
|
||||
window.location.href = import.meta.env.BASE_URL + '#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '')
|
||||
}
|
||||
// __silentError: 调用方已经接管错误 toast (如把红错改成黄警), 拦截器不再重复弹
|
||||
if (!res.config?.__silentError) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
|
||||
const SSE_PATH = '/dev-api/business/message/stream'
|
||||
const SSE_PATH = import.meta.env.VITE_APP_BASE_API + '/business/message/stream'
|
||||
|
||||
// 监听器表 (允许多组件订阅)
|
||||
const listeners = { new_message: new Set(), connected: new Set() }
|
||||
|
||||
@@ -88,7 +88,7 @@ function reset() {
|
||||
|
||||
function preview(row) {
|
||||
// 启用的文章才允许预览(实际注册页只看启用的), 这里做软拦截
|
||||
window.open(`/#/article/${row.type}`, '_blank')
|
||||
window.open(`${import.meta.env.BASE_URL}#/article/${row.type}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
@@ -88,7 +88,7 @@ function reset() {
|
||||
}
|
||||
|
||||
function preview(row) {
|
||||
window.open(`/#/special-plan/${row.id}`, '_blank')
|
||||
window.open(`${import.meta.env.BASE_URL}#/special-plan/${row.id}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
@@ -513,7 +513,11 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.login-page .logo:hover{ opacity: 0.7; }
|
||||
|
||||
.login-page .logo-icon{
|
||||
width: 36px;
|
||||
|
||||
@@ -37,52 +37,32 @@
|
||||
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 →</a>
|
||||
</h2>
|
||||
<ul class="simple-list">
|
||||
<li class="simple-item" v-for="(s, idx) in pendingAgreements" :key="s.id" @click="showQrcode(s, idx)">
|
||||
<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)"
|
||||
>
|
||||
<div class="item-main">
|
||||
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
|
||||
</div>
|
||||
<span class="item-status">待签署</span>
|
||||
<span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
|
||||
</li>
|
||||
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知消息 -->
|
||||
<!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
通知消息
|
||||
<span class="title-actions">
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
</span>
|
||||
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 →</a>
|
||||
</h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="openDetail(n)">
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">{{ n.title }}</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="!notices.length" class="empty">暂无通知</li>
|
||||
</ul>
|
||||
<NoticeList :limit="5" :show-header="false" />
|
||||
</section>
|
||||
|
||||
<!-- 消息详情 dialog (点列表项触发) -->
|
||||
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px" align-center destroy-on-close>
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.text || detail.content || '-' }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
|
||||
<div class="qrcode-wrap">
|
||||
@@ -106,16 +86,14 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList, listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
import { getMyExpertProfile } from '@/api/business/expert'
|
||||
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
import { listUnsignedMeetingProtocols, listInvitedMeetings } from '@/api/business/meetingAttendee'
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
|
||||
const store = useUserStore()
|
||||
|
||||
const upcomingMeetings = ref([])
|
||||
const pendingAgreements = ref([])
|
||||
const notices = ref([])
|
||||
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
|
||||
const expertName = ref('')
|
||||
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
|
||||
@@ -140,7 +118,7 @@ async function showQrcode(row, idx) {
|
||||
try {
|
||||
// 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入)
|
||||
// Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3)
|
||||
const fullUrl = window.location.origin + '/#/doctor/sign-fill?attendeeId=' + row.id
|
||||
const fullUrl = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + row.id
|
||||
qrcodeMobileUrl.value = fullUrl
|
||||
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
|
||||
width: 240,
|
||||
@@ -202,8 +180,8 @@ async function load() {
|
||||
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏)
|
||||
if (store.expertAuditApproved) {
|
||||
try {
|
||||
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
|
||||
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
|
||||
const { data } = await listInvitedMeetings()
|
||||
upcomingMeetings.value = (Array.isArray(data) ? data : []).slice(0, 5)
|
||||
} catch (e) { upcomingMeetings.value = [] }
|
||||
|
||||
try {
|
||||
@@ -212,91 +190,24 @@ async function load() {
|
||||
id: s.id,
|
||||
meetingId: s.meetingId,
|
||||
meetingName: s.meetingName,
|
||||
startTime: s.startTime
|
||||
startTime: s.startTime,
|
||||
isEsigned: s.isEsigned
|
||||
}))
|
||||
} catch (e) { pendingAgreements.value = [] }
|
||||
} else {
|
||||
upcomingMeetings.value = []
|
||||
pendingAgreements.value = []
|
||||
}
|
||||
|
||||
// 通知
|
||||
try {
|
||||
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read // 严格按 DB is_read, 不再用 i>=2 硬编码"第3条算旧"误导用户
|
||||
}))
|
||||
} catch (e) { notices.value = [] }
|
||||
// 通知列表已抽到 <NoticeList> 组件, 本页不再处理
|
||||
}
|
||||
|
||||
/** 仅重拉通知列表 (SSE new_message 事件触发, AdminLayout 已统一弹 toast, 这里只刷新本页面 list) */
|
||||
async function refreshNotices() {
|
||||
try {
|
||||
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read
|
||||
}))
|
||||
} catch (e) { /* SSE 重拉失败静默, 用户下次手动刷新可见 */ }
|
||||
}
|
||||
|
||||
// SSE 订阅句柄, 卸载时解订阅避免内存泄漏
|
||||
let unsubscribeNewMessage = null
|
||||
|
||||
// ===== 消息详情 dialog =====
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
/** 点通知项: 打开 dialog + 调 markRead (后端会再触发 SSE 推新未读, navbar 角标自动减) */
|
||||
async function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
if (!n.read) {
|
||||
try {
|
||||
await markMessageRead(n.id)
|
||||
n.read = true // 本地乐观更新, 避免再调 refreshNotices 闪屏
|
||||
} catch (e) {
|
||||
// markRead 失败不阻塞 dialog, 用户下次点还会重试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已读: 调后端 markAllRead (后端会推 silent=true 的 SSE, 角标自动清零) */
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
// 乐观本地全标已读, 不等 SSE 推送 (push 过来后 refreshNotices 也会全 read)
|
||||
notices.value = notices.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表里只要有未读就显示"全部已读"按钮 */
|
||||
const hasUnread = computed(() => notices.value.some(n => !n.read))
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
timer = setInterval(updateClock, 60000)
|
||||
load()
|
||||
// 订阅全局总线 (AdminLayout 把 SSE 事件扇出到这里), 跨路由不掉, 但页面 unmount 时仍需解订阅
|
||||
// toast 弹窗由 AdminLayout 统一处理 (silent flag), 这里只做本页 list 刷新 + 医生审核状态联动
|
||||
unsubscribeNewMessage = onGlobal(() => {
|
||||
refreshNotices()
|
||||
// manager 审核通过事件过来: 重新拉 store 触发的 audit 状态, 若刚转 approved, 把 pannel 数据拉出来
|
||||
if (!store.expertAuditApproved && store.role === 'doctor') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -312,7 +223,6 @@ onBeforeUnmount(() => {
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
.title-actions { display: inline-flex; align-items: center; gap: 12px; font-weight: normal; }
|
||||
.cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.cols-row .section { margin-bottom: 16px; }
|
||||
.simple-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
@@ -323,17 +233,9 @@ onBeforeUnmount(() => {
|
||||
.item-title { font-size: 13px; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.item-status { flex-shrink: 0; font-size: 12px; color: #8c8c8c; }
|
||||
.item-status.done { color: #52c41a; }
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item:hover .title { color: var(--brand-primary); }
|
||||
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; }
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.simple-item.disabled { cursor: default; }
|
||||
.simple-item.disabled .item-title { color: #bfbfbf; }
|
||||
.simple-item.disabled:hover .item-title { color: #bfbfbf; }
|
||||
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -344,9 +246,4 @@ onBeforeUnmount(() => {
|
||||
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
|
||||
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
|
||||
|
||||
/* 消息详情 dialog */
|
||||
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||
.detail-body .time { font-size: 12px; color: #8c8c8c; }
|
||||
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable>
|
||||
<el-option label="未开始" value="未开始" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已结束" value="已结束" />
|
||||
<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><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
@@ -18,16 +16,16 @@
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="120">
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="stageTag(row.currentStage)" size="small">{{ row.currentStage || '-' }}</el-tag>
|
||||
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
|
||||
<el-button v-if="row.laborSigned !== '1'" link type="primary" @click="onSign(row)">签署劳务</el-button>
|
||||
<el-button v-if="!row.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
|
||||
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -44,30 +42,16 @@
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<!-- 签署劳务 dialog: 未签状态显示, 含 PDF 上传 -->
|
||||
<el-dialog v-model="signOpen" title="签署劳务" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="项目编号">{{ signForm.projectNo }}</el-form-item>
|
||||
<el-form-item label="会议名称">{{ signForm.meetingName }}</el-form-item>
|
||||
<el-form-item label="签字 PDF">
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:before-upload="beforeSignUpload"
|
||||
:http-request="uploadSignPdf"
|
||||
:file-list="signFileList"
|
||||
:on-remove="onSignRemove"
|
||||
accept=".pdf"
|
||||
>
|
||||
<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>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 签署劳务 dialog: 二维码 (带 token, 手机扫码可直接登录填写) -->
|
||||
<el-dialog v-model="signOpen" title="签署劳务" width="380px" align-center destroy-on-close>
|
||||
<div class="sign-qr-wrap">
|
||||
<div v-if="signQrLoading" v-loading="true" class="sign-qr-loading"></div>
|
||||
<img v-else-if="signQrUrl" :src="signQrUrl" class="sign-qr-img" />
|
||||
<div class="sign-link-tip">会议:{{ signForm.meetingName }}</div>
|
||||
<el-link type="primary" :underline="false" class="sign-copy" @click="copySignLink">复制链接</el-link>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="signOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!signForm.signedPdfUrl" @click="submitSign">签署劳务</el-button>
|
||||
<el-button @click="signOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -80,11 +64,11 @@
|
||||
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="pdf-preview">
|
||||
<iframe v-if="detail.signedPdfUrl" :src="detail.signedPdfUrl" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
|
||||
<iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
|
||||
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="detail.signedPdfUrl" link type="primary" @click="downloadPdf(detail.signedPdfUrl, detail.meetingName)">下载</el-button>
|
||||
<el-button v-if="detail.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
|
||||
<el-button @click="laborOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -94,8 +78,9 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
import QRCode from 'qrcode'
|
||||
import { bizList } from '@/api/public'
|
||||
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
|
||||
|
||||
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
@@ -104,9 +89,10 @@ const loading = ref(false)
|
||||
const detail = ref({})
|
||||
const laborOpen = ref(false)
|
||||
const signOpen = ref(false)
|
||||
const signForm = reactive({ meetingId: '', projectNo: '', meetingName: '', signedPdfUrl: '' })
|
||||
const signFileList = ref([])
|
||||
const submitting = ref(false)
|
||||
const signForm = reactive({ meetingName: '' })
|
||||
const signLink = ref('')
|
||||
const signQrUrl = ref('')
|
||||
const signQrLoading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -128,12 +114,6 @@ function reset() {
|
||||
load()
|
||||
}
|
||||
|
||||
function stageTag(s) {
|
||||
if (s === '已结束') return 'info'
|
||||
if (s === '执行中') return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function onViewLabor(row) {
|
||||
detail.value = row
|
||||
laborOpen.value = true
|
||||
@@ -144,7 +124,7 @@ function onView(row) {
|
||||
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
|
||||
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
|
||||
ElMessageBox.alert(
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${row.currentStage || '-'}`,
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
|
||||
'会议详情',
|
||||
{ confirmButtonText: '关闭' }
|
||||
)
|
||||
@@ -161,54 +141,49 @@ function onDownloadFile(row) {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function onSign(row) {
|
||||
signForm.meetingId = row.meetingId
|
||||
signForm.projectNo = row.projectNo || ''
|
||||
async function onSign(row) {
|
||||
signForm.meetingName = row.meetingName || ''
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
const attendeeId = row.attendeeId
|
||||
if (!attendeeId) { ElMessage.warning('缺少参会记录, 无法生成签署链接'); return }
|
||||
const token = localStorage.getItem('ry_token') || ''
|
||||
const url = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + attendeeId
|
||||
+ (row.meetingId != null ? '&meetingId=' + row.meetingId : '')
|
||||
+ '&token=' + encodeURIComponent(token)
|
||||
signLink.value = url
|
||||
signOpen.value = true
|
||||
}
|
||||
|
||||
function beforeSignUpload(file) {
|
||||
const max = 20 * 1024 * 1024
|
||||
if (file.size > max) { ElMessage.error('文件不能超过 20MB'); return false }
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) { ElMessage.error('仅支持 PDF 格式'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadSignPdf(opts) {
|
||||
signQrUrl.value = ''
|
||||
signQrLoading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(opts.file, 'ry8080/labor/signed/')
|
||||
signForm.signedPdfUrl = url
|
||||
ElMessage.success('签字 PDF 上传成功')
|
||||
signQrUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#1a1a1a', light: '#ffffff' }
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
||||
ElMessage.error('生成二维码失败')
|
||||
} finally {
|
||||
signQrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSignRemove() {
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
}
|
||||
|
||||
async function submitSign() {
|
||||
if (!signForm.signedPdfUrl) return ElMessage.warning('请先上传签字 PDF')
|
||||
submitting.value = true
|
||||
function copySignLink() {
|
||||
if (!signLink.value) return
|
||||
try {
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: signForm.meetingId,
|
||||
laborSigned: '1',
|
||||
signedPdfUrl: signForm.signedPdfUrl,
|
||||
signedTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
ElMessage.success('签署成功')
|
||||
signOpen.value = false
|
||||
load()
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(signLink.value)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = signLink.value
|
||||
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) {
|
||||
ElMessage.error(e?.msg || '签署失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
ElMessage.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +206,13 @@ load()
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.pdf-preview { margin-top: 12px; }
|
||||
.sign-link-tip { font-size: 13px; color: #606266; margin-bottom: 10px; }
|
||||
.sign-link-hint { font-size: 12px; color: #909399; margin-top: 10px; }
|
||||
.sign-qr-wrap { text-align: center; padding: 12px 0; }
|
||||
.sign-qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||
.sign-qr-img { width: 240px; height: 240px; }
|
||||
.sign-qr-wrap .sign-link-tip { margin-top: 16px; }
|
||||
.sign-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
|
||||
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
|
||||
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">
|
||||
首页 / 消息通知
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" class="mark-all" @click="markAllRead">全部已读</el-button>
|
||||
</div>
|
||||
|
||||
<ul class="notice-list">
|
||||
<li v-for="n in rows" :key="n.id" class="notice-item" :class="{ read: n.read }">
|
||||
<div class="dot"></div>
|
||||
<div class="body" @click="openDetail(n)">
|
||||
<div class="title"><span class="cat" :class="catClass(n.category)">{{ n.category }}</span>{{ n.title }}</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="!rows.length" class="empty">暂无消息</li>
|
||||
</ul>
|
||||
|
||||
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px">
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span class="cat" :class="catClass(detail.category)">{{ detail.category }}</span>
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.text || '-' }}</div>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
|
||||
const rows = ref([])
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 }); const list = (r.data && r.data.rows) || r.rows || []
|
||||
rows.value = list.map(a => ({
|
||||
id: a.id,
|
||||
category: a.category,
|
||||
title: a.title,
|
||||
text: a.text,
|
||||
time: a.time,
|
||||
read: a.read
|
||||
}))
|
||||
} catch (e) { rows.value = [] }
|
||||
}
|
||||
|
||||
function mapCategory(t) {
|
||||
if (t === 'system') return '系统通知'
|
||||
if (t === 'audit') return '审核结果'
|
||||
if (t === 'project') return '项目动态'
|
||||
if (t === 'meeting') return '会议提醒'
|
||||
return '通知'
|
||||
}
|
||||
|
||||
function catClass(c) {
|
||||
if (c === '系统通知') return 'sys'
|
||||
if (c === '审核结果') return 'audit'
|
||||
if (c === '项目动态') return 'proj'
|
||||
if (c === '会议提醒') return 'meet'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatAgo(t) {
|
||||
const diff = (Date.now() - new Date(t).getTime()) / 1000
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
|
||||
return new Date(t).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
if (!n.read) {
|
||||
// 乐观本地置已读 + 后端 markRead (后端触发 SSE 推新未读, navbar 角标自动减)
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.mark-all { font-size: 13px; }
|
||||
.notice-list { list-style: none; }
|
||||
.notice-item { padding: 14px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; display: flex; align-items: center; gap: 8px; }
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text { font-size: 12px; color: #595959; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.notice-item:hover .title { color: var(--brand-primary); }
|
||||
.cat { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 2px; background: #f0f0f0; color: #595959; }
|
||||
.cat.sys { background: #e6f7ff; color: var(--brand-primary); }
|
||||
.cat.audit { background: #f9f0ff; color: #722ed1; }
|
||||
.cat.proj { background: #fff7e6; color: #fa8c16; }
|
||||
.cat.meet { background: #f6ffed; color: #52c41a; }
|
||||
.empty { padding: 24px; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -1,9 +1,16 @@
|
||||
<template>
|
||||
<div class="sign-fill">
|
||||
<div v-loading="loading">
|
||||
<div v-if="notInvited" class="not-invited">
|
||||
<div class="not-invited-text">您暂未被邀请,请联系执会人员</div>
|
||||
<el-button type="primary" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<div v-else v-loading="loading">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
|
||||
<!-- 参会人信息 -->
|
||||
<div class="section-title">参会人信息</div>
|
||||
<!-- 大标题: 会议名称 + 期数 -->
|
||||
<div class="sign-header">
|
||||
<div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div>
|
||||
<div class="sign-header-period">期数:{{ periodDisplay || '-' }}</div>
|
||||
</div>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
@@ -16,9 +23,15 @@
|
||||
<el-form-item label="科室" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入科室" maxlength="100" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 银行账户 -->
|
||||
<div class="section-title">银行账户</div>
|
||||
<el-form-item label="医务职称">
|
||||
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 100%">
|
||||
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
|
||||
<template #footer>
|
||||
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
|
||||
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户名称(持卡人姓名)" prop="accountName">
|
||||
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
|
||||
</el-form-item>
|
||||
@@ -53,8 +66,6 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 劳务信息 (只读, admin 预填) -->
|
||||
<div class="section-title">本次劳务</div>
|
||||
<el-form-item label="劳务形式">
|
||||
<div class="labor-form-cell">
|
||||
<el-checkbox-group v-model="laborFormSelected">
|
||||
@@ -65,15 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="医务职称">
|
||||
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 240px">
|
||||
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
|
||||
<template #footer>
|
||||
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
|
||||
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button>
|
||||
@@ -88,13 +90,18 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSignInfo, saveSignProfile } from '@/api/business/sign'
|
||||
import { getSignInfo, saveSignProfile, resolveSign } from '@/api/business/sign'
|
||||
import { getInfo } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
|
||||
const userStore = useUserStore()
|
||||
const attendeeId = ref(null)
|
||||
const meetingId = computed(() => route.query.meetingId ? Number(route.query.meetingId) : null)
|
||||
const notInvited = ref(false)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -120,6 +127,19 @@ const form = reactive({
|
||||
})
|
||||
const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' })
|
||||
|
||||
// 会议大标题 (会议名称 + 期数), 由后端 getSignInfo 返回
|
||||
const meetingName = ref('')
|
||||
const periodNo = ref(null)
|
||||
const totalPeriods = ref(null)
|
||||
const periodDisplay = computed(() => {
|
||||
const p = periodNo.value
|
||||
const t = totalPeriods.value
|
||||
if (p == null && t == null) return ''
|
||||
if (p == null) return `${t}`
|
||||
if (t == null) return `${p}`
|
||||
return `${p}/${t}`
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
@@ -135,6 +155,73 @@ const rules = {
|
||||
bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 扫码带 token 直登 / 无 token 校验登录; 返回 true 表示已就绪 */
|
||||
async function ensureLogin() {
|
||||
const token = route.query.token
|
||||
if (token) {
|
||||
// 扫码带 token: 用新 token 重新登录 (覆盖本地已登录态)
|
||||
userStore.setToken(token)
|
||||
try {
|
||||
const info = await getInfo()
|
||||
const u = info.user || {}
|
||||
const role = u.roleType
|
||||
if (!role) {
|
||||
ElMessage.error('账号角色未配置, 请联系管理员')
|
||||
router.replace({ name: 'login' })
|
||||
return false
|
||||
}
|
||||
userStore.setUser({
|
||||
userId: u.userId,
|
||||
userName: u.userName || u.nickName || '',
|
||||
nickName: u.nickName || u.userName || '',
|
||||
phonenumber: u.phonenumber || '',
|
||||
accountType: u.accountType || 'MAIN',
|
||||
parentUserId: u.parentUserId || null,
|
||||
role
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
ElMessage.error('登录已失效, 请重新登录')
|
||||
router.replace({ name: 'login' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 无 token: 必须已登录
|
||||
if (userStore.user) return true
|
||||
ElMessage.error('请先登录')
|
||||
router.replace({ name: 'login', query: { redirect: route.fullPath } })
|
||||
return false
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loading.value = true
|
||||
const ok = await ensureLogin()
|
||||
if (!ok) return
|
||||
// 解析 attendeeId: 优先 URL 里的 attendeeId; 没有则按 meetingId 查
|
||||
let aid = route.query.attendeeId ? Number(route.query.attendeeId) : null
|
||||
if (!aid && meetingId.value) {
|
||||
try {
|
||||
const { data } = await resolveSign(meetingId.value)
|
||||
if (!data || !data.attendeeId) {
|
||||
notInvited.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
aid = data.attendeeId
|
||||
// 标题: 有 meetingId → 按 meetingId 查 (resolve 已返回会议名/期数)
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
attendeeId.value = aid
|
||||
await load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!attendeeId.value) {
|
||||
ElMessage.error('参数缺失, 请从工作台进入')
|
||||
@@ -162,25 +249,43 @@ async function load() {
|
||||
idCardAttachments: cur.idCardAttachments || '',
|
||||
title: cur.title || def.title || ''
|
||||
})
|
||||
// 先设置选项 (项目角色 + "其他"), 解析劳务形式需要用它区分"项目角色"和"其他自定义"
|
||||
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
|
||||
titleOptions.value = data.titleOptions || []
|
||||
|
||||
// 劳务形式回显: 兼容旧 JSON 数组 (["主席","__other__:xx"]) + 新逗号分隔 ("主席,主持,xx")
|
||||
laborFormSelected.value = []
|
||||
laborFormOther.value = ''
|
||||
if (cur.laborForm) {
|
||||
try {
|
||||
const parsed = JSON.parse(cur.laborForm)
|
||||
if (Array.isArray(parsed)) {
|
||||
laborFormSelected.value = parsed.filter(x => x !== '__other__' && !x.startsWith('__other__:'))
|
||||
const otherItem = parsed.find(x => x === '__other__' || x.startsWith('__other__:'))
|
||||
if (otherItem) {
|
||||
laborFormSelected.value.push('__other__')
|
||||
laborFormOther.value = otherItem.startsWith('__other__:') ? otherItem.substring('__other__:'.length) : ''
|
||||
}
|
||||
let items = []
|
||||
if (typeof cur.laborForm === 'string' && cur.laborForm.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(cur.laborForm); if (Array.isArray(p)) items = p } catch (e) { items = [cur.laborForm] }
|
||||
} else {
|
||||
items = String(cur.laborForm).split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
const known = new Set(laborFormOptions.value.map(o => o.value))
|
||||
items.forEach(it => {
|
||||
const s = String(it)
|
||||
if (s === '__other__' || s.startsWith('__other__:')) {
|
||||
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
|
||||
if (s.startsWith('__other__:')) laborFormOther.value = s.substring('__other__:'.length)
|
||||
} else if (known.has(s)) {
|
||||
if (!laborFormSelected.value.includes(s)) laborFormSelected.value.push(s)
|
||||
} else {
|
||||
// 不在项目角色里 → 当作"其他"自定义
|
||||
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
|
||||
laborFormOther.value = s
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
})
|
||||
}
|
||||
|
||||
feeDisplay.preTax = cur.feePreTax || '—'
|
||||
feeDisplay.tax = cur.tax || '—'
|
||||
feeDisplay.fee = cur.fee || '—'
|
||||
|
||||
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
|
||||
titleOptions.value = data.titleOptions || []
|
||||
meetingName.value = data.meetingName || ''
|
||||
periodNo.value = data.periodNo ?? null
|
||||
totalPeriods.value = data.totalPeriods ?? null
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally {
|
||||
@@ -195,16 +300,13 @@ async function onSubmit() {
|
||||
ElMessage.warning('请上传身份证正反面')
|
||||
return
|
||||
}
|
||||
// 劳务形式序列化 (JSON 数组字符串, 含 __other__ + 自定义内容)
|
||||
// 劳务形式序列化: 逗号分隔字符串, "其他"自定义内容作为普通项
|
||||
let laborForm = ''
|
||||
if (laborFormSelected.value.length) {
|
||||
const arr = laborFormSelected.value.map(x => {
|
||||
if (x === '__other__' && laborFormOther.value) {
|
||||
return '__other__:' + laborFormOther.value
|
||||
}
|
||||
return x
|
||||
})
|
||||
laborForm = JSON.stringify(arr)
|
||||
laborForm = laborFormSelected.value
|
||||
.map(x => (x === '__other__' ? (laborFormOther.value || '').trim() : x))
|
||||
.filter(x => x !== '' && x !== '__other__')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
@@ -228,23 +330,21 @@ function goBack() {
|
||||
router.push('/doctor/home')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 手机端全屏表单 (无 navbar / 无 page-card) */
|
||||
.sign-fill { padding: 16px; }
|
||||
|
||||
/* 章节标题: 左边 3px 蓝竖线 */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
/* 大标题: 会议名称 + 期数 */
|
||||
.sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; }
|
||||
.sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
|
||||
.sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
|
||||
|
||||
/* 未受邀提示 */
|
||||
.not-invited { padding: 60px 20px; text-align: center; }
|
||||
.not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
|
||||
|
||||
/* 开户地址 3 列布局 */
|
||||
.region-row {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page-card executor-meetings">
|
||||
<div class="breadcrumb">首页 / 会议列表</div>
|
||||
<p class="page-sub">查看执行方参与的会议, 提交监督意见</p>
|
||||
<p class="page-sub">查看执行方参与的会议</p>
|
||||
|
||||
<!-- ========== 筛选区 (与 People.vue 风格一致) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
@@ -19,6 +19,11 @@
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" clearable placeholder="请选择" style="width:140px">
|
||||
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="期数"><el-input v-model="q.sessionNo" clearable placeholder="输入期数" style="width:100px" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
@@ -42,9 +47,9 @@
|
||||
<el-table-column label="期数/总期数" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.currentStage === '执行中' ? 'success' : 'info'" size="small">{{ row.currentStage || '-' }}</el-tag>
|
||||
<el-tag :type="row.currentStage === 'RUNNING' ? 'success' : 'info'" size="small">{{ stageLabel('executor', row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isFinished" label="是否结题" width="90" align="center">
|
||||
@@ -67,53 +72,26 @@
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<!-- 监督意见 弹窗 -->
|
||||
<!-- 监督意见 (只读: 由支持方/监察员在审批时填写, 执行方仅可查看) -->
|
||||
<el-dialog v-model="supOpen" title="监督意见" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="会议名称">
|
||||
<span>{{ supRow.meetingName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="监督结论">
|
||||
<el-radio-group v-model="supForm.rating">
|
||||
<el-radio value="good">满意</el-radio>
|
||||
<el-radio value="normal">一般</el-radio>
|
||||
<el-radio value="bad">存在需整改的问题</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="监督意见">
|
||||
<el-input v-model="supForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入监督意见(0/500)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="supOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="onSaveSup">确定</el-button>
|
||||
</template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="会议名称">{{ supRow.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ stageLabel('executor', supRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="监督意见">{{ supRow.supervisionOpinion || '暂无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="supOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="viewOpen" title="会议详情" width="640px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="项目编号">{{ view.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议ID">{{ view.meetingId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称" :span="2">{{ view.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目形式">{{ view.projectForm }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ view.currentStage }}</el-descriptions-item>
|
||||
<el-descriptions-item label="期数/总期数">{{ view.periodNo }}/{{ view.totalPeriods }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否系列会">{{ view.isSeries }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间" :span="2">{{ view.startTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间" :span="2">{{ view.endTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="监督意见" :span="2">{{ view.supervisionOpinion || '暂无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="viewOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizList } from '@/api/public'
|
||||
import { stageLabel, STAGE_OPTIONS } from '@/utils/meetingStage'
|
||||
|
||||
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' })
|
||||
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
@@ -121,10 +99,6 @@ const selected = ref([])
|
||||
|
||||
const supOpen = ref(false)
|
||||
const supRow = ref({})
|
||||
const supForm = reactive({ rating: 'good', opinion: '' })
|
||||
|
||||
const viewOpen = ref(false)
|
||||
const view = ref({})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -136,39 +110,32 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED + ?isFinished=1, 让列表页自动应用筛选)
|
||||
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
|
||||
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' }
|
||||
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' }
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(arr) { selected.value = arr }
|
||||
|
||||
function onView(row) { view.value = row; viewOpen.value = true }
|
||||
function onView(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
|
||||
|
||||
function onSupervision(row) {
|
||||
supRow.value = row
|
||||
supForm.rating = 'good'
|
||||
supForm.opinion = row.supervisionOpinion || ''
|
||||
supOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaveSup() {
|
||||
try {
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: supRow.value.meetingId,
|
||||
supervisionOpinion: supForm.opinion,
|
||||
supervisionRating: supForm.rating
|
||||
})
|
||||
ElMessage.success('已保存监督意见')
|
||||
supOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,57 +2,47 @@
|
||||
<div class="page-card executor-overview">
|
||||
<div class="breadcrumb">首页 / 工作台</div>
|
||||
|
||||
<!-- 5 个 KPI 卡 (与 manager/Workbench 风格一致) -->
|
||||
<!-- 5 个 KPI 卡 (点击跳列表页 + 自动应用筛选, 字段名跟 Projects.vue / Meetings.vue q 一致) -->
|
||||
<div class="stats-grid">
|
||||
<router-link class="stat-card" to="/executor/projects">
|
||||
<div class="stat-label">项目总数量</div>
|
||||
<div class="stat-value">{{ stats.totalProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/executor/projects">
|
||||
<router-link class="stat-card" to="/executor/projects?isSettled=Y">
|
||||
<div class="stat-label">已结算数量</div>
|
||||
<div class="stat-value">{{ stats.settledProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/executor/projects">
|
||||
<router-link class="stat-card" to="/executor/projects?isFinished=1">
|
||||
<div class="stat-label">已结题数量</div>
|
||||
<div class="stat-value">{{ stats.completedProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/executor/meetings">
|
||||
<router-link class="stat-card" to="/executor/meetings?currentStage=RUNNING">
|
||||
<div class="stat-label">已执行会议</div>
|
||||
<div class="stat-value">{{ stats.executedMeetings }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/executor/meetings">
|
||||
<router-link class="stat-card" to="/executor/meetings?currentStage=NOT_STARTED">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
|
||||
<section class="section">
|
||||
<div class="section-title">消息通知<a class="more" href="javascript:void(0)">更多 →</a></div>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" v-for="n in notices" :key="n.id" :class="{ read: n.read }">
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">{{ n.title }}</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="notices.length === 0" class="empty">暂无消息</li>
|
||||
</ul>
|
||||
<div class="section-title">消息通知<a class="more" @click.prevent="$router.push('/executor/messages')">更多 →</a></div>
|
||||
<NoticeList :limit="50" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { bizList } from '@/api/public'
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
|
||||
const stats = ref({
|
||||
totalProjects: 0, settledProjects: 0, completedProjects: 0,
|
||||
executedMeetings: 0, pendingMeetings: 0
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
@@ -65,8 +55,8 @@ async function loadStats() {
|
||||
// 会议总数量
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
const meetingTotal = ms.total || ms.data?.total || 0
|
||||
// 已执行会议
|
||||
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, status: '已执行' })
|
||||
// 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交)
|
||||
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' })
|
||||
stats.value.executedMeetings = em.total || em.data?.total || 0
|
||||
stats.value.pendingMeetings = Math.max(0, meetingTotal - stats.value.executedMeetings)
|
||||
// 已结算项目
|
||||
@@ -77,23 +67,7 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotices() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 })
|
||||
const list = r.rows || []
|
||||
notices.value = list.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
time: n.time,
|
||||
read: n.read
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('loadNotices failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadStats(); loadNotices() })
|
||||
onMounted(() => { loadStats() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -145,17 +119,6 @@ onMounted(() => { loadStats(); loadNotices() })
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; cursor: pointer; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; }
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.empty { padding: 24px; text-align: center; color: #909399; }
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="page-card executor-projects">
|
||||
<div class="breadcrumb">首页 / 项目列表</div>
|
||||
<p class="page-sub">查看执行方承接的项目, 进行项目分配与评价</p>
|
||||
|
||||
<!-- ========== 筛选区 (与 People.vue 风格一致) ========== -->
|
||||
<el-form inline :model="query" class="filter-form">
|
||||
@@ -16,21 +15,27 @@
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算">
|
||||
<el-select v-model="query.isSettled" clearable placeholder="请选择" style="width:140px">
|
||||
<el-option label="已结算" value="Y" />
|
||||
<el-option label="未结算" value="N" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadList">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- ========== 批量按钮 (与 People.vue 风格一致) ========== -->
|
||||
<div class="batch-bar">
|
||||
<!-- ========== 批量按钮 (仅主账号; 执行人 SUB 只查看) ========== -->
|
||||
<div v-if="!isSub" class="batch-bar">
|
||||
<el-button type="primary" :disabled="!selected.length" @click="onAssign">项目分配</el-button>
|
||||
<span class="filter-tip">已选 {{ selected.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 (与 People.vue 风格一致) ========== -->
|
||||
<el-table :data="list" border stripe v-loading="loading" @selection-change="sel=>selected=sel">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column v-if="!isSub" type="selection" width="48" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="120" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="总场次/总期数" width="110" align="center">
|
||||
@@ -71,10 +76,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="项目开始时间" width="150" />
|
||||
<el-table-column prop="endTime" label="项目结束时间" width="150" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="atCap(row)" :title="atCap(row) ? '已建会议数已达分配场次上限' : ''" @click="onCreateMeeting(row)">建会</el-button>
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onAssign(row)">分配</el-button>
|
||||
<el-button v-if="!isSub" link type="primary" @click="onAssign(row)">分配</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -84,52 +90,42 @@
|
||||
@current-change="loadList" @size-change="loadList" />
|
||||
</div>
|
||||
|
||||
<!-- 项目分配 弹窗 -->
|
||||
<el-dialog v-model="assignOpen" title="项目分配" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="项目名称">
|
||||
<span>{{ assignRow.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配给">
|
||||
<el-select v-model="assignForm.assignee" placeholder="请选择监察员" filterable style="width:100%">
|
||||
<el-option v-for="p in people" :key="p.userId" :label="p.name" :value="p.userId" />
|
||||
<!-- 项目分配 (执行人) 弹窗: 仿 sponsor/SponsorProjects.vue, 多选 + 批量 -->
|
||||
<el-dialog v-model="assignOpen" :title="assignBatchMode ? `批量项目分配 (${assignBatchRows.length} 个项目)` : '项目分配'" width="600px" append-to-body destroy-on-close>
|
||||
<div v-if="!assignBatchMode" class="assign-project-info">
|
||||
<div><span class="lbl">项目编号</span><span>{{ assignForm.projectNo }}</span></div>
|
||||
<div><span class="lbl">项目名称</span><span>{{ assignForm.projectName }}</span></div>
|
||||
</div>
|
||||
<div v-else class="assign-project-info">
|
||||
<div><span class="lbl">已选项目数</span><span>{{ assignBatchRows.length }}</span></div>
|
||||
<div><span class="lbl">项目编号预览</span><span style="color:#606266;font-size:12px">{{ assignBatchRows.slice(0,5).map(r=>r.projectNo).join('、') }}{{ assignBatchRows.length > 5 ? ' 等' : '' }}</span></div>
|
||||
</div>
|
||||
<el-form :model="assignForm" label-width="110px">
|
||||
<el-form-item label="分配给执行人">
|
||||
<el-select v-model="assignForm.staffUserIds" multiple collapse-tags collapse-tags-tooltip filterable placeholder="请选择执行人 (可多选)" style="width:100%">
|
||||
<el-option v-for="s in allStaff" :key="s.userId" :label="s.name + (s.phone ? ' (' + s.phone + ')' : '')" :value="s.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="onSaveAssign">确定</el-button>
|
||||
<el-button type="primary" :loading="assignSubmitting" :disabled="!assignForm.staffUserIds.length" @click="saveAssign">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 评分弹窗已移除: executor 不可评分 -->
|
||||
|
||||
<el-dialog v-model="detailOpen" title="项目详情" width="720px">
|
||||
<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 }}/{{ detail.totalPeriods }}</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="总金额">¥ {{ formatNum(detail.totalAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可用金额" :span="2">¥ {{ formatNum(detail.availableAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付劳务费">¥ {{ formatNum(detail.paidLaborAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付会务费">¥ {{ formatNum(detail.paidMeetingAmount) }}</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>
|
||||
<!-- 项目详情已迁到公共页 /executor/projects/detail/:projectId (复用 manager/ManagerProjectDetail.vue) -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { bizUpdate } from '@/api/public'
|
||||
import { listExecutorPerson } from '@/api/business/person'
|
||||
import { executorAssignProject, getExecutorAssigns } from '@/api/business/project'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
// executor 专属: 只查分给当前用户 (主账号 biz_org.org_id 或 子账号 exec_user_id) 的项目
|
||||
async function listExecutorProjects(params) {
|
||||
@@ -139,17 +135,22 @@ async function listExecutorProjects(params) {
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const query = reactive({ projectNo: '', projectName: '', isFinished: '' })
|
||||
const query = reactive({ projectNo: '', projectName: '', isFinished: '', isSettled: '' })
|
||||
const dateRange = ref([])
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const selected = ref([])
|
||||
const people = ref([])
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
// 项目分配 (执行人): 仿 sponsor/SponsorProjects.vue
|
||||
const assignOpen = ref(false)
|
||||
const assignBatchMode = ref(false)
|
||||
const assignBatchRows = ref([])
|
||||
const assignSubmitting = ref(false)
|
||||
const assignRow = ref({})
|
||||
const assignForm = reactive({ assignee: '' })
|
||||
const assignForm = reactive({ projectId: null, projectNo: '', projectName: '', staffUserIds: [] })
|
||||
const allStaff = ref([])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isSub = computed(() => userStore.isSub)
|
||||
|
||||
function formatNum(n) {
|
||||
if (n == null) return '0.00'
|
||||
@@ -171,43 +172,108 @@ async function loadList() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 读 URL query 写入 query (Overview KPI 卡跳转时带 ?isSettled=Y / ?isFinished=1, 让列表页自动应用筛选)
|
||||
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.isFinished != null && q2.isFinished !== '') query.isFinished = String(q2.isFinished)
|
||||
if (q2.isSettled != null && q2.isSettled !== '') query.isSettled = String(q2.isSettled)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.projectNo = ''
|
||||
query.projectName = ''
|
||||
query.isFinished = ''
|
||||
query.isSettled = ''
|
||||
dateRange.value = []
|
||||
page.pageNum = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
function viewDetail(row) { detail.value = row; detailOpen.value = true }
|
||||
function viewDetail(row) { router.push(`/executor/projects/detail/${row.projectId}`) }
|
||||
|
||||
async function loadPeople() {
|
||||
try { const r = await bizList('person', { pageNum: 1, pageSize: 200 }); people.value = ((r.data && r.data.rows) || r.rows || []).map(p => ({ userId: p.personId, name: p.name })) }
|
||||
catch { people.value = [] }
|
||||
}
|
||||
|
||||
function onAssign(row) {
|
||||
const target = row || selected.value[0]
|
||||
if (!target) return
|
||||
assignRow.value = target
|
||||
assignForm.assignee = ''
|
||||
assignOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaveAssign() {
|
||||
if (!assignForm.assignee) { ElMessage.warning('请选择分配人'); return }
|
||||
async function loadStaff() {
|
||||
// 执行方自己的执行人 (role=meetingExecutor), 走 listExecutorPerson (后端强制 unit_type='executor' + 当前主账号隔离)
|
||||
try {
|
||||
await bizUpdate('project', { projectId: assignRow.value.projectId, supervisorId: assignForm.assignee })
|
||||
ElMessage.success('分配成功')
|
||||
const res = await listExecutorPerson({ role: 'meetingExecutor', pageNum: 1, pageSize: 100 })
|
||||
allStaff.value = res.rows || []
|
||||
} catch { allStaff.value = [] }
|
||||
}
|
||||
/** 拉项目当前已分配执行人 → 回显 staffUserIds (单选模式) */
|
||||
async function loadCurrentAssigns(projectId) {
|
||||
try {
|
||||
const resp = await getExecutorAssigns(projectId)
|
||||
const arr = (resp && resp.data) || []
|
||||
assignForm.staffUserIds = (Array.isArray(arr) ? arr : []).map(x => x.staffUserId).filter(Boolean)
|
||||
} catch (e) { assignForm.staffUserIds = [] }
|
||||
}
|
||||
function openAssign(row) {
|
||||
assignBatchMode.value = false
|
||||
assignBatchRows.value = []
|
||||
assignRow.value = row
|
||||
assignForm.projectId = row.projectId
|
||||
assignForm.projectNo = row.projectNo
|
||||
assignForm.projectName = row.projectName
|
||||
assignForm.staffUserIds = []
|
||||
assignOpen.value = true
|
||||
loadStaff()
|
||||
loadCurrentAssigns(row.projectId)
|
||||
}
|
||||
function openAssignBatch(rows) {
|
||||
assignBatchMode.value = true
|
||||
assignBatchRows.value = rows.map(r => ({ projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName }))
|
||||
assignForm.staffUserIds = []
|
||||
assignOpen.value = true
|
||||
loadStaff()
|
||||
}
|
||||
function onAssign(row) {
|
||||
if (row) { openAssign(row); return }
|
||||
// 批量按钮入口 (无 row): 单选走 openAssign, 多选走批量
|
||||
if (!selected.value.length) return ElMessage.warning('请先选择项目')
|
||||
if (selected.value.length === 1) openAssign(selected.value[0])
|
||||
else openAssignBatch(selected.value)
|
||||
}
|
||||
async function saveAssign() {
|
||||
if (!assignForm.staffUserIds.length) return ElMessage.warning('请至少选择一位执行人')
|
||||
assignSubmitting.value = true
|
||||
try {
|
||||
if (assignBatchMode.value) {
|
||||
let ok = 0, errs = []
|
||||
for (const r of assignBatchRows.value) {
|
||||
try {
|
||||
await executorAssignProject({ projectId: r.projectId, staffUserIds: [...assignForm.staffUserIds] })
|
||||
ok++
|
||||
} catch (e) { errs.push(`${r.projectNo}: ${e?.msg || e.message}`) }
|
||||
}
|
||||
if (errs.length) ElMessageBox.alert(`批量分配: 成功 ${ok}/${assignBatchRows.value.length}\n\n失败:\n${errs.join('\n')}`, '分配结果', { type: 'warning' })
|
||||
else ElMessage.success(`批量分配成功 (${ok}/${assignBatchRows.value.length})`)
|
||||
} else {
|
||||
await executorAssignProject({ projectId: assignForm.projectId, staffUserIds: [...assignForm.staffUserIds] })
|
||||
ElMessage.success('分配成功')
|
||||
}
|
||||
assignOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '分配失败') }
|
||||
} finally {
|
||||
assignSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 评价相关函数已移除: executor 不可评分
|
||||
|
||||
onMounted(() => { loadList(); loadPeople() })
|
||||
// 建会: 执行机构人员 (MAIN/SUB 只要能看到项目) 都可建会, 跳共享 MeetingNew 页 (与 manager/admin 同页), 限额由后端 BizMeetingController.add 校验
|
||||
function atCap(row) {
|
||||
const assigned = row.assignedSessions || 0
|
||||
const built = row.meetingCount || 0
|
||||
return built >= assigned
|
||||
}
|
||||
|
||||
function onCreateMeeting(row) {
|
||||
router.push(`/executor/meetings/new?projectId=${row.projectId}`)
|
||||
}
|
||||
|
||||
onMounted(() => { readQueryFromRoute(); loadList() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -219,4 +285,7 @@ onMounted(() => { loadList(); loadPeople() })
|
||||
.filter-tip { color: #909399; font-size: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
.assign-project-info { background: #f5f7fa; padding: 12px 16px; border-radius: 4px; margin-bottom: 12px; }
|
||||
.assign-project-info > div { display: flex; line-height: 22px; }
|
||||
.assign-project-info .lbl { color: #909399; width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -367,7 +367,7 @@ const uploadRef = ref(null)
|
||||
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
|
||||
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
|
||||
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action
|
||||
const importAction = computed(() => '/dev-api/business/expert/importData?updateSupport=false')
|
||||
const importAction = computed(() => import.meta.env.VITE_APP_BASE_API + '/business/expert/importData?updateSupport=false')
|
||||
|
||||
function openImport() {
|
||||
importResult.value = null
|
||||
@@ -376,7 +376,7 @@ function openImport() {
|
||||
|
||||
// 模板下载: 3 行 fetch + blob (后端 /business/expert/importTemplate)
|
||||
function downloadImportTpl() {
|
||||
fetch('/dev-api/business/expert/importTemplate', { headers: importHeaders.value })
|
||||
fetch(import.meta.env.VITE_APP_BASE_API + '/business/expert/importTemplate', { headers: importHeaders.value })
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const a = document.createElement('a')
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 -->
|
||||
<!-- 面包屑 (按角色显示: sponsor → 我的项目; manager/admin → 项目管理) -->
|
||||
<div class="breadcrumb">
|
||||
首页 / 项目管理 / <span class="current">项目详情</span>
|
||||
首页 /
|
||||
<span v-if="userStore?.user?.roleType === 'sponsor'">我的项目</span>
|
||||
<span v-else-if="userStore?.user?.roleType === 'executor'">项目列表</span>
|
||||
<span v-else>项目管理</span>
|
||||
/ <span class="current">项目详情</span>
|
||||
</div>
|
||||
|
||||
<!-- ================= 1. 项目信息 (11 字段, 两列, 只读) ================= -->
|
||||
@@ -32,11 +36,27 @@
|
||||
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="项目评价"><span class="info-value">{{ fmtScore(row.managerScore) }}</span></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="服务公司">
|
||||
<el-table :data="assigns" border size="small" class="assign-table-el">
|
||||
<el-table-column type="index" label="#" width="48" align="center" />
|
||||
<el-table-column prop="orgName" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="sessions" label="场次" width="90" align="right" />
|
||||
<el-table-column label="金额" width="130" align="right">
|
||||
<template #default="{ row }">¥ {{ fmtMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span style="color:#c0c4cc">暂无分配 (到 项目管理 / 项目分配 添加)</span>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- ================= 2. 角色劳务设置 (只读) ================= -->
|
||||
<div class="new-card-title">角色劳务设置</div>
|
||||
<p class="hint-text">*角色劳务设置针对该项目所有会议生效</p>
|
||||
<table class="info-table role-labor-table">
|
||||
<colgroup>
|
||||
<col style="width:120px">
|
||||
@@ -59,7 +79,6 @@
|
||||
|
||||
<!-- ================= 3. 支持单位监督员 (只读) ================= -->
|
||||
<div class="new-card-title">支持单位监督员</div>
|
||||
<p class="hint-text">*增加只显示已点支持意向的</p>
|
||||
<div class="monitor-list">
|
||||
<div v-for="(m, idx) in monitors" :key="idx" class="monitor-row">
|
||||
<span class="monitor-num">{{ idx + 1 }}.</span>
|
||||
@@ -68,6 +87,8 @@
|
||||
<div v-if="!monitors.length" class="empty-hint">暂无监督员</div>
|
||||
</div>
|
||||
|
||||
<!-- (已分配执行方 已折叠进上方"项目信息"section, 此处不再重复) -->
|
||||
|
||||
<!-- ================= 4. 已发布公告 (只读, 仅查看) ================= -->
|
||||
<div class="new-card-title">已发布公告</div>
|
||||
<el-table v-loading="loading" :data="announcements" border>
|
||||
@@ -90,16 +111,23 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizGet, bizList } from '@/api/public'
|
||||
import { listExecutorOrgs } from '@/api/system'
|
||||
import request from '@/utils/request'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ===================== 状态 =====================
|
||||
const projectId = ref('')
|
||||
const row = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
// 已分配执行方 (服务公司) — 数据源: GET /business/project/{id}/assigns + listExecutorOrgs 补 org_name
|
||||
const assigns = ref([])
|
||||
|
||||
// 角色劳务 (从 biz_project.role_labor JSON 字段读; 空数组 → 模板 "暂无角色" 行兜底)
|
||||
const form = reactive({
|
||||
roleRows: []
|
||||
@@ -149,11 +177,7 @@ async function loadProject() {
|
||||
const rl = JSON.parse(row.value.roleLabor || '[]')
|
||||
if (Array.isArray(rl) && rl.length) form.roleRows = rl
|
||||
} catch (e) { /* keep empty */ }
|
||||
// 监督员 (JSON 字段)
|
||||
try {
|
||||
const mn = JSON.parse(row.value.monitors || '[]')
|
||||
if (Array.isArray(mn)) monitors.value = mn
|
||||
} catch (e) { /* keep empty */ }
|
||||
// 监督员 (走 /sponsorAssigns 端点, 不在 loadProject 里)
|
||||
} catch (e) {
|
||||
console.error('[project-detail] load failed', e)
|
||||
row.value = {}
|
||||
@@ -173,8 +197,68 @@ async function loadAnnouncements() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 已分配执行方 (服务公司) =====================
|
||||
/**
|
||||
* 数据源同 manager/projects/edit/{id} 的 loadAssigns:
|
||||
* 1) GET /business/project/{id}/assigns 拿原始分配行 (assignId, execUserId, sessions, amount)
|
||||
* 2) 对每个 execUserId 调 listExecutorOrgs({ userId }) 补 org_name
|
||||
* (后端 SQL 已限定 parent_user_id IS NULL, 自动取 MAIN 账号对应的执行公司)
|
||||
* N+1 但项目分配数一般 < 10, 简单可控; 真要优化再加聚合 JOIN 接口
|
||||
*/
|
||||
async function loadAssigns() {
|
||||
try {
|
||||
const resp = await request({ url: `/business/project/${projectId.value}/assigns`, method: 'get' })
|
||||
const list = (resp && resp.data) || []
|
||||
if (!Array.isArray(list) || !list.length) { assigns.value = []; return }
|
||||
// 并发查每个执行方公司名
|
||||
const enriched = await Promise.all(list.map(async (a) => {
|
||||
let orgName = '-'
|
||||
try {
|
||||
const opts = await listExecutorOrgs({ userId: a.execUserId })
|
||||
const org = (opts && opts.data && opts.data[0]) || null
|
||||
if (org && org.orgName) orgName = org.orgName
|
||||
} catch (e) { /* 单条失败不影响其它, 默认显示 '-' */ }
|
||||
return {
|
||||
assignId: a.assignId,
|
||||
execUserId: a.execUserId,
|
||||
orgName,
|
||||
sessions: a.sessions || 0,
|
||||
amount: a.amount || 0
|
||||
}
|
||||
}))
|
||||
assigns.value = enriched
|
||||
} catch (e) {
|
||||
console.error('[project-detail] assigns load failed', e)
|
||||
assigns.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 监督员 (sponsor 视角的监察员) =====================
|
||||
// 数据源: GET /business/project/{id}/sponsorAssigns (返回 [{monitorUserId, monitorUserName, ...}])
|
||||
// 注: 不再读 biz_project.monitors — 该字段在 biz_project 表不存在 (历史 bug)
|
||||
async function loadMonitors() {
|
||||
if (!projectId.value) return
|
||||
try {
|
||||
const resp = await request({ url: `/business/project/${projectId.value}/sponsorAssigns`, method: 'get' })
|
||||
const list = (resp && resp.data) || []
|
||||
monitors.value = (Array.isArray(list) ? list : []).map(x => ({
|
||||
userId: x.monitorUserId,
|
||||
userName: x.monitorUserName || ('(userId=' + x.monitorUserId + ')')
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('[project-detail] monitors load failed', e)
|
||||
monitors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 导航 =====================
|
||||
function goBack() { router.push('/manager/projects') }
|
||||
// 按角色回退: sponsor → 我的项目, manager → 项目管理, admin → 项目管理
|
||||
function goBack() {
|
||||
const role = userStore?.role || userStore?.user?.roleType
|
||||
if (role === 'sponsor') router.push('/sponsor/my-projects')
|
||||
else if (role === 'executor') router.push('/executor/projects')
|
||||
else router.push('/manager/projects')
|
||||
}
|
||||
function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) }
|
||||
|
||||
// ===================== 预览 =====================
|
||||
@@ -188,6 +272,7 @@ function openPreview(url, title) {
|
||||
// ===================== 初始化 =====================
|
||||
onMounted(async () => {
|
||||
await loadProject()
|
||||
await Promise.all([loadAssigns(), loadMonitors()])
|
||||
await loadAnnouncements()
|
||||
})
|
||||
</script>
|
||||
@@ -235,6 +320,9 @@ onMounted(async () => {
|
||||
.role-labor-table td { border-right: 1px solid #ebeef5; }
|
||||
.role-labor-table th:last-child,
|
||||
.role-labor-table td:last-child { border-right: none; }
|
||||
/* 服务公司表格 (el-table in form): 紧凑, 不要撑满整张卡片 */
|
||||
.assign-table-el { width: auto; max-width: 560px; }
|
||||
.assign-table-el :deep(.el-table__empty-block) { min-height: 48px; }
|
||||
.info-table th { background: #fafafa; padding: 10px 12px; text-align: left; color: #1a1a1a; font-weight: 600; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
|
||||
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; }
|
||||
.empty-row { text-align: center; color: #c0c4cc; }
|
||||
|
||||
@@ -294,7 +294,7 @@ async function submitSettle() {
|
||||
function proxyUrl(url) {
|
||||
if (!url) return url
|
||||
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||
return '/dev-api/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -337,6 +337,14 @@ async function onBatchDelete() {
|
||||
|
||||
function reset() { q.value = { sponsorAdminUserIds:[], execAdminUserIds:[], projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', managerScore:'' }; page.pageNum = 1; load() }
|
||||
|
||||
// 读 URL query 写入 q (Workbench KPI 卡跳转时带 ?isFinished=1 / ?isSettled=Y 等, 让列表页自动应用筛选)
|
||||
// 注意: 只读不改 URL — 列表页内 reset()/search 不反向写 URL, Workbench 是 source of truth
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
|
||||
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
|
||||
}
|
||||
|
||||
// 导出
|
||||
function exportProjects() { ElMessage.info('导出项目功能开发中') }
|
||||
function exportEval() { ElMessage.info('项目评价导出功能开发中') }
|
||||
@@ -673,10 +681,11 @@ const previewUrl = ref('')
|
||||
const previewTitle = ref('附件预览')
|
||||
function openPreview(url, title) { previewUrl.value = url; previewTitle.value = title || '附件预览'; previewOpen.value = true }
|
||||
|
||||
onMounted(load)
|
||||
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载)
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载, query 变了也重新应用筛选)
|
||||
watch(() => route.fullPath, (newPath, oldPath) => {
|
||||
if (newPath === '/manager/projects') {
|
||||
readQueryFromRoute()
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,57 +2,47 @@
|
||||
<div class="page-card manager-workbench">
|
||||
<div class="breadcrumb">首页 / 工作台</div>
|
||||
|
||||
<!-- 5 个 KPI 卡 -->
|
||||
<!-- 5 个 KPI 卡 (点击跳到列表页 + 自动应用筛选, 字段名跟 Projects.vue / Meetings.vue q 一致) -->
|
||||
<div class="stats-grid">
|
||||
<router-link class="stat-card" to="/manager/projects">
|
||||
<div class="stat-label">项目总数量</div>
|
||||
<div class="stat-value">{{ stats.totalProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/meetings">
|
||||
<router-link class="stat-card" to="/manager/projects?isSettled=Y">
|
||||
<div class="stat-label">已结算数量</div>
|
||||
<div class="stat-value">{{ stats.settledProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/projects">
|
||||
<router-link class="stat-card" to="/manager/projects?isFinished=1">
|
||||
<div class="stat-label">已结题数量</div>
|
||||
<div class="stat-value">{{ stats.completedProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/meetings">
|
||||
<router-link class="stat-card" to="/manager/meetings?currentStage=RUNNING">
|
||||
<div class="stat-label">已执行会议</div>
|
||||
<div class="stat-value">{{ stats.executedMeetings }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/meetings">
|
||||
<router-link class="stat-card" to="/manager/meetings?currentStage=NOT_STARTED">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">消息通知<a class="more" href="javascript:void(0)">更多 →</a></h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" v-for="n in notices" :key="n.id" :class="{ read: n.read }">
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">{{ n.title }}</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="notices.length === 0" class="empty">暂无消息</li>
|
||||
</ul>
|
||||
<h2 class="section-title">消息通知<a class="more" @click.prevent="$router.push('/manager/messages')">更多 →</a></h2>
|
||||
<NoticeList :limit="50" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { bizList } from '@/api/public'
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
|
||||
const stats = ref({
|
||||
totalProjects: 0, settledProjects: 0, completedProjects: 0,
|
||||
executedMeetings: 0, pendingMeetings: 0
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
@@ -65,8 +55,8 @@ async function loadStats() {
|
||||
// 会议总数量
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
const meetingTotal = ms.total || ms.data?.total || 0
|
||||
// 已执行会议
|
||||
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, status: '已执行' })
|
||||
// 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交)
|
||||
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' })
|
||||
stats.value.executedMeetings = em.total || em.data?.total || 0
|
||||
stats.value.pendingMeetings = meetingTotal - stats.value.executedMeetings
|
||||
// 已结算项目
|
||||
@@ -77,22 +67,7 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotices() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 }); const list = r.rows
|
||||
notices.value = list.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
time: n.time,
|
||||
read: n.read
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('loadNotices failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadStats(); loadNotices() })
|
||||
onMounted(() => { loadStats() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -144,17 +119,6 @@ onMounted(() => { loadStats(); loadNotices() })
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; cursor: pointer; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; }
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.empty { padding: 24px; text-align: center; color: #909399; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,41 +5,36 @@
|
||||
|
||||
<!-- el-form 包裹 + label-width 110px (跟原型一致) -->
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<!-- 2 列布局 (跟原型 .form-grid 一致: grid-auto-flow: column 让左 6 右 5 对齐) -->
|
||||
<!-- 只读快照 (直接显示, 放上面) -->
|
||||
<div class="form-grid">
|
||||
<!-- 左列 6 项只读快照 (来自 biz_project 派生) -->
|
||||
<el-form-item label="项目编号">
|
||||
<span class="form-value code">{{ snap.projectNo }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<span class="form-value">{{ snap.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="总场次/总期数">
|
||||
<span class="form-value">{{ snap.totalSessions }}</span>
|
||||
<el-form-item label="会议名称">
|
||||
<span class="form-value">{{ form.meetingName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="支持单位">
|
||||
<span class="form-value">{{ snap.sponsorOrgName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<span class="form-value">{{ snap.createTime }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建人员">
|
||||
<span class="form-value">{{ snap.createUserName }}</span>
|
||||
<el-form-item label="总场次/总期数">
|
||||
<span class="form-value">{{ snap.totalSessions }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 右列 5 项: 1 只读 (会议名称 = 项目名) + 4 输入 -->
|
||||
<el-form-item label="会议名称">
|
||||
<span class="form-value">{{ form.meetingName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="期数" prop="periodNo">
|
||||
<el-input v-model="form.periodNo" placeholder="请填写第几期" />
|
||||
</el-form-item>
|
||||
<!-- 待填写 (放下面) -->
|
||||
<div class="form-grid form-grid-inputs">
|
||||
<el-form-item label="会议开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议结束时间" prop="endTime">
|
||||
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择结束时间" />
|
||||
</el-form-item>
|
||||
<el-form-item label="期数" prop="periodNo">
|
||||
<el-input v-model="form.periodNo" placeholder="请填写第几期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
@@ -87,14 +82,12 @@ const pageTitle = computed(() => ({ new: '新建会议', edit: '修改会议', c
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
// ========== 项目只读快照 (左列 6 项) ==========
|
||||
// ========== 项目只读快照 (左列 4 项) ==========
|
||||
const snap = reactive({
|
||||
projectNo: '',
|
||||
projectName: '',
|
||||
totalSessions: '',
|
||||
sponsorOrgName: '',
|
||||
createTime: '',
|
||||
createUserName: ''
|
||||
sponsorOrgName: ''
|
||||
})
|
||||
|
||||
// ========== 表单 (右列 5 项: meetingName 只读 + 4 输入) ==========
|
||||
@@ -125,8 +118,6 @@ async function loadProjectSnapshot(pid) {
|
||||
snap.projectName = d.projectName || ''
|
||||
snap.totalSessions = d.totalSessions ?? ''
|
||||
snap.sponsorOrgName = d.sponsorOrgName || ''
|
||||
snap.createTime = d.createTime || ''
|
||||
snap.createUserName = d.createUserName || d.createBy || ''
|
||||
} catch (e) { /* 静默, 不阻塞主流程 */ }
|
||||
}
|
||||
|
||||
@@ -147,7 +138,7 @@ async function loadMeeting(mid) {
|
||||
// 左列快照 projectNo/projectName 用 meeting 冗余字段 (即使 project_id 为 NULL 也能显示)
|
||||
snap.projectNo = d.projectNo || ''
|
||||
snap.projectName = d.projectName || ''
|
||||
// 总场次/支持单位/创建时间/创建人员 优先用 URL projectId, 其次 meeting.projectId
|
||||
// 总场次/支持单位 优先用 URL projectId, 其次 meeting.projectId
|
||||
const pid = urlProjectId || d.projectId
|
||||
if (pid) {
|
||||
await loadProjectSnapshot(pid)
|
||||
@@ -180,7 +171,8 @@ async function onSave() {
|
||||
await bizAdd('meeting', payload, { __silentError: true })
|
||||
ElMessage.success(mode.value === 'copy' ? '复制成功' : '会议创建成功')
|
||||
}
|
||||
goBack()
|
||||
// 建会/修改/复制成功 → 跳会议列表 (不再返回项目列表, executor/manager 一致)
|
||||
router.push(meetingsBase.value)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || (mode.value === 'edit' ? '修改失败' : '保存失败'))
|
||||
} finally {
|
||||
@@ -234,15 +226,19 @@ onMounted(async () => {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* === 2 列布局 (跟原型 .form-grid 一致, grid-auto-flow: column 让左 6 右 5 对齐) === */
|
||||
/* === 2 列布局 (只读区/填写区 各一个 grid, 行优先流) === */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(6, auto);
|
||||
gap: 20px 32px;
|
||||
max-width: 900px;
|
||||
}
|
||||
/* 填写区: 与上方只读区分隔 */
|
||||
.form-grid-inputs {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
/* grid 内的 el-form-item 不再加默认 margin-bottom (由 grid 控制间距) */
|
||||
.form-grid :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -23,10 +23,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="已完结" value="已完结" />
|
||||
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="请输入备注" clearable style="width:140px" /></el-form-item>
|
||||
@@ -36,16 +33,15 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- ========== 批量按钮 (admin/manager 共用: 批量提交 + 批量删除) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" :disabled="!selectedIds.length" @click="onBatchSubmit">批量提交</el-button>
|
||||
<!-- ========== 批量按钮 (admin/manager 共用: 批量提交 + 批量删除; sponsor 隐藏) ========== -->
|
||||
<div v-if="isRole('admin', 'manager')" class="batch-bar">
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="onBatchDelete">批量删除</el-button>
|
||||
<span v-if="selectedIds.length" class="filter-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 (与 People.vue 风格一致, 无 page-card 包裹) ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column v-if="isRole('admin', 'manager')" type="selection" width="44" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
|
||||
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
@@ -60,19 +56,21 @@
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', 'stage-' + stageClass(row.currentStage)]">{{ row.currentStage }}</span>
|
||||
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel(roleSegment, row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onSubmit(row)">提交</el-button>
|
||||
<el-button link type="primary" @click="onCopy(row)">复制</el-button>
|
||||
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onCopy(row)">复制</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager') && canSettleRow(row)" link type="success" @click="onSettle(row)">结算</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager') && canFinishRow(row)" link type="primary" @click="onFinish(row)">完结</el-button>
|
||||
<el-button v-if="isRole('admin', 'manager')" link type="danger" @click="onDelete(row)">删除</el-button>
|
||||
<el-button v-if="isRole('sponsor') && derivePhysicalStage(row) === 'AWAITING_SUPERVISION'" link type="warning" @click="onApproval(row)">审批</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -90,20 +88,46 @@
|
||||
|
||||
<!-- 修改 / 复制: 按角色跳 admin-meetings-new / manager-meetings-new (MeetingNew.vue 公共页) -->
|
||||
<!-- 查看: 按角色跳 /admin/meetings/detail/:id 或 /manager/meetings/detail/:id -->
|
||||
|
||||
<!-- 支持方(监察员) 审批 dialog: 仅 sponsor 且 current_stage=AWAITING_SUPERVISION 时可见 -->
|
||||
<el-dialog v-model="approvalOpen" title="支持方审批" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="会议名称"><span>{{ approvalRow.meetingName }}</span></el-form-item>
|
||||
<el-form-item label="审批结果">
|
||||
<el-radio-group v-model="approvalForm.approved">
|
||||
<el-radio :label="true">通过</el-radio>
|
||||
<el-radio :label="false">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审批意见">
|
||||
<el-input v-model="approvalForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="退回时意见必填, 将通知执行方整改" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="approvalOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="approvalSaving" @click="onApprovalConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { bizList, bizUpdate, bizDelete } from '@/api/public'
|
||||
import { bizList, bizDelete } from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import { stageLabel, STAGE_OPTIONS, derivePhysicalStage, stageClass } from '@/utils/meetingStage'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ========== 角色感知 (admin/manager 共用此页, 按 URL 第 1 段识别) ==========
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const roleSegment = computed(() => route.path.split('/')[1]) // 'admin' | 'manager'
|
||||
const isAdmin = computed(() => roleSegment.value === 'admin')
|
||||
const roleSegment = computed(() => route.path.split('/')[1]) // 'admin' | 'manager' | 'sponsor'
|
||||
// 角色判断工具: 传一个或多个角色, 命中返回 true. 后续所有按角色显隐/分支统一走这里
|
||||
function isRole(...roles) {
|
||||
return roles.includes(roleSegment.value)
|
||||
}
|
||||
const isAdmin = computed(() => isRole('admin'))
|
||||
// 面包屑: admin 多一层 "项目管理" (跟其它 admin 页对齐)
|
||||
const breadcrumb = computed(() =>
|
||||
isAdmin.value ? '首页 / 项目管理 / 会议管理' : '首页 / 会议管理'
|
||||
@@ -137,17 +161,6 @@ function fmtTime(d) {
|
||||
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
// ========== 工具: 当前阶段颜色 class ==========
|
||||
function stageClass(s) {
|
||||
if (s === '未执行') return 'pending'
|
||||
if (s === '执行中') return 'running'
|
||||
if (s === '已完结' || s === '已执行') return 'done'
|
||||
if (s === '待审核') return 'reviewing'
|
||||
if (s === '待结算') return 'waiting'
|
||||
if (s === '冻结中') return 'frozen'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// ========== 加载 ==========
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -168,6 +181,13 @@ function reset() {
|
||||
load()
|
||||
}
|
||||
|
||||
// 读 URL query 写入 q (Workbench/Overview/Home KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED, 让列表页自动应用筛选)
|
||||
// 只读不改 URL — KPI 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
|
||||
}
|
||||
|
||||
// ========== 路由跳转 ==========
|
||||
// 查看: 按角色跳 detail 页 (admin/manager 走各自路由, 复用 manager/MeetingDetail.vue)
|
||||
function viewDetail(row) {
|
||||
@@ -181,30 +201,6 @@ function onCopy(row) {
|
||||
router.push({ name: newRouteName.value, query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
|
||||
}
|
||||
|
||||
// ========== 提交 (单条) ==========
|
||||
async function onSubmit(row) {
|
||||
try { await ElMessageBox.confirm(`确定提交会议「${row.meetingName}」吗?`, '提交确认', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
await bizUpdate('meeting', { meetingId: row.meetingId, currentStage: '执行中' }, { __silentError: true })
|
||||
ElMessage.success('提交成功')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '提交失败') }
|
||||
}
|
||||
|
||||
// ========== 批量提交 ==========
|
||||
async function onBatchSubmit() {
|
||||
if (!selectedIds.value.length) return
|
||||
try { await ElMessageBox.confirm(`确定对选中的 ${selectedIds.value.length} 个会议执行批量提交吗?`, '批量提交', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
// 逐条更新 (无批量接口)
|
||||
for (const id of selectedIds.value) {
|
||||
await bizUpdate('meeting', { meetingId: id, currentStage: '执行中' }, { __silentError: true })
|
||||
}
|
||||
ElMessage.success('批量提交成功')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量提交失败') }
|
||||
}
|
||||
|
||||
// ========== 删除会议 (软删除, 后端强校验 admin/manager) ==========
|
||||
async function onDelete(row) {
|
||||
try {
|
||||
@@ -244,7 +240,73 @@ async function onBatchDelete() {
|
||||
} catch (e) { ElMessage.error(e?.msg || '批量删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
// ========== 结算 (合规/管理员 手动点击, 后端强校验 role) ==========
|
||||
function isOneVal(v) { return v === 1 || v === '1' || v === true }
|
||||
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (材料+凭证都 APPROVED 且未结算)
|
||||
function canSettleRow(row) {
|
||||
return row.materialAuditStage === 'APPROVED' && row.voucherAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
|
||||
}
|
||||
async function onSettle(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认结算会议「${row.meetingName}」?`, '结算确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/business/meeting/${row.meetingId}/settle`, null, { __silentError: true })
|
||||
ElMessage.success('已结算')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '结算失败') }
|
||||
}
|
||||
// 列表完结按钮显隐: 已结算 且 未完结
|
||||
function canFinishRow(row) {
|
||||
return isOneVal(row.isSettled) && !isOneVal(row.isFinished)
|
||||
}
|
||||
async function onFinish(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认完结会议「${row.meetingName}」? 完结后流程终止`, '完结确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/business/meeting/${row.meetingId}/finish`, null, { __silentError: true })
|
||||
ElMessage.success('已完结')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '完结失败') }
|
||||
}
|
||||
|
||||
// ========== 支持方(监察员) 审批 ==========
|
||||
const approvalOpen = ref(false)
|
||||
const approvalSaving = ref(false)
|
||||
const approvalRow = ref({})
|
||||
const approvalForm = reactive({ approved: true, opinion: '' })
|
||||
|
||||
function onApproval(row) {
|
||||
approvalRow.value = row
|
||||
approvalForm.approved = true
|
||||
approvalForm.opinion = ''
|
||||
approvalOpen.value = true
|
||||
}
|
||||
|
||||
async function onApprovalConfirm() {
|
||||
if (!approvalForm.approved && !approvalForm.opinion.trim()) {
|
||||
ElMessage.warning('退回时意见不能为空')
|
||||
return
|
||||
}
|
||||
approvalSaving.value = true
|
||||
try {
|
||||
await request.post(`/business/meeting/${approvalRow.value.meetingId}/audit-supervision`, {
|
||||
auditType: 'MATERIAL',
|
||||
approved: approvalForm.approved,
|
||||
opinion: approvalForm.opinion
|
||||
})
|
||||
ElMessage.success(approvalForm.approved ? '已通过 → 审核通过' : '已退回 → 待整改')
|
||||
approvalOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '审批失败')
|
||||
} finally {
|
||||
approvalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { readQueryFromRoute(); load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
共享消息通知页 (5 角色复用)
|
||||
------------------------------------------------------------
|
||||
/admin/messages /manager/messages /doctor/messages
|
||||
/executor/messages /sponsor/messages 全部指向此文件.
|
||||
没有任何 role 区分, 列表数据由后端按当前登录用户自动隔离.
|
||||
-->
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 消息通知</div>
|
||||
<NoticeList :limit="50" :show-category="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card {
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -81,9 +81,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
|
||||
@@ -126,9 +126,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
@@ -166,7 +164,7 @@ async function loadSpecialPlans() {
|
||||
}
|
||||
|
||||
function openPlan(id) {
|
||||
window.open(`/#/special-plan/${id}`, '_blank')
|
||||
window.open(`${import.meta.env.BASE_URL}#/special-plan/${id}`, '_blank')
|
||||
}
|
||||
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
|
||||
const loggedIn = computed(() => !!userStore.token)
|
||||
|
||||
@@ -52,13 +52,20 @@
|
||||
<div style="padding: 40px 0; text-align: center; color: #9ca3af; font-size: 13px;">暂无数据</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="notice-header">
|
||||
<span class="notice-header-no">序号</span>
|
||||
<span class="notice-header-title">项目</span>
|
||||
<span class="notice-header-date">开始日期</span>
|
||||
<span class="notice-header-end">截止日期</span>
|
||||
</div>
|
||||
<div v-for="(n, i) in pageItems" :key="n.annId || i" class="notice-item" @click="goDetail(n)">
|
||||
<span class="notice-item-no">{{ String((currentPage - 1) * PAGE_SIZE + i + 1).padStart(3, '0') }}</span>
|
||||
<span class="notice-date">{{ fmtDate(n.date) }}</span>
|
||||
<span class="notice-title">
|
||||
<span v-if="n.projectNo" style="color:#909399;margin-right:8px;font-size:13px">【{{ n.projectNo }}】</span>
|
||||
{{ n.title }}
|
||||
</span>
|
||||
<span class="notice-date">{{ fmtDate(n.date) }}</span>
|
||||
<span class="notice-end">{{ fmtDate(n.endTime) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -111,9 +118,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
@@ -168,7 +173,8 @@ async function load() {
|
||||
}
|
||||
return {
|
||||
annId: r.projectId,
|
||||
date: r.publishTime,
|
||||
date: r.startTime,
|
||||
endTime: r.endTime,
|
||||
type,
|
||||
projectNo: r.projectNo || '',
|
||||
projectName: r.projectName,
|
||||
@@ -257,7 +263,6 @@ function fmtDate(d) {
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function handleScroll() { isScrolled.value = window.scrollY > 10 }
|
||||
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
|
||||
@@ -304,7 +309,11 @@ a { color: inherit; text-decoration: none; }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.logo:hover { opacity: 0.85; }
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
@@ -393,6 +402,21 @@ a { color: inherit; text-decoration: none; }
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 已登录: 右上角人员信息 (用户名 + 头像图标) — 深色 navbar 上必须白色 */
|
||||
.user-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.user-link:hover { opacity: 0.85; }
|
||||
.user-link svg { stroke: currentColor; }
|
||||
|
||||
.login-btn {
|
||||
padding: 7px 20px;
|
||||
background: #fff;
|
||||
@@ -408,6 +432,20 @@ a { color: inherit; text-decoration: none; }
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ========== 页面骨架 (sticky footer: 内容少时底部仍贴屏幕底) ========== */
|
||||
.pub-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main.container {
|
||||
flex: 1 0 auto;
|
||||
/* flex item 的 width:auto 会塌成 fit-content (margin:0 auto 覆盖 align-items:stretch),
|
||||
导致版心宽度丢失; 显式 width:100% 配合 max-width:1354px + margin:auto 恢复居中版心 */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== 主体容器 ========== */
|
||||
.container {
|
||||
max-width: 1354px;
|
||||
@@ -528,11 +566,57 @@ a { color: inherit; text-decoration: none; }
|
||||
color: #1f2937;
|
||||
line-height: 1.7;
|
||||
transition: color 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 表头: 与 notice-item 相同的 flex + gap + 列宽, 保证列对齐 */
|
||||
.notice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.notice-header-no {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice-header-date {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.notice-header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-header-end {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
/* 右侧截止时间: 等宽数字 + 灰色, 与日期列同宽对齐 */
|
||||
.notice-end {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.notice-item-no {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
|
||||
@@ -148,9 +148,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
@@ -324,7 +322,7 @@ function isImg(url) {
|
||||
function proxyUrl(url) {
|
||||
if (!url) return url
|
||||
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||
return '/dev-api/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -102,9 +102,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
@@ -172,7 +170,7 @@ function isImg(url) {
|
||||
function proxyUrl(url) {
|
||||
if (!url) return url
|
||||
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||
return '/dev-api/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -8,95 +8,82 @@
|
||||
<div class="page-card sponsor-home">
|
||||
<div class="breadcrumb">首页</div>
|
||||
|
||||
<!-- KPI: 5 个数字面板, 1 行展示 -->
|
||||
<!-- KPI: 5 个数字面板, 1 行展示 (跳转 + 自动应用筛选, 字段名跟 Projects.vue / Meetings.vue q 一致) -->
|
||||
<div class="stats-grid stats-grid-5">
|
||||
<router-link class="stat-card" to="/sponsor/projects">
|
||||
<div class="stat-label">项目总数量</div>
|
||||
<div class="stat-value">{{ stats.totalProjects }}</div>
|
||||
<div class="stat-desc">查看全部项目</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/sponsor/meetings?isSettled=Y">
|
||||
<div class="stat-label">已结算的会议</div>
|
||||
<div class="stat-value">{{ stats.settledMeetings }}</div>
|
||||
<div class="stat-desc">查看已结算会议</div>
|
||||
<router-link class="stat-card" to="/sponsor/projects?isSettled=Y">
|
||||
<div class="stat-label">已结算的项目</div>
|
||||
<div class="stat-value">{{ stats.settledProjects }}</div>
|
||||
<div class="stat-desc">查看已结算项目</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/sponsor/projects?isFinished=1">
|
||||
<div class="stat-label">已结题的项目</div>
|
||||
<div class="stat-value">{{ stats.completedProjects }}</div>
|
||||
<div class="stat-desc">查看已结题项目</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/sponsor/meetings?currentStage=已执行">
|
||||
<router-link class="stat-card" to="/sponsor/meetings?currentStage=RUNNING">
|
||||
<div class="stat-label">已执行会议</div>
|
||||
<div class="stat-value">{{ stats.executedMeetings }}</div>
|
||||
<div class="stat-desc">查看已执行会议</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/sponsor/meetings?currentStage=未执行">
|
||||
<router-link class="stat-card" to="/sponsor/meetings?currentStage=NOT_STARTED">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
<div class="stat-desc">查看未执行会议</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">消息通知<router-link class="more" to="/sponsor/messages">更多 →</router-link></h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" v-for="n in notices" :key="n.title" :class="{ read: n.read }">
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">{{ n.title }}</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="notices.length === 0" class="empty">暂无消息</li>
|
||||
</ul>
|
||||
<NoticeList :limit="50" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
import { bizList } from '@/api/public'
|
||||
import NoticeList from '@/components/NoticeList.vue'
|
||||
|
||||
// 字段名跟 Projects.vue / Meetings.vue q 一致, 之前 3/5 卡 stats 不更新或用错字段名 (status 后端不认, settledMeetings 误指项目级 isSettled)
|
||||
const stats = ref({
|
||||
totalProjects: 0,
|
||||
settledMeetings: 0,
|
||||
completedProjects: 0,
|
||||
executedMeetings: 0,
|
||||
pendingMeetings: 0
|
||||
settledProjects: 0, // 项目级 isSettled=Y
|
||||
completedProjects: 0, // 项目级 isFinished=1
|
||||
executedMeetings: 0, // 会议 currentStage=RUNNING (字段名跟 Meetings.vue q 一致, 之前误用 status= 后端不认, 数字一直是错的)
|
||||
pendingMeetings: 0 // 总会议 - 已执行
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
// 项目总数量 (跟 manager/executor 一样调普通 list, 后续若需要按 sponsor 隔离再换 sponsorList 接口)
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || 0
|
||||
stats.value.totalProjects = ps.total || ps.data?.total || 0
|
||||
// 已结算项目
|
||||
const sp = await bizList('project', { pageNum: 1, pageSize: 1, isSettled: 'Y' })
|
||||
stats.value.settledProjects = sp.total || sp.data?.total || 0
|
||||
// 已结题项目
|
||||
const cp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' })
|
||||
stats.value.completedProjects = cp.total || cp.data?.total || 0
|
||||
// 会议总数量
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.executedMeetings = ms.total || 0
|
||||
const meetingTotal = ms.total || ms.data?.total || 0
|
||||
// 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交)
|
||||
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' })
|
||||
stats.value.executedMeetings = em.total || em.data?.total || 0
|
||||
stats.value.pendingMeetings = Math.max(0, meetingTotal - stats.value.executedMeetings)
|
||||
} catch (e) {
|
||||
console.warn('loadStats failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotices() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 }); const list = r.rows
|
||||
notices.value = list.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
time: n.time,
|
||||
read: n.read
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('loadNotices failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadNotices()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -146,25 +133,8 @@ onMounted(() => {
|
||||
line-height: 1;
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; cursor: pointer; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item {
|
||||
padding: 12px 0; border-bottom: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; }
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text {
|
||||
font-size: 12px; color: #595959;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px;
|
||||
}
|
||||
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
|
||||
.empty { padding: 24px; text-align: center; color: #909399; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid-3 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
@@ -30,10 +30,14 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" clearable placeholder="全部" style="width:120px">
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-select v-model="q.currentStage" clearable placeholder="全部" style="width:140px">
|
||||
<el-option label="未执行" value="NOT_STARTED" />
|
||||
<el-option label="执行中" value="RUNNING" />
|
||||
<el-option label="待合规审核" value="AWAITING_COMPLIANCE" />
|
||||
<el-option label="待支持方审核" value="AWAITING_SPONSOR" />
|
||||
<el-option label="待结算" value="AWAITING_SETTLEMENT" />
|
||||
<el-option label="已完结" value="SETTLED" />
|
||||
<el-option label="冻结中" value="FROZEN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
@@ -74,7 +78,7 @@
|
||||
<el-button size="small" link type="primary" @click="onAudit(row)">审核</el-button>
|
||||
<el-button size="small" link @click="onDownload(row, 'labor')">劳务下载</el-button>
|
||||
<el-button size="small" link @click="onDownload(row, 'meeting')">会务下载</el-button>
|
||||
<el-button size="small" link type="danger" :disabled="row.currentStage !== '冻结中'" @click="onUnfreeze(row)">解冻</el-button>
|
||||
<el-button size="small" link type="danger" :disabled="row.currentStage !== 'FROZEN'" @click="onUnfreeze(row)">解冻</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -160,6 +164,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
@@ -185,6 +190,14 @@ const unfreezeOpen = ref(false)
|
||||
const unfreezeRow = ref(null)
|
||||
const unfreezeSubmitting = ref(false)
|
||||
|
||||
// 读 URL query 写入 q (Home KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED, 让列表页自动应用筛选)
|
||||
// 只读不改 URL — Home 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
const route = useRoute()
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.currentStage != null && q2.currentStage !== '') q.currentStage = String(q2.currentStage)
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -253,12 +266,12 @@ async function submitAudit() {
|
||||
}
|
||||
|
||||
function canSettle(row) {
|
||||
return row && ['未执行', '执行中'].includes(row.currentStage)
|
||||
return row && ['NOT_STARTED', 'RUNNING'].includes(row.currentStage)
|
||||
}
|
||||
function onSettle(row) {
|
||||
ElMessageBox.confirm(`确定发起「${row.meetingName}」结算吗?`, '结算', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await bizUpdate('meeting', { meetingId: row.meetingId, currentStage: '已完结' })
|
||||
await bizUpdate('meeting', { meetingId: row.meetingId, currentStage: 'SETTLED' })
|
||||
ElMessage.success('已发起结算')
|
||||
loadList()
|
||||
}).catch(() => {})
|
||||
@@ -278,7 +291,7 @@ async function submitUnfreeze() {
|
||||
if (!unfreezeRow.value) return
|
||||
unfreezeSubmitting.value = true
|
||||
try {
|
||||
await bizUpdate('meeting', { meetingId: unfreezeRow.value.meetingId, currentStage: '执行中' })
|
||||
await bizUpdate('meeting', { meetingId: unfreezeRow.value.meetingId, currentStage: 'RUNNING' })
|
||||
ElMessage.success('解冻成功')
|
||||
unfreezeOpen.value = false
|
||||
loadList()
|
||||
@@ -304,7 +317,7 @@ function calcRemain(row) {
|
||||
return h > 24 ? `${Math.floor(h/24)}天${h%24}小时` : `${h}小时`
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
onMounted(() => { readQueryFromRoute(); loadList() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算">
|
||||
<el-select v-model="q.isSettled" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已结算" value="Y" />
|
||||
<el-option label="未结算" value="N" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
@@ -206,13 +212,14 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { bizList, bizAdd, bizUpdate } from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const q = reactive({ projectNo: '', projectName: '', projectForm: '', isFinished: '' })
|
||||
const q = reactive({ projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const selected = ref([])
|
||||
|
||||
@@ -257,8 +264,17 @@ async function loadPersons() {
|
||||
} catch (e) { personList.value = [] }
|
||||
}
|
||||
|
||||
// 读 URL query 写入 q (Home KPI 卡跳转时带 ?isSettled=Y / ?isFinished=1, 让列表页自动应用筛选)
|
||||
// 只读不改 URL — Home 是 source of truth, 列表页内 reset()/search 不反向写 URL
|
||||
const route = useRoute()
|
||||
function readQueryFromRoute() {
|
||||
const q2 = route.query
|
||||
if (q2.isFinished != null && q2.isFinished !== '') q.isFinished = String(q2.isFinished)
|
||||
if (q2.isSettled != null && q2.isSettled !== '') q.isSettled = String(q2.isSettled)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', projectName: '', projectForm: '', isFinished: '' })
|
||||
Object.assign(q, { projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
@@ -427,7 +443,7 @@ async function submitAssign() {
|
||||
} catch (e) { ElMessage.error(e?.msg || '分配失败') }
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadPersons() })
|
||||
onMounted(() => { readQueryFromRoute(); load(); loadPersons() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="48" :selectable="(row) => row.userId !== store.user?.userId" />
|
||||
<el-table-column prop="account" label="账号" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="name" label="姓名" width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="orgName" label="所属公司" min-width="180" show-overflow-tooltip />
|
||||
|
||||
@@ -41,12 +41,14 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<!-- SUB 子账号: 仅查看被分配的项目, 不参与重新分配, 隐藏"项目分配"按钮 -->
|
||||
<div v-if="!isSub" class="toolbar">
|
||||
<el-button type="primary" @click="onAssignBatch">项目分配</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<!-- SUB 子账号无需勾选 (不参与分配) -->
|
||||
<el-table-column v-if="!isSub" type="selection" width="40" />
|
||||
<el-table-column label="项目编号" width="160">
|
||||
<template #default="{ row }">
|
||||
<a class="link">{{ row.projectNo }}</a>
|
||||
@@ -88,7 +90,8 @@
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openSingleScore(row)">项目评价</el-button>
|
||||
<el-button link type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<!-- SUB 子账号仅查看, 不参与分配 -->
|
||||
<el-button v-if="!isSub" link type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -140,39 +143,38 @@
|
||||
</div>
|
||||
<el-form :model="assignForm" label-width="100px" class="modal-form">
|
||||
<el-form-item label="分配给监察员">
|
||||
<el-input v-model="assignForm.monitorInput" placeholder="输入监察员姓名筛选" clearable style="width: 240px" @input="filterMonitors" />
|
||||
</el-form-item>
|
||||
<el-form-item label="候选监察员">
|
||||
<div class="chip-list">
|
||||
<el-tag v-for="m in candidateMonitors" :key="m.userId" class="chip" type="info" effect="plain" @click="pickMonitor(m)">{{ m.name }}</el-tag>
|
||||
<span v-if="!candidateMonitors.length" style="color:#909399">无可分配监察员</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配说明">
|
||||
<el-input v-model="assignForm.assignDesc" type="textarea" :rows="3" placeholder="请输入分配说明" />
|
||||
</el-form-item>
|
||||
<el-form-item label="监察要点">
|
||||
<el-input v-model="assignForm.assignPoints" type="textarea" :rows="3" placeholder="请输入监察要点" />
|
||||
<el-select v-model="assignForm.monitorUserIds" multiple collapse-tags collapse-tags-tooltip filterable placeholder="请选择监察员 (姓名 / 手机号, 可多选)" style="width: 100%">
|
||||
<el-option
|
||||
v-for="m in allMonitors"
|
||||
:key="m.userId"
|
||||
:label="m.name + ' (' + m.phone + ')'"
|
||||
:value="m.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="assignSubmitting" @click="saveAssign">确定</el-button>
|
||||
<el-button type="primary" :loading="assignSubmitting" :disabled="!assignForm.monitorUserIds.length" @click="saveAssign">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { listSponsorProjects, rateProject, sponsorAssignProject, sponsorAssignBatch } from '@/api/business/project'
|
||||
import { listSponsorProjects, rateProject, sponsorAssignProject, sponsorAssignBatch, getSponsorAssigns } from '@/api/business/project'
|
||||
import { bizUpdate } from '@/api/public'
|
||||
import { listBizPerson } from '@/api/business/person'
|
||||
import { listSponsorPerson } from '@/api/business/person'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
// SUB 子账号: 只能看被分配的项目, 不能修改分配
|
||||
const isSub = computed(() => userStore.isSub)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
@@ -228,7 +230,8 @@ function reset() {
|
||||
function onSelectionChange(val) { selected.value = val }
|
||||
|
||||
function onView(row) {
|
||||
ElMessageBox.alert(`项目编号: ${row.projectNo}\n项目名称: ${row.projectName}`, '项目详情', { confirmButtonText: '确定' })
|
||||
// 跳项目详情页 (复用 manager/ManagerProjectDetail.vue, goBack 自动按角色回退)
|
||||
router.push(`/sponsor/projects/detail/${row.projectId}`)
|
||||
}
|
||||
|
||||
// ========== 执行单位评分 (与 manager/Projects.vue 完全相同的样式和逻辑) ==========
|
||||
@@ -299,26 +302,25 @@ const assignBatchRows = ref([]) // 批量模式下被分配的项目列
|
||||
const assignSubmitting = ref(false)
|
||||
const assignForm = reactive({
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
monitorInput: '', monitorUserId: null, monitorUserName: '',
|
||||
assignDesc: '', assignPoints: ''
|
||||
monitorUserIds: []
|
||||
})
|
||||
const allMonitors = ref([])
|
||||
const candidateMonitors = ref([])
|
||||
async function loadMonitors() {
|
||||
// 业务隔离: 仅查 login 主账号创建的人员 (parent_user_id = mainUserId)
|
||||
const mainUid = userStore.id
|
||||
const res = await listBizPerson({ parentUserId: mainUid, role: 'supervisor', pageNum: 1, pageSize: 100 })
|
||||
// 跟 sponsor/people (人员管理) 完全一致: 走 sponsorList, SQL 硬编码 unit_type='sponsor'
|
||||
// + (u.parent_user_id = mainUid OR u.user_id = mainUid), 自动取当前主账号
|
||||
// 仅筛 role='supervisor' (监察员候选人)
|
||||
const res = await listSponsorPerson({ role: 'supervisor', pageNum: 1, pageSize: 100 })
|
||||
allMonitors.value = res.rows || []
|
||||
candidateMonitors.value = allMonitors.value.slice()
|
||||
}
|
||||
function filterMonitors() {
|
||||
const kw = assignForm.monitorInput.trim()
|
||||
candidateMonitors.value = kw ? allMonitors.value.filter(m => m.name.includes(kw)) : allMonitors.value.slice()
|
||||
}
|
||||
function pickMonitor(m) {
|
||||
assignForm.monitorUserId = m.userId
|
||||
assignForm.monitorUserName = m.name
|
||||
assignForm.monitorInput = m.name
|
||||
/** 拉项目当前已分配监察员 → 回显 monitorUserIds (单选模式) */
|
||||
async function loadCurrentAssigns(projectId) {
|
||||
try {
|
||||
const resp = await getSponsorAssigns(projectId)
|
||||
const list = (resp && resp.data) || []
|
||||
assignForm.monitorUserIds = (Array.isArray(list) ? list : []).map(x => x.monitorUserId).filter(Boolean)
|
||||
} catch (e) {
|
||||
assignForm.monitorUserIds = []
|
||||
}
|
||||
}
|
||||
function openAssign(row) {
|
||||
assignBatchMode.value = false
|
||||
@@ -326,40 +328,32 @@ function openAssign(row) {
|
||||
assignForm.projectId = row.projectId
|
||||
assignForm.projectNo = row.projectNo
|
||||
assignForm.projectName = row.projectName
|
||||
assignForm.monitorInput = ''
|
||||
assignForm.monitorUserId = null
|
||||
assignForm.monitorUserName = ''
|
||||
assignForm.assignDesc = ''
|
||||
assignForm.assignPoints = ''
|
||||
assignForm.monitorUserIds = []
|
||||
assignVisible.value = true
|
||||
loadMonitors()
|
||||
// 异步回显当前已分配 (等候选加载好, 让 el-select 找得到 option)
|
||||
loadCurrentAssigns(row.projectId)
|
||||
}
|
||||
function openAssignBatch(rows) {
|
||||
assignBatchMode.value = true
|
||||
assignBatchRows.value = rows.map(r => ({ projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName }))
|
||||
assignForm.monitorInput = ''
|
||||
assignForm.monitorUserId = null
|
||||
assignForm.monitorUserName = ''
|
||||
assignForm.assignDesc = ''
|
||||
assignForm.assignPoints = ''
|
||||
// 批量模式: 不同项目原监察员可能不同, 不回显 (提交时按当前多选覆盖全部)
|
||||
assignForm.monitorUserIds = []
|
||||
assignVisible.value = true
|
||||
loadMonitors()
|
||||
}
|
||||
async function saveAssign() {
|
||||
if (!assignForm.monitorUserId) return ElMessage.warning('请选择监察员')
|
||||
if (!assignForm.assignDesc) return ElMessage.warning('请填写分配说明')
|
||||
if (!assignForm.monitorUserIds.length) return ElMessage.warning('请至少选择一位监察员')
|
||||
assignSubmitting.value = true
|
||||
try {
|
||||
if (assignBatchMode.value) {
|
||||
// 批量模式: 循环逐条 POST (后端先删后插, 每个项目都会被覆盖)
|
||||
// 批量模式: 每个项目都按当前多选覆盖 (后端先删后插 N 条)
|
||||
let ok = 0, errs = []
|
||||
for (const r of assignBatchRows.value) {
|
||||
try {
|
||||
await sponsorAssignProject({
|
||||
projectId: r.projectId,
|
||||
monitorUserId: assignForm.monitorUserId,
|
||||
assignDesc: assignForm.assignDesc,
|
||||
assignPoints: assignForm.assignPoints
|
||||
monitorUserIds: [...assignForm.monitorUserIds]
|
||||
})
|
||||
ok++
|
||||
} catch (e) { errs.push(`${r.projectNo}: ${e?.msg || e.message}`) }
|
||||
@@ -367,12 +361,10 @@ async function saveAssign() {
|
||||
if (errs.length) ElMessageBox.alert(`批量分配: 成功 ${ok}/${assignBatchRows.value.length}\n\n失败:\n${errs.join('\n')}`, '分配结果', { type: 'warning' })
|
||||
else ElMessage.success(`批量分配成功 (${ok}/${assignBatchRows.value.length})`)
|
||||
} else {
|
||||
// 单条模式
|
||||
// 单条模式: 多选一次提交
|
||||
await sponsorAssignProject({
|
||||
projectId: assignForm.projectId,
|
||||
monitorUserId: assignForm.monitorUserId,
|
||||
assignDesc: assignForm.assignDesc,
|
||||
assignPoints: assignForm.assignPoints
|
||||
monitorUserIds: [...assignForm.monitorUserIds]
|
||||
})
|
||||
ElMessage.success('分配成功')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user