refactor: 合并 biz_support_unit/biz_execution_unit/biz_service_org 为 biz_org, 删除 2 张孤儿表, 改 biz_person.work_unit → org_id FK
主要改动: - SQL: biz_support_unit → biz_org, 加 org_type, 加 business_nature; 删 biz_execution_unit, biz_service_org - SQL: biz_project.support_unit_id/name + service_org_id/name → org_id/name/type - SQL: biz_meeting.support_unit_name → org_name - SQL: biz_person.work_unit → org_id (FK) - 后端: 新 BizOrg entity/mapper/service/controller - 后端: BizProject/BizMeeting 字段重命名 - 后端: 删 12 个 BizSupportUnit/BizExecutionUnit/BizServiceOrg Java 文件 - 后端: BizPersonImportVO.workUnit → orgName (导入时查 biz_org 取 org_id) - 后端: BizAuthController.registerExecutor 完整实现 (原 registerSupplier stub) - 前端: 新 admin/Orgs.vue + manager/Orgs.vue (原 SupportUnits) - 前端: RegisterExecutor.vue (原 RegisterSupplier, 单页 2 步) - 前端: sponsor/executor/manager 多个文件 workUnit → orgId/orgName 重命名 - 前端: 统一 supplier → executor, 业务命名 sponsor(赞助方) / executor(执行方=供应商) - 前端: 全工程 execution → executor (company type / role / person unit_type)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>北京整合医学学会 - 项目管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3068
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ry-vue3",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.4.0",
|
||||
"pinia": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"sass": "^1.69.0",
|
||||
"unplugin-auto-import": "^0.17.0",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function login(data) {
|
||||
return request({ url: '/login', method: 'post', data })
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request({ url: '/logout', method: 'post' })
|
||||
}
|
||||
|
||||
export function getInfo() {
|
||||
return request({ url: '/getInfo', method: 'get' })
|
||||
}
|
||||
|
||||
export function getCaptcha() {
|
||||
return request({ url: '/captchaImage', method: 'get' })
|
||||
}
|
||||
|
||||
export function getRouters() {
|
||||
return request({ url: '/getRouters', method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listBizPerson(query) {
|
||||
return request({ url: '/business/person/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
/**
|
||||
* sponsor 端专属人员列表 (后端按当前主账号 + 子账号团队强制隔离)
|
||||
* 后端返回 TableDataInfo 风格 {total, code, msg, rows}, 经 request.js 拦截器包装为 {code, msg, data: {total, rows}}
|
||||
* 这里显式解包, 让调用方直接读 res.rows / res.total
|
||||
*/
|
||||
export async function listSponsorPerson(query) {
|
||||
const { data } = await request({ url: '/business/person/sponsorList', method: 'get', params: query })
|
||||
const payload = data?.data || data
|
||||
return {
|
||||
rows: payload?.rows || [],
|
||||
total: payload?.total || 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* sponsor 专属项目列表 (团队模式: 主账号+子账号关联的项目)
|
||||
* 后端返回 TableDataInfo 风格 {total, code, msg, rows}, 经 request.js 拦截器包装为 {code, msg, data: {total, rows}}
|
||||
* 这里显式解包, 让调用方直接读 res.rows / res.total
|
||||
*/
|
||||
export async function listSponsorProjects(query) {
|
||||
const { data } = await request({ url: '/business/project/sponsorList', method: 'get', params: query })
|
||||
// 兼容两种形态: 包装后 {code, msg, data: {total, rows}} / 原始 {total, rows}
|
||||
const payload = data?.data || data
|
||||
return {
|
||||
rows: payload?.rows || [],
|
||||
total: payload?.total || 0
|
||||
}
|
||||
}
|
||||
|
||||
export function rateProject(data) {
|
||||
return request({ url: '/business/project/rate', method: 'post', data })
|
||||
}
|
||||
|
||||
export function sponsorAssignProject(data) {
|
||||
return request({ url: '/business/project/sponsorAssign', method: 'post', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 赞助方批量分配 (多个项目同一个监察员 + 同一份说明)
|
||||
* body: [{projectId, monitorUserId, assignDesc, assignPoints}, ...]
|
||||
*/
|
||||
export function sponsorAssignBatch(data) {
|
||||
return request({ url: '/business/project/sponsorAssignBatch', method: 'post', data })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 字典类型常量 (前端统一引用,避免拼写错误)
|
||||
export const DICT_TYPE = {
|
||||
DEPARTMENT: 'department', // 科室
|
||||
TITLE: 'title' // 医生职称
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取某类型字典的启用项 (匿名可访问)
|
||||
* @param {string} type - DICT_TYPE.*
|
||||
* @returns {Promise<{value: number, label: string, raw: object}[]>}
|
||||
* - value: deptId/titleId
|
||||
* - label: name
|
||||
* - raw: 完整字段 (含 sort/status/...)
|
||||
*/
|
||||
export async function getActiveDict(type) {
|
||||
const url = `/business/dict/${type}/active`
|
||||
const r = await request({ url, method: 'get' })
|
||||
const raw = r.data || r || []
|
||||
const idField = type === DICT_TYPE.DEPARTMENT ? 'deptId' : 'titleId'
|
||||
return raw.map(item => ({
|
||||
value: item[idField],
|
||||
label: item.name,
|
||||
raw: item
|
||||
}))
|
||||
}
|
||||
|
||||
// ===== 管理员维护接口 =====
|
||||
export function listDepartments(query) {
|
||||
return request({ url: '/business/dict/department/list', method: 'get', params: query })
|
||||
}
|
||||
export function addDepartment(data) {
|
||||
return request({ url: '/business/dict/department', method: 'post', data })
|
||||
}
|
||||
export function updateDepartment(data) {
|
||||
return request({ url: '/business/dict/department', method: 'put', data })
|
||||
}
|
||||
export function deleteDepartments(ids) {
|
||||
return request({ url: `/business/dict/department/${ids}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function listTitles(query) {
|
||||
return request({ url: '/business/dict/title/list', method: 'get', params: query })
|
||||
}
|
||||
export function addTitle(data) {
|
||||
return request({ url: '/business/dict/title', method: 'post', data })
|
||||
}
|
||||
export function updateTitle(data) {
|
||||
return request({ url: '/business/dict/title', method: 'put', data })
|
||||
}
|
||||
export function deleteTitles(ids) {
|
||||
return request({ url: `/business/dict/title/${ids}`, method: 'delete' })
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 公开门户 API
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 首页:项目公示 + 公示公告 + 资源
|
||||
export const getPublicIndex = () => request.get('/business/public/index')
|
||||
|
||||
// 项目公示分页
|
||||
export const listPublicProjects = (params) => request.get('/business/public/announcements', { params })
|
||||
|
||||
// 公示详情
|
||||
export const getPublicAnnouncement = (annId) => request.get(`/business/public/announcement/${annId}`)
|
||||
|
||||
// 支持函详情
|
||||
export const getPublicSupportLetter = (annId) => request.get(`/business/public/supportLetter/${annId}`)
|
||||
|
||||
// 邀请函详情(参会邀请)
|
||||
export const getPublicInvitation = (annId) => request.get(`/business/public/invitation/${annId}`)
|
||||
|
||||
// 业务字典
|
||||
export const getDictTypes = () => request.get('/business/dict/types')
|
||||
export const getDictData = (dictType) => request.get('/business/dict/data', { params: { dictType } })
|
||||
|
||||
// 业务认证
|
||||
export const bizLogin = (data) => request.post('/business/auth/login', data)
|
||||
export const registerExpert = (data) => request.post('/business/auth/registerExpert', data)
|
||||
export const registerExecutor = (data) => request.post('/business/auth/registerExecutor', data)
|
||||
export const registerSponsor = (data) => request.post('/business/auth/registerSponsor', data)
|
||||
|
||||
// 业务 CRUD(generic)
|
||||
// announcement 后端接口未实现, 已改为调 RuoYi 系统通知接口 /system/notice/list
|
||||
const STUB_ENTITIES = new Set(['announcement'])
|
||||
export const bizList = (entity, params) => {
|
||||
if (STUB_ENTITIES.has(entity)) {
|
||||
return Promise.resolve({ code: 200, msg: 'ok', data: { rows: [], total: 0 } })
|
||||
}
|
||||
return request.get(`/business/${entity}/list`, { params })
|
||||
}
|
||||
|
||||
// 系统通知(RuoYi sys_notice 表)
|
||||
// 返回 {id, title, category, text, time, read, type, content}
|
||||
export const listNotices = async (params = { pageNum: 1, pageSize: 5 }) => {
|
||||
const { data } = await request.get('/system/notice/list', { params })
|
||||
const rows = data?.rows || []
|
||||
return rows.map(n => {
|
||||
// notice_content 是 longblob HTML, 提取纯文本
|
||||
const html = String(n.noticeContent || '')
|
||||
const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200)
|
||||
return {
|
||||
id: n.noticeId,
|
||||
title: n.noticeTitle,
|
||||
category: n.noticeType === '2' ? '公告' : '通知',
|
||||
text: text || (n.remark || '请查看通知内容'),
|
||||
time: n.createTime ? new Date(n.createTime.replace(/-/g, '/')).toLocaleString('zh-CN') : '',
|
||||
read: !!n.isRead,
|
||||
type: n.noticeType,
|
||||
content: html,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 个人通知 (biz_message 表) — 给当前用户发
|
||||
// 返回 {rows: [{id, title, text, time, read, type, content, bizType, bizId}], unread, total}
|
||||
const MSG_TYPE_LABEL = { '1': '通知', '2': '待办', '3': '系统' }
|
||||
export const listMyMessages = async (params = { limit: 5 }) => {
|
||||
const { data } = await request.get('/business/message/my', { params })
|
||||
const rows = data?.rows || []
|
||||
return {
|
||||
rows: rows.map(m => ({
|
||||
id: m.msgId,
|
||||
title: m.title,
|
||||
category: MSG_TYPE_LABEL[m.msgType] || '通知',
|
||||
text: String(m.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200),
|
||||
time: m.createTime ? new Date(m.createTime.replace(/-/g, '/')).toLocaleString('zh-CN') : '',
|
||||
read: m.isRead === '1',
|
||||
type: m.msgType,
|
||||
content: m.content,
|
||||
bizType: m.bizType,
|
||||
bizId: m.bizId,
|
||||
})),
|
||||
unread: data?.unread || 0,
|
||||
total: data?.total || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 标记单条已读
|
||||
export const markMessageRead = (msgId) => request.put(`/business/message/read/${msgId}`)
|
||||
// 全部标记已读
|
||||
export const markAllMessagesRead = () => request.put('/business/message/readAll')
|
||||
|
||||
export const bizGet = (entity, id) => request.get(`/business/${entity}/${id}`)
|
||||
export const bizAdd = (entity, data) => request.post(`/business/${entity}`, data)
|
||||
export const bizUpdate = (entity, data) => request.put(`/business/${entity}`, data)
|
||||
export const bizDelete = (entity, ids) => request.delete(`/business/${entity}/${ids}`)
|
||||
@@ -0,0 +1,22 @@
|
||||
// 系统管理 API (用户列表 / 角色)
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 用户列表(按 userName 模糊搜索)
|
||||
export function listUser(query) {
|
||||
return request({ url: '/system/user/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
// 角色列表
|
||||
export function listRole(query) {
|
||||
return request({ url: '/system/role/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
// 按角色查询用户列表 (绕开 sys_user:list 权限)
|
||||
// 用法: listByRole({ roleType: 'executor', userName: 'exe' })
|
||||
export function listByRole(query) {
|
||||
return request({ url: '/business/executor/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
// 兼容旧名
|
||||
export const listExecutor = (query) => listByRole({ roleType: 'executor', ...query })
|
||||
export const listSupporters = (query) => listByRole({ roleType: 'sponsor', ...query })
|
||||
@@ -0,0 +1,7 @@
|
||||
html, body, #app { height: 100%; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }
|
||||
:root { --theme-color: #409eff; --theme-color-light: #66b1ff; --theme-color-dark: #3a8ee6; }
|
||||
.page-wrap { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 16px; box-shadow: 0 1px 4px rgba(0,21,41,.08); margin-bottom: 16px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; color: #1f1f1f; }
|
||||
.filter-bar { margin-bottom: 12px; }
|
||||
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; }
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 品牌主题色切换工具
|
||||
* ----------------------------------------
|
||||
* 用法:
|
||||
* import { setBrand } from '@/assets/theme'
|
||||
* setBrand('#0f766e') // 改主题色(自动重算变体)
|
||||
* setBrand('#1E3A8A') // 改回默认
|
||||
* setBrand('#1E3A8A', { persist: true }) // 写 localStorage
|
||||
* applyStoredBrand() // 应用持久化的颜色
|
||||
*
|
||||
* 派生色算法(HSL 空间调整,比简单 hex 加减更稳):
|
||||
* deep: lightness -= 12
|
||||
* darker: lightness -= 22
|
||||
* light: lightness += 12
|
||||
* lighter: lightness += 35
|
||||
* text: lightness += 22
|
||||
* mix: 50% alpha + white (用于 focus border)
|
||||
*/
|
||||
|
||||
const KEY = 'brand-primary'
|
||||
|
||||
/** hex -> {r,g,b} */
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '')
|
||||
const v = h.length === 3
|
||||
? h.split('').map(c => c + c).join('')
|
||||
: h
|
||||
return {
|
||||
r: parseInt(v.slice(0, 2), 16),
|
||||
g: parseInt(v.slice(2, 4), 16),
|
||||
b: parseInt(v.slice(4, 6), 16)
|
||||
}
|
||||
}
|
||||
|
||||
/** {r,g,b} -> {h,s,l} (h: 0..360, s: 0..100, l: 0..100) */
|
||||
function rgbToHsl({ r, g, b }) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h = 0, s = 0
|
||||
const l = (max + min) / 2
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h *= 60
|
||||
}
|
||||
return { h, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
/** {h,s,l} -> {r,g,b} */
|
||||
function hslToRgb({ h, s, l }) {
|
||||
s /= 100; l /= 100
|
||||
const k = n => (n + h / 30) % 12
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
const f = n => {
|
||||
const x = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
|
||||
return Math.round(x * 255)
|
||||
}
|
||||
return { r: f(0), g: f(8), b: f(4) }
|
||||
}
|
||||
|
||||
/** {h,s,l} -> '#rrggbb' */
|
||||
function hslToHex({ h, s, l }) {
|
||||
const { r, g, b } = hslToRgb({ h, s, l })
|
||||
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** 把 lightness 限制在 8..92 之间 */
|
||||
function clampL(l) { return Math.max(8, Math.min(92, l)) }
|
||||
|
||||
/**
|
||||
* 根据主色算出 7 个派生色 hex
|
||||
*/
|
||||
export function derivePalette(primary) {
|
||||
const hsl = rgbToHsl(hexToRgb(primary))
|
||||
return {
|
||||
'--brand-primary': primary,
|
||||
'--brand-primary-deep': hslToHex({ ...hsl, l: clampL(hsl.l - 12) }),
|
||||
'--brand-primary-darker': hslToHex({ ...hsl, l: clampL(hsl.l - 22) }),
|
||||
'--brand-primary-light': hslToHex({ ...hsl, l: clampL(hsl.l + 12) }),
|
||||
'--brand-primary-lighter': hslToHex({ ...hsl, l: clampL(hsl.l + 35) }),
|
||||
'--brand-primary-text': hslToHex({ ...hsl, l: clampL(hsl.l + 22) }),
|
||||
'--brand-primary-mix': hslToHex({ ...hsl, l: clampL(hsl.l + 45) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 :root 上 7 个 CSS 变量
|
||||
* @param {string} primary 主色 hex
|
||||
* @param {object} [opts] { persist: bool } 是否写 localStorage
|
||||
*/
|
||||
export function setBrand(primary, opts = {}) {
|
||||
const palette = derivePalette(primary)
|
||||
const root = document.documentElement
|
||||
for (const [k, v] of Object.entries(palette)) root.style.setProperty(k, v)
|
||||
if (opts.persist) localStorage.setItem(KEY, primary)
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用持久化在 localStorage 的颜色(无则保持现状)
|
||||
*/
|
||||
export function applyStoredBrand() {
|
||||
const stored = localStorage.getItem(KEY)
|
||||
if (stored) setBrand(stored)
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 :root 的主色(只读)
|
||||
*/
|
||||
export function getBrand() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--brand-primary').trim()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* =============================================================
|
||||
品牌主题色(Brand Theme)
|
||||
------------------------------------------------------------
|
||||
所有色值都是 HSL/HEX 函数动态计算的派生色。
|
||||
改 :root 里的 --brand-primary 即可全局换色。
|
||||
|
||||
派生色算法(hex → HSL → 调整亮度/饱和 → 转回 hex):
|
||||
--brand-primary 主色
|
||||
--brand-primary-deep 暗 12% (hover / active / 标题强调)
|
||||
--brand-primary-darker 暗 22% (顶部导航 / 深底)
|
||||
--brand-primary-light 浅 22% (背景 / disabled)
|
||||
--brand-primary-lighter 浅 55% (hover-bg / 选中态)
|
||||
--brand-primary-text 浅 35% (链接默认)
|
||||
--brand-primary-mix 50% 透明 + 白 (输入框 focus border)
|
||||
|
||||
Element Plus 同步:main.js 在启动时读取 --brand-primary
|
||||
并通过 ElMessage / ElButton 的 --el-color-primary 联动。
|
||||
============================================================= */
|
||||
|
||||
:root {
|
||||
--brand-primary: #1E3A8A;
|
||||
--brand-primary-deep: #152a66;
|
||||
--brand-primary-darker: #0d1d4d;
|
||||
--brand-primary-light: #3a55a3;
|
||||
--brand-primary-lighter: #c1cbe6;
|
||||
--brand-primary-text: #4f6dbf;
|
||||
--brand-primary-mix: #8e9ad1;
|
||||
}
|
||||
|
||||
/* Element Plus 联动:把品牌色同步给 Element Plus 内置变量。
|
||||
这样所有 <el-button type="primary"> 等都跟主题色。 */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-primary);
|
||||
--el-color-primary-light-3: var(--brand-primary-light);
|
||||
--el-color-primary-light-5: var(--brand-primary-lighter);
|
||||
--el-color-primary-light-7: var(--brand-primary-lighter);
|
||||
--el-color-primary-light-9: var(--brand-primary-lighter);
|
||||
--el-color-primary-dark-2: var(--brand-primary-deep);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<el-cascader
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:props="cascaderProps"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:show-all-levels="showAllLevels"
|
||||
:collapse-tags="collapseTags"
|
||||
:separator="separator"
|
||||
class="area-cascader"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import areaData from '@/utils/areaData.json'
|
||||
|
||||
/**
|
||||
* 公共地区控件 (el-cascader 封装)
|
||||
* 3 级: 省 / 市 / 区 (基于 china-area-data 标准行政区划)
|
||||
*
|
||||
* 默认 v-model = 地区编码数组 ['110000', '110100', '110101']
|
||||
* 设置 format="string" 时 v-model = joinSep 拼接的字符串, 例 '北京市/北京市/东城区'
|
||||
* (用于直接存 varchar 列, 避免 JSON 序列化)
|
||||
*
|
||||
* 用法:
|
||||
* <area-cascader v-model="form.region" placeholder="请选择地区" />
|
||||
* <area-cascader v-model="form.region" format="string" join-sep="/" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, String], default: () => [] },
|
||||
placeholder: { type: String, default: '请选择省/市/区' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: true },
|
||||
showAllLevels: { type: Boolean, default: true },
|
||||
collapseTags: { type: Boolean, default: false },
|
||||
separator: { type: String, default: '/' },
|
||||
format: { type: String, default: 'array' }, // 'array' | 'string'
|
||||
joinSep: { type: String, default: '/' },
|
||||
props: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const areaMap = new Map() // value -> {label, parent}
|
||||
const labelToValue = new Map()
|
||||
;(function buildMap(list, parent) {
|
||||
for (const item of list) {
|
||||
areaMap.set(item.value, { label: item.label, parent })
|
||||
labelToValue.set(item.label, item.value)
|
||||
if (item.children) buildMap(item.children, item.value)
|
||||
}
|
||||
})(areaData, null)
|
||||
|
||||
// 把 value 数组翻译成 label 数组
|
||||
function toLabels(values) {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values.map(v => areaMap.get(v)?.label || '')
|
||||
}
|
||||
|
||||
// 把 label 数组翻译成 value 数组 (用于字符串回填)
|
||||
function toValues(labels) {
|
||||
if (!Array.isArray(labels)) return []
|
||||
return labels.map(l => labelToValue.get(l) || '')
|
||||
}
|
||||
|
||||
// 字符串 "北京市/北京市/东城区" → value 数组
|
||||
function stringToValues(str) {
|
||||
if (typeof str !== 'string' || !str) return []
|
||||
const labels = str.split(props.joinSep)
|
||||
return toValues(labels)
|
||||
}
|
||||
|
||||
// 静态引用, 组件树只在加载时构建一次
|
||||
const options = areaData
|
||||
|
||||
const cascaderProps = computed(() => ({
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
expandTrigger: 'hover',
|
||||
checkStrictly: false,
|
||||
multiple: false,
|
||||
...props.props
|
||||
}))
|
||||
|
||||
// 内部 selected 永远是数组 (el-cascader 要求)
|
||||
const selected = ref([])
|
||||
let inited = false
|
||||
|
||||
function syncFromModel() {
|
||||
if (!inited) {
|
||||
// 首次挂载: 从 modelValue 初始化 selected
|
||||
if (props.format === 'string') {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
selected.value = props.modelValue
|
||||
} else if (typeof props.modelValue === 'string' && props.modelValue) {
|
||||
selected.value = stringToValues(props.modelValue)
|
||||
} else {
|
||||
selected.value = []
|
||||
}
|
||||
} else {
|
||||
selected.value = Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
}
|
||||
inited = true
|
||||
}
|
||||
// 首次挂载之后, 父传回的 modelValue 不再覆盖 selected (selected 由 el-cascader 内部维护)
|
||||
// 但父可能传新值 (loadExpertProfile 等),需要重新解析
|
||||
// 简化策略: 父传空字符串 → 清空; 父传非空且跟当前不同 → 重新解析
|
||||
if (props.format === 'string') {
|
||||
if (typeof props.modelValue === 'string' && !props.modelValue) {
|
||||
selected.value = []
|
||||
} else if (typeof props.modelValue === 'string' && props.modelValue) {
|
||||
const newVals = stringToValues(props.modelValue)
|
||||
// 只有当跟当前 selected 真的不一样时, 才更新
|
||||
if (JSON.stringify(newVals) !== JSON.stringify(selected.value)) {
|
||||
selected.value = newVals
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
selected.value = props.modelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => syncFromModel(), { immediate: true })
|
||||
|
||||
function handleChange(val) {
|
||||
selected.value = val || []
|
||||
if (props.format === 'string') {
|
||||
const labels = toLabels(val || [])
|
||||
const str = labels.filter(Boolean).join(props.joinSep)
|
||||
emit('update:modelValue', str)
|
||||
emit('change', str)
|
||||
} else {
|
||||
emit('update:modelValue', val || [])
|
||||
emit('change', val || [])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.area-cascader {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="selected"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
:loading="loading"
|
||||
class="dict-select"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="valueField === 'label' ? opt.label : opt.value"
|
||||
:disabled="opt.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getActiveDict, DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共字典下拉控件
|
||||
* v-model 默认绑定 value (deptId/titleId), 也可改为绑定 label (name 字符串)
|
||||
*
|
||||
* 用法 1 (绑 ID):
|
||||
* <dict-select v-model="form.deptId" dict-type="department" />
|
||||
* <dict-select v-model="form.titleId" dict-type="title" :value-field="'value'" />
|
||||
*
|
||||
* 用法 2 (绑 name 字符串, 兼容旧业务字段如 form.department="内科"):
|
||||
* <dict-select v-model="form.department" dict-type="department" :value-field="'label'" />
|
||||
*
|
||||
* 事件:
|
||||
* @change(value, option) option.raw 含完整字段 (sort/status/...)
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 字典类型, 必须从 DICT_TYPE 取 */
|
||||
dictType: { type: String, required: true, validator: v => Object.values(DICT_TYPE).includes(v) },
|
||||
/** v-model 绑的字段: 'value' (ID) 或 'label' (name 字符串) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const options = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
options.value = await getActiveDict(props.dictType)
|
||||
} catch (e) {
|
||||
console.warn(`[DictSelect ${props.dictType}] load failed`, e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(val) {
|
||||
selected.value = val
|
||||
const opt = Array.isArray(val)
|
||||
? options.value.filter(o => val.includes(props.valueField === 'label' ? o.label : o.value))
|
||||
: options.value.find(o => (props.valueField === 'label' ? o.label : o.value) === val)
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// 字典类型变化时重拉 (如动态切换)
|
||||
watch(() => props.dictType, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<dict-select
|
||||
v-model="selected"
|
||||
:dict-type="DICT_TYPE.DEPARTMENT"
|
||||
:value-field="valueField"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
class="doctor-dept-select"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import { DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共科室下拉控件 (医生/科室字典, 与 RuoYi sys_dept 区分)
|
||||
* v-model 默认绑 ID (deptId), 也可改为绑 name 字符串
|
||||
*
|
||||
* 用法:
|
||||
* <dept-select v-model="form.deptId" />
|
||||
* <dept-select v-model="form.department" value-field="label" filterable placeholder="搜索科室" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 'value' (deptId) 或 'label' (科室名称) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择科室' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
function handleChange(val, opt) {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-dept-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<dict-select
|
||||
v-model="selected"
|
||||
:dict-type="DICT_TYPE.TITLE"
|
||||
:value-field="valueField"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:multiple="multiple"
|
||||
class="doctor-title-select"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import { DICT_TYPE } from '@/api/dict'
|
||||
|
||||
/**
|
||||
* 公共医生职称下拉控件
|
||||
* v-model 默认绑 ID (titleId), 也可改为绑 name 字符串
|
||||
*
|
||||
* 用法:
|
||||
* <title-select v-model="form.titleId" />
|
||||
* <title-select v-model="form.doctorTitle" value-field="label" placeholder="选职称" />
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: null },
|
||||
/** 'value' (titleId) 或 'label' (职称名称) */
|
||||
valueField: { type: String, default: 'value' },
|
||||
placeholder: { type: String, default: '请选择医生职称' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const selected = ref(props.modelValue ?? (props.multiple ? [] : null))
|
||||
watch(() => props.modelValue, v => {
|
||||
selected.value = v ?? (props.multiple ? [] : null)
|
||||
})
|
||||
|
||||
function handleChange(val, opt) {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val, opt)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-title-select { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
身份证正反面上传控件 (仿 hwt-code gr-id-card, 适配 PC 后台)
|
||||
用法 (双 v-model):
|
||||
<id-card-uploader
|
||||
v-model:front-url="form.idCardFrontUrl"
|
||||
v-model:back-url="form.idCardBackUrl"
|
||||
:dir="'ry8080/idcard/'"
|
||||
/>
|
||||
|
||||
readonly=true 时不显示上传按钮, 点击 thumb 弹 el-image 预览
|
||||
上传走 OSS 直传 (utils/oss.js 的 uploadToOss)
|
||||
-->
|
||||
<template>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': frontUrl }" @click="handleClick('front')">
|
||||
<div v-if="!frontUrl && !readonly" class="ht-image-placeholder-wrap">
|
||||
<img class="ht-image-placeholder" :src="frontPlaceholder" />
|
||||
</div>
|
||||
<div v-else-if="!frontUrl && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="frontUrl" />
|
||||
<el-image v-else :src="frontUrl" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[frontUrl]"></el-image>
|
||||
</div>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': backUrl }" style="margin-left:20px" @click="handleClick('back')">
|
||||
<div v-if="!backUrl && !readonly" class="ht-image-placeholder-wrap">
|
||||
<img class="ht-image-placeholder" :src="backPlaceholder" />
|
||||
</div>
|
||||
<div v-else-if="!backUrl && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="backUrl" />
|
||||
<el-image v-else :src="backUrl" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[backUrl]"></el-image>
|
||||
</div>
|
||||
<!-- hidden file input triggered by thumb click -->
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputFront"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="(e) => onFileChange(e, 'front')"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputBack"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="(e) => onFileChange(e, 'back')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const props = defineProps({
|
||||
frontUrl: { type: String, default: '' },
|
||||
backUrl: { 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' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:frontUrl', 'update:backUrl'])
|
||||
|
||||
const fileInputFront = ref(null)
|
||||
const fileInputBack = ref(null)
|
||||
const uploading = ref({ front: false, back: false })
|
||||
|
||||
function handleClick(side) {
|
||||
if (props.readonly) return
|
||||
if (uploading.value[side]) return
|
||||
const el = side === 'front' ? fileInputFront.value : fileInputBack.value
|
||||
el?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e, side) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning('只能上传图片文件')
|
||||
return
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
ElMessage.warning('图片大小不能超过 10MB')
|
||||
return
|
||||
}
|
||||
uploading.value[side] = true
|
||||
try {
|
||||
const url = await uploadToOss(file, props.dir)
|
||||
if (side === 'front') {
|
||||
emit('update:frontUrl', url)
|
||||
} else {
|
||||
emit('update:backUrl', url)
|
||||
}
|
||||
ElMessage.success(side === 'front' ? '身份证正面已上传' : '身份证反面已上传')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value[side] = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ht-image-upload-content {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
width: 90px;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ht-image-upload-content:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.ht-image-upload-content.has-file {
|
||||
border-style: solid;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
.ht-image-placeholder-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.ht-image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ht-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f2f4f5;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
单图 OSS 上传控件 (90×60 缩略图, 视觉跟 IdCardUploader 一致)
|
||||
用法:
|
||||
<oss-image-uploader
|
||||
v-model="form.licenseCertUrl"
|
||||
:dir="'ry8080/license/'"
|
||||
placeholder="证书"
|
||||
/>
|
||||
|
||||
readonly=true 时不触发上传, 点击缩略图弹 el-image 预览
|
||||
上传走 OSS 直传 (utils/oss.js 的 uploadToOss)
|
||||
-->
|
||||
<template>
|
||||
<div class="ht-image-upload-content" :class="{ 'has-file': modelValue }" @click="handleClick">
|
||||
<div v-if="!modelValue && !readonly" class="ht-image-placeholder-wrap">
|
||||
<span class="placeholder-text">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div v-else-if="!modelValue && readonly" style="width: 90px;height: 60px; background: #f2f4f5;"></div>
|
||||
<img v-else-if="!readonly" class="ht-image" :src="modelValue" />
|
||||
<el-image v-else :src="modelValue" style="width: 90px;height: 60px; display: flex; justify-content: center; align-items: center; background: #f2f4f5;"
|
||||
:preview-src-list="[modelValue]"></el-image>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
style="display:none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
dir: { type: String, default: 'ry8080/image/' },
|
||||
placeholder: { type: String, default: '点击上传' },
|
||||
readonly: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
if (props.readonly || uploading.value) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning('只能上传图片文件')
|
||||
return
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
ElMessage.warning('图片大小不能超过 10MB')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, props.dir)
|
||||
emit('update:modelValue', url)
|
||||
ElMessage.success('上传成功')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ht-image-upload-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
width: 90px;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
}
|
||||
.ht-image-upload-content:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.ht-image-upload-content.has-file {
|
||||
border-style: solid;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
.ht-image-placeholder-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.placeholder-text {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
color: #8c8c8c;
|
||||
line-height: 1;
|
||||
}
|
||||
.ht-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f2f4f5;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
|
||||
<div v-if="fileUrl" class="preview-box">
|
||||
<!-- 图片 -->
|
||||
<img v-if="kind === 'image'" :src="fileUrl" class="preview-img" :alt="title" />
|
||||
<!-- PDF:iframe 预览,浏览器内置 viewer -->
|
||||
<iframe v-else-if="kind === 'pdf'" :src="fileUrl" class="preview-iframe" />
|
||||
<!-- 其它(doc/xls/zip 等):给下载链接 -->
|
||||
<div v-else class="preview-fallback">
|
||||
<el-icon class="fallback-icon"><Document /></el-icon>
|
||||
<p>该文件类型暂不支持页内预览,请点击下方按钮下载/在新窗口打开查看。</p>
|
||||
<el-button type="primary" @click="openNew">在新窗口打开</el-button>
|
||||
<el-button @click="copyUrl">复制链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无附件" />
|
||||
<template #footer>
|
||||
<el-button @click="open = false">关闭</el-button>
|
||||
<el-button v-if="fileUrl" type="primary" @click="openNew">在新窗口打开</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
url: { type: String, default: '' },
|
||||
title: { type: String, default: '附件预览' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const fileUrl = computed(() => props.url || '')
|
||||
|
||||
const kind = computed(() => {
|
||||
const u = fileUrl.value.split('?')[0].toLowerCase()
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(u)) return 'image'
|
||||
if (/\.pdf(\?.*)?$/.test(u)) return 'pdf'
|
||||
return 'other'
|
||||
})
|
||||
|
||||
function openNew() {
|
||||
if (!fileUrl.value) return
|
||||
window.open(fileUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
async function copyUrl() {
|
||||
if (!fileUrl.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileUrl.value)
|
||||
ElMessage.success('链接已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
||||
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||
.preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
||||
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
|
||||
.preview-fallback p { margin: 12px 0 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="aside-logo">
|
||||
<div class="logo-text">{{ roleLabel }}</div>
|
||||
<div class="logo-sub">BAHIM 工作台</div>
|
||||
</div>
|
||||
<el-menu :default-active="route.path" router class="aside-menu">
|
||||
<el-menu-item v-for="m in menu" :key="m.path" :index="m.path">
|
||||
<el-icon><component :is="m.icon" /></el-icon>
|
||||
<span>{{ m.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title">{{ route.meta?.title || '工作台' }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<el-button text @click="$router.push('/')">门户首页</el-button>
|
||||
<el-dropdown trigger="click" @command="onCommand">
|
||||
<span class="user-name">{{ store.user?.userName || store.user?.displayName }}<el-icon><CaretBottom /></el-icon></span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useUserStore()
|
||||
const role = computed(() => route.meta?.role || store.role)
|
||||
|
||||
const ROLE_LABEL = { leader: '项目负责人', manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '赞助方' }
|
||||
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
|
||||
|
||||
const MENU = {
|
||||
admin: [
|
||||
{ path: '/admin/workbench', title: '工作台', icon: House },
|
||||
{ path: '/admin/users', title: '用户管理', icon: User },
|
||||
{ path: '/admin/roles', title: '角色管理', icon: Setting },
|
||||
{ path: '/admin/projects', title: '项目全览', icon: Document },
|
||||
{ path: '/admin/meetings', title: '会议全览', icon: Calendar },
|
||||
{ path: '/admin/department', title: '科室管理', icon: Folder },
|
||||
{ path: '/admin/title', title: '职称管理', icon: Medal },
|
||||
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
|
||||
{ path: '/admin/orgs', title: '公司管理', icon: Connection },
|
||||
{ path: '/admin/account', title: '账号信息', icon: User }
|
||||
],
|
||||
leader: [
|
||||
{ path: '/leader/home', title: '工作台', icon: House },
|
||||
{ path: '/leader/projects', title: '项目列表', icon: Document },
|
||||
{ path: '/leader/meetings', title: '会议列表', icon: Calendar },
|
||||
{ path: '/leader/account', title: '账号信息', icon: User }
|
||||
],
|
||||
manager: [
|
||||
{ path: '/manager/workbench', title: '工作台', icon: House },
|
||||
{ path: '/manager/plans', title: '项目策划方案', icon: EditPen },
|
||||
{ path: '/manager/projects', title: '项目管理', icon: Document },
|
||||
{ path: '/manager/meetings', title: '会议管理', icon: Calendar },
|
||||
{ path: '/manager/experts', title: '专家审核', icon: User },
|
||||
{ path: '/manager/orgs', title: '公司管理', icon: OfficeBuilding },
|
||||
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
|
||||
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
|
||||
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
|
||||
],
|
||||
doctor: [
|
||||
{ path: '/doctor/home', title: '首页', icon: House },
|
||||
{ path: '/doctor/meetings', title: '我参与的会议', icon: Calendar },
|
||||
{ path: '/doctor/projects', title: '我报名的项目', icon: Document },
|
||||
{ path: '/doctor/messages', title: '消息通知', icon: Bell },
|
||||
{ path: '/doctor/submissions', title: '我的项目设计投稿', icon: EditPen },
|
||||
{ path: '/doctor/account', title: '账号信息', icon: User }
|
||||
],
|
||||
executor: [
|
||||
{ path: '/executor/overview', title: '首页', icon: House },
|
||||
{ path: '/executor/meetings', title: '会议执行', icon: Calendar },
|
||||
{ path: '/executor/projects', title: '项目列表', icon: Document },
|
||||
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
|
||||
{ path: '/executor/account', title: '账号信息', icon: Setting },
|
||||
{ path: '/executor/labor', title: '劳务凭证', icon: EditPen }
|
||||
],
|
||||
sponsor: [
|
||||
{ path: '/sponsor/home', title: '首页', icon: House },
|
||||
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document },
|
||||
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
|
||||
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
|
||||
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
|
||||
]
|
||||
}
|
||||
const menu = computed(() => (MENU[role.value] || []).filter(item => !item.requireMain || store.isMain))
|
||||
|
||||
const logout = async () => {
|
||||
try { await logoutApi() } catch {}
|
||||
store.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
const onCommand = (cmd) => { if (cmd === 'logout') logout() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout { height: 100vh; }
|
||||
.aside { background: #001529; color: #fff; }
|
||||
.aside-logo { padding: 16px; text-align: center; border-bottom: 1px solid var(--brand-primary-darker); }
|
||||
.logo-text { color: #fff; font-size: 16px; font-weight: 600; }
|
||||
.logo-sub { color: #8a99b3; font-size: 12px; }
|
||||
.aside-menu { background: #001529; border: none; }
|
||||
:deep(.el-menu-item) { color: #c0c4cc; }
|
||||
:deep(.el-menu-item.is-active) { background: #1890ff; color: #fff; }
|
||||
.topbar { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #ebeef5; }
|
||||
.topbar-left { display: flex; align-items: center; }
|
||||
.page-title { font-size: 16px; font-weight: 600; }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.main { background: #f5f7fa; padding: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="portal-layout">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 纯 layout 容器 — header / footer 由各页面自己渲染(来自原型 1:1)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-layout { min-height: 100vh; background: #fff; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import * as ElIcons from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.scss'
|
||||
import './assets/theme.scss'
|
||||
import { applyStoredBrand } from './assets/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
for (const [k, v] of Object.entries(ElIcons)) app.component(k, v)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
applyStoredBrand()
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import PortalLayout from '@/layout/PortalLayout.vue'
|
||||
import AdminLayout from '@/layout/AdminLayout.vue'
|
||||
|
||||
const routes = [
|
||||
// 公开门户(无登录)
|
||||
{ path: '/', component: PortalLayout, children: [
|
||||
{ path: '', name: 'home', component: () => import('@/views/portal/Home.vue'), meta: { title: '首页' } },
|
||||
{ path: 'publicity', name: 'publicity', component: () => import('@/views/portal/Publicity.vue'), meta: { title: '项目公示' } },
|
||||
{ path: 'publicity/:projectId', name: 'publicity-detail', component: () => import('@/views/portal/PublicityDetail.vue'), meta: { title: '公示详情' } },
|
||||
{ path: 'support-letter/:annId', name: 'support-letter', component: () => import('@/views/portal/SupportLetter.vue'), meta: { title: '支持函详情' } },
|
||||
{ path: 'invitation/:annId', name: 'invitation', component: () => import('@/views/portal/Invitation.vue'), meta: { title: '邀请函详情' } },
|
||||
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
|
||||
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
|
||||
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '赞助方注册' } },
|
||||
{ path: 'login', name: 'login', component: () => import('@/views/auth/Login.vue'), meta: { title: '登录' } }
|
||||
]
|
||||
},
|
||||
// 各角色后台(登录后)
|
||||
// 后台管理员
|
||||
{ path: '/admin', component: AdminLayout, meta: { role: 'admin' }, children: [
|
||||
{ path: '', redirect: { name: 'admin-workbench' } },
|
||||
{ path: 'workbench', name: 'admin-workbench', component: () => import('@/views/admin/Workbench.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue'), meta: { title: '用户管理' } },
|
||||
{ path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('@/views/admin/Projects.vue'), meta: { title: '项目全览' } },
|
||||
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/admin/Meetings.vue'), meta: { title: '会议全览' } },
|
||||
{ path: 'department', name: 'admin-department', component: () => import('@/views/admin/Department.vue'), meta: { title: '科室管理' } },
|
||||
{ path: 'title', name: 'admin-title', component: () => import('@/views/admin/Title.vue'), meta: { title: '职称管理' } },
|
||||
{ path: 'experts', name: 'admin-experts', component: () => import('@/views/admin/Experts.vue'), meta: { title: '专家管理' } },
|
||||
{ path: 'orgs', name: 'admin-orgs', component: () => import('@/views/admin/Orgs.vue'), meta: { title: '公司管理' } },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
|
||||
]
|
||||
},
|
||||
{ path: '/leader', component: AdminLayout, meta: { role: 'leader' }, children: [
|
||||
{ path: '', redirect: { name: 'leader-home' } },
|
||||
{ path: 'home', name: 'leader-home', component: () => import('@/views/leader/Home.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'projects', name: 'leader-projects', component: () => import('@/views/leader/Projects.vue'), meta: { title: '项目列表' } },
|
||||
{ path: 'meetings', name: 'leader-meetings', component: () => import('@/views/leader/Meetings.vue'), meta: { title: '会议列表' } },
|
||||
{ path: 'account', name: 'leader-account', component: () => import('@/views/leader/Account.vue'), meta: { title: '账号信息' } }
|
||||
]
|
||||
},
|
||||
{ path: '/manager', component: AdminLayout, meta: { role: 'manager' }, children: [
|
||||
{ path: '', redirect: { name: 'manager-workbench' } },
|
||||
{ path: 'workbench', name: 'manager-workbench', component: () => import('@/views/manager/Workbench.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'plans', name: 'manager-plans', component: () => import('@/views/manager/Plans.vue'), meta: { title: '项目策划方案' } },
|
||||
{ path: 'projects', name: 'manager-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
|
||||
{ path: 'projects/new', name: 'manager-projects-new', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '新建项目' } },
|
||||
{ path: 'projects/edit/:projectId', name: 'manager-projects-edit', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '编辑项目' } },
|
||||
{ path: 'projects/detail/:projectId', name: 'manager-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||
{ path: 'meetings', name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue'), meta: { title: '会议管理' } },
|
||||
{ path: 'meetings/new', name: 'manager-meetings-new', component: () => import('@/views/manager/MeetingNew.vue'), meta: { title: '新建会议' } },
|
||||
{ path: 'experts', name: 'manager-experts', component: () => import('@/views/manager/Experts.vue'), meta: { title: '专家审核' } },
|
||||
{ path: 'orgs', name: 'manager-orgs', component: () => import('@/views/manager/Orgs.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: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } }
|
||||
]
|
||||
},
|
||||
{ path: '/doctor', component: AdminLayout, meta: { role: 'doctor' }, children: [
|
||||
{ path: '', redirect: { name: 'doctor-home' } },
|
||||
{ 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: '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/:subId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } },
|
||||
{ path: 'submission/edit/:subId', name: 'doctor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目设计投稿' } },
|
||||
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } }
|
||||
]
|
||||
},
|
||||
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
|
||||
{ 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: 'projects', name: 'executor-projects', component: () => import('@/views/executor/Projects.vue'), meta: { title: '项目列表' } },
|
||||
{ path: 'people', name: 'executor-people', component: () => import('@/views/executor/People.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: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [
|
||||
{ path: '', redirect: { name: 'sponsor-home' } },
|
||||
{ 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: '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: '/:pathMatch(.*)*', redirect: '/' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = (to.meta?.title || 'BAHIM') + ' - BAHIM 项目管理系统'
|
||||
const role = to.meta?.role
|
||||
// 公开门户 & 登录/注册 不拦截
|
||||
if (!role) return next()
|
||||
const user = JSON.parse(localStorage.getItem('ry_user') || 'null')
|
||||
if (!user) return next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
if (user.role !== role) return next({ name: 'login' })
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('ry_token') || '',
|
||||
user: JSON.parse(localStorage.getItem('ry_user') || 'null')
|
||||
}),
|
||||
getters: {
|
||||
role: (s) => s.user?.role || '',
|
||||
isLogin: (s) => !!s.user,
|
||||
accountType: (s) => s.user?.accountType || 'MAIN',
|
||||
parentUserId: (s) => s.user?.parentUserId || null,
|
||||
isMain: (s) => (s.user?.accountType || 'MAIN') === 'MAIN',
|
||||
isSub: (s) => s.user?.accountType === 'SUB'
|
||||
},
|
||||
actions: {
|
||||
setLogin(token, user) {
|
||||
this.token = token
|
||||
this.user = user
|
||||
localStorage.setItem('ry_token', token)
|
||||
localStorage.setItem('ry_user', JSON.stringify(user))
|
||||
},
|
||||
setToken(token) {
|
||||
this.token = token
|
||||
localStorage.setItem('ry_token', token)
|
||||
},
|
||||
setUser(user) {
|
||||
this.user = user
|
||||
localStorage.setItem('ry_user', JSON.stringify(user))
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.user = null
|
||||
localStorage.removeItem('ry_token')
|
||||
localStorage.removeItem('ry_user')
|
||||
}
|
||||
}
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
|
||||
// OSS 直传工具 - 配合后端 /common/oss/sign
|
||||
// 流程: 1) GET /common/oss/sign?dir=xxx 拿签名
|
||||
// 2) POST 到 OSS bucket, formData 携带 policy/signature/key/accessKeyId
|
||||
// 3) 拿回 OSS URL 写回表单
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 获取 OSS 上传签名
|
||||
* @param {string} dir 上传目录 (如 'ry8080/invitation/')
|
||||
* @returns {Promise<{host, dir, accessKeyId, policy, signature, expire}>}
|
||||
*/
|
||||
export async function getOssSign(dir) {
|
||||
const res = await request({
|
||||
url: '/common/oss/sign',
|
||||
method: 'get',
|
||||
params: { dir }
|
||||
})
|
||||
if (res.code !== 200) {
|
||||
throw new Error(res.msg || 'OSS 签名失败')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接上传 File 到 OSS bucket
|
||||
* @param {File} file 要上传的文件
|
||||
* @param {string} dir 上传目录
|
||||
* @returns {Promise<string>} 完整 URL (https://bucket.xxx/key)
|
||||
*/
|
||||
// 根据文件扩展名返回正确的 MIME,避免 OSS 默认给 application/octet-stream
|
||||
function getMimeByExt(name) {
|
||||
const ext = (name || '').split('.').pop().toLowerCase()
|
||||
const map = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp'
|
||||
}
|
||||
return map[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export async function uploadToOss(file, dir) {
|
||||
const sign = await getOssSign(dir)
|
||||
const ext = (file.name || '').split('.').pop() || 'bin'
|
||||
const key = sign.dir + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.' + ext
|
||||
// 显式传 Content-Type,让 OSS 按此存文件元数据, 浏览器拿到 application/pdf 即可内嵌预览
|
||||
const mime = getMimeByExt(file.name)
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('key', key)
|
||||
fd.append('policy', sign.policy)
|
||||
fd.append('OSSAccessKeyId', sign.accessKeyId)
|
||||
fd.append('signature', sign.signature)
|
||||
fd.append('success_action_status', '200')
|
||||
fd.append('Content-Type', mime)
|
||||
fd.append('file', file)
|
||||
|
||||
// ⚠️ 不要手动设置 Content-Type header,会让 multipart/form-data boundary 丢失
|
||||
// FormData 会自动生成正确的 multipart 格式
|
||||
const resp = await fetch(sign.host, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error('OSS 上传失败: HTTP ' + resp.status)
|
||||
}
|
||||
return sign.host + '/' + key
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/dev-api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
request.interceptors.request.use((cfg) => {
|
||||
const t = localStorage.getItem('ry_token')
|
||||
if (t) cfg.headers.Authorization = `Bearer ${t}`
|
||||
return cfg
|
||||
})
|
||||
|
||||
request.interceptors.response.use(
|
||||
(res) => {
|
||||
// blob/file 类型的响应 (如 Excel 下载/导出) 不走 code 检查, 直接放行
|
||||
const rt = res.config?.responseType
|
||||
if (rt === 'blob' || rt === 'arraybuffer') {
|
||||
return res
|
||||
}
|
||||
const data = res.data
|
||||
if (!data) return data
|
||||
if (data.code === 200) {
|
||||
// TableDataInfo 风格: {code, msg, total, rows} -> 归一化为 {code, msg, data: {total, rows}}
|
||||
if (data.rows !== undefined && data.total !== undefined && data.data === undefined) {
|
||||
return { code: 200, msg: data.msg, data: { total: data.total, rows: data.rows } }
|
||||
}
|
||||
// AjaxResult 风格: {code, msg, data} 直接返回
|
||||
return data
|
||||
}
|
||||
if (data.code === 401) {
|
||||
localStorage.removeItem('ry_token')
|
||||
localStorage.removeItem('ry_user')
|
||||
window.location.href = '/#/login'
|
||||
}
|
||||
ElMessage.error(data?.msg || '请求失败')
|
||||
return Promise.reject(new Error(data?.msg || '请求失败'))
|
||||
},
|
||||
(err) => {
|
||||
ElMessage.error(err.message || '网络错误')
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,65 @@
|
||||
// 雪花 ID 53-bit 压缩版 (JS Number 安全)
|
||||
// 移植自 hwt-code (IdGenerator.java) - 与后端 com.ruoyi.common.utils.id.IdGenerator 完全一致
|
||||
// 53-bit 容量: 2^53-1 = 9007199254740991, JS Number max safe = 2^53-1
|
||||
//
|
||||
// 算法:
|
||||
// timestamp 40 bits (per 10ms, 2300+ 年可用)
|
||||
// machineId 5 bits (0~31)
|
||||
// sequence 8 bits (0~255, 每 10ms 重置)
|
||||
//
|
||||
// 注意: JS bit-shift 对 32-bit 安全, 53-bit 不会溢出 (32-bit long 范围更大)
|
||||
|
||||
const MACHINE_BIT = 5
|
||||
const SEQUENCE_BIT = 8
|
||||
const MAX_MACHINE_NUM = -1 ^ (-1 << MACHINE_BIT) // 31
|
||||
const MAX_SEQUENCE = -1 ^ (-1 << SEQUENCE_BIT) // 255
|
||||
const MACHINE_LEFT = SEQUENCE_BIT
|
||||
const TIMESTMP_LEFT = MACHINE_BIT + SEQUENCE_BIT
|
||||
|
||||
let machineId = 0
|
||||
let sequence = 0
|
||||
let lastStmp = -1
|
||||
|
||||
function initDefaultInstance(id) {
|
||||
machineId = id
|
||||
sequence = 0
|
||||
lastStmp = -1
|
||||
}
|
||||
|
||||
function getTimestamp() {
|
||||
return Math.floor(Date.now() / 10) // per 10ms (与后端一致)
|
||||
}
|
||||
|
||||
function getNextTimestamp() {
|
||||
let mill = getTimestamp()
|
||||
while (mill <= lastStmp) mill = getTimestamp()
|
||||
return mill
|
||||
}
|
||||
|
||||
function nextId() {
|
||||
let currStmp = getTimestamp()
|
||||
if (currStmp < lastStmp) throw new Error('Clock moved backwards. Refusing to generate id')
|
||||
if (currStmp === lastStmp) {
|
||||
sequence = (sequence + 1) & MAX_SEQUENCE
|
||||
if (sequence === 0) currStmp = getNextTimestamp()
|
||||
} else {
|
||||
sequence = 0
|
||||
}
|
||||
lastStmp = currStmp
|
||||
// JS Number 53-bit 安全
|
||||
return Number(BigInt(currStmp) << BigInt(TIMESTMP_LEFT) | BigInt(machineId) << BigInt(MACHINE_LEFT) | BigInt(sequence))
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return nextId()
|
||||
}
|
||||
|
||||
function parseIdTimestamp(id) {
|
||||
return new Date(Number(BigInt(id) >> BigInt(TIMESTMP_LEFT)) * 10)
|
||||
}
|
||||
|
||||
export default {
|
||||
initDefaultInstance,
|
||||
generateId,
|
||||
parseIdTimestamp,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 投稿状态字典 - 与后端 SubmissionStatus 枚举同步
|
||||
// value 用英文 code (PENDING/APPROVED/REJECTED/DRAFT), 前端通过 label 映射中文
|
||||
// locked=true 表示锁定(不可修改/提交)
|
||||
export const SUBMISSION_STATUS = {
|
||||
PENDING: { code: 'PENDING', label: '待审核', type: 'warning', locked: true },
|
||||
APPROVED: { code: 'APPROVED', label: '审核通过', type: 'success', locked: true },
|
||||
REJECTED: { code: 'REJECTED', label: '已退回', type: 'danger', locked: false },
|
||||
DRAFT: { code: 'DRAFT', label: '待提交', type: 'info', locked: false }
|
||||
}
|
||||
|
||||
// 容错: 把数据库返回的 status (字符串) 规范成 enum, 找不到原样返回
|
||||
export function parseStatus(val) {
|
||||
if (val === null || val === undefined || val === '') return null
|
||||
const key = String(val).trim().toUpperCase()
|
||||
return SUBMISSION_STATUS[key] ? key : null
|
||||
}
|
||||
|
||||
// 获取状态 label, 容错返回 val 原值
|
||||
export function statusLabel(val) {
|
||||
const key = parseStatus(val)
|
||||
return key ? SUBMISSION_STATUS[key].label : (val ?? '')
|
||||
}
|
||||
|
||||
// 获取 el-tag type
|
||||
export function statusType(val) {
|
||||
const key = parseStatus(val)
|
||||
return key ? SUBMISSION_STATUS[key].type : 'info'
|
||||
}
|
||||
|
||||
// 是否锁定 (待审核/审核通过不可修改/提交)
|
||||
export function isLocked(val) {
|
||||
const key = parseStatus(val)
|
||||
if (!key) return false
|
||||
return SUBMISSION_STATUS[key].locked === true
|
||||
}
|
||||
|
||||
// 筛选项列表 (用于 el-select options)
|
||||
export const STATUS_OPTIONS = Object.values(SUBMISSION_STATUS).map(s => ({
|
||||
label: s.label,
|
||||
value: s.code
|
||||
}))
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="admin-account">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看当前登录账号、修改个人信息与密码</div>
|
||||
|
||||
<el-descriptions :column="2" border style="margin-bottom: 24px">
|
||||
<el-descriptions-item label="账号">{{ profile.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">{{ profile.nickName || profile.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ roleLabel }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ profile.phonenumber || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ profile.email || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ profile.loginDate || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="修改资料" name="profile">
|
||||
<el-form :model="form" label-width="100px" style="max-width: 480px">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.nickName" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phonenumber" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="saveProfile">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<el-form :model="pwd" label-width="100px" style="max-width: 480px">
|
||||
<el-form-item label="旧密码">
|
||||
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwd.newPassword" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="pwd.confirmPassword" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="changePassword">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const store = useUserStore()
|
||||
const tab = ref('profile')
|
||||
const loading = ref(false)
|
||||
const profile = ref({})
|
||||
const ROLE_LABEL = { admin: '后台管理员', leader: '项目负责人', manager: '合规人员', doctor: '评审专家', executor: '执行人', sponsor: '赞助方' }
|
||||
const roleLabel = computed(() => ROLE_LABEL[store.role] || store.role || '后台管理员')
|
||||
|
||||
const form = reactive({ nickName: '', phonenumber: '', email: '' })
|
||||
const pwd = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await request({ url: '/system/user/profile', method: 'get' })
|
||||
profile.value = data || {}
|
||||
Object.assign(form, {
|
||||
nickName: profile.value.nickName || '',
|
||||
phonenumber: profile.value.phonenumber || '',
|
||||
email: profile.value.email || ''
|
||||
})
|
||||
} catch (e) { console.warn(e) }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
loading.value = true
|
||||
try {
|
||||
await request({ url: '/system/user/profile', method: 'put', data: { ...form } })
|
||||
ElMessage.success('保存成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (!pwd.oldPassword || !pwd.newPassword) return ElMessage.warning('请填写旧密码和新密码')
|
||||
if (pwd.newPassword !== pwd.confirmPassword) return ElMessage.warning('两次输入的新密码不一致')
|
||||
loading.value = true
|
||||
try {
|
||||
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: pwd.oldPassword, newPassword: pwd.newPassword } })
|
||||
ElMessage.success('密码修改成功')
|
||||
pwd.oldPassword = pwd.newPassword = pwd.confirmPassword = ''
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '密码修改失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-account { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="admin-department">
|
||||
<div class="page-card">
|
||||
<div class="desc">维护医生可用的科室字典。停用的科室不会出现在医生选择下拉中。</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="科室名称:">
|
||||
<el-input v-model="q.name" placeholder="输入名称模糊搜索" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable>
|
||||
<el-option label="启用" value="0" />
|
||||
<el-option label="停用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button type="success" @click="openAdd">新增科室</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="deptId" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="科室名称" min-width="200" />
|
||||
<el-table-column prop="sort" label="排序" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'info'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" link :type="row.status === '0' ? 'warning' : 'success'" @click="toggleStatus(row)">
|
||||
{{ row.status === '0' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeOne(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="q.pageSize"
|
||||
:current-page="q.pageNum"
|
||||
@current-change="(p) => { q.pageNum = p; load() }"
|
||||
@size-change="(s) => { q.pageSize = s; q.pageNum = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑 dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑科室' : '新增科室'" width="420px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="科室名称">
|
||||
<el-input v-model="form.name" placeholder="请输入科室名称" maxlength="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="0">启用</el-radio>
|
||||
<el-radio value="1">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { listDepartments, addDepartment, updateDepartment, deleteDepartments } from '@/api/dict'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = reactive({ name: '', status: '', pageNum: 1, pageSize: 20 })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listDepartments({ ...q })
|
||||
rows.value = r.rows || []
|
||||
total.value = r.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { name: '', status: '', pageNum: 1 })
|
||||
load()
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ deptId: null, name: '', sort: 0, status: '0' })
|
||||
|
||||
function openAdd() {
|
||||
isEdit.value = false
|
||||
Object.assign(form, { deptId: null, name: '', sort: 0, status: '0' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, { deptId: row.deptId, name: row.name, sort: row.sort || 0, status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return ElMessage.warning('请输入科室名称')
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateDepartment({ deptId: form.deptId, name: form.name, sort: form.sort, status: form.status })
|
||||
} else {
|
||||
await addDepartment({ name: form.name, sort: form.sort })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const newStatus = row.status === '0' ? '1' : '0'
|
||||
try {
|
||||
await updateDepartment({ deptId: row.deptId, name: row.name, sort: row.sort, status: newStatus })
|
||||
ElMessage.success('已' + (newStatus === '0' ? '启用' : '停用'))
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOne(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除科室"${row.name}"? 该操作不可恢复`, '删除确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await deleteDepartments(row.deptId)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-department { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.pager { margin-top: 16px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<!--
|
||||
角色: 🟦 后台 专家管理 (admin/experts)
|
||||
字段映射: 抄 manager/Experts.vue 11 列 + 操作列, 容器/风格改用 admin 通用 page-card + desc
|
||||
接口: bizList('expert', q) / bizAdd / bizUpdate
|
||||
-->
|
||||
<div class="admin-experts">
|
||||
<div class="page-card">
|
||||
<div class="desc">维护全平台评审专家档案, 管理其执业证书、职称证明与账号状态</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="q.name" placeholder="输入姓名" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="q.phone" placeholder="输入手机号" clearable maxlength="11" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位">
|
||||
<el-input v-model="q.workUnit" placeholder="输入工作单位" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="q.auditStatus" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="待审核" value="0" />
|
||||
<el-option label="通过" value="1" />
|
||||
<el-option label="退回" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable style="width:120px">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="禁用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="openAdd">新建专家</el-button>
|
||||
<el-button @click="openImport">批量导入</el-button>
|
||||
<el-button :disabled="!selection.length" @click="onBatchAudit">批量审核</el-button>
|
||||
<el-button @click="openImportPracticeCert">批量导入执业证书</el-button>
|
||||
<el-button @click="openImportTitleCert">批量导入职称证明</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="name" label="姓名" width="100" fixed />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="科室" width="100" />
|
||||
<el-table-column prop="title" label="职称" width="100" />
|
||||
<el-table-column label="执业证书" width="170">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.practiceCertUrl">
|
||||
<el-button link type="primary" @click="onViewCert(row.practiceCertUrl)">查看</el-button>
|
||||
<el-button link type="primary" @click="onDownloadCert(row.practiceCertUrl, row.phone)">下载</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="职称证明" width="170">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.titleCertUrl">
|
||||
<el-button link type="primary" @click="onViewCert(row.titleCertUrl)">查看</el-button>
|
||||
<el-button link type="primary" @click="onDownloadCert(row.titleCertUrl, row.phone)">下载</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', auditStatusClass(row.auditStatus)]">{{ auditStatusText(row.auditStatus) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auditTime" label="审批时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link :type="row.status === '1' ? 'danger' : 'success'" @click="row.status === '1' ? onDisable(row) : onEnable(row)">
|
||||
{{ row.status === '1' ? '禁用' : '恢复' }}
|
||||
</el-button>
|
||||
<el-button link type="info" v-if="row.auditStatus === '2'" @click="onViewAuditOpinion(row)">已退回</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑专家 -->
|
||||
<el-dialog v-model="editOpen" :title="editForm.expertId ? '编辑专家' : '新建专家'" width="640px">
|
||||
<el-form :model="editForm" label-width="120px" :rules="editRules" ref="editFormRef">
|
||||
<el-form-item label="姓名" prop="name"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" maxlength="11" /></el-form-item>
|
||||
<el-form-item label="工作单位" prop="workUnit"><el-input v-model="editForm.workUnit" /></el-form-item>
|
||||
<el-form-item label="科室" prop="department"><el-input v-model="editForm.department" placeholder="如: 呼吸科" /></el-form-item>
|
||||
<el-form-item label="职称" prop="title">
|
||||
<el-select v-model="editForm.title" placeholder="请选择" style="width:100%">
|
||||
<el-option label="住院医师" value="住院医师" />
|
||||
<el-option label="主治医师" value="主治医师" />
|
||||
<el-option label="副主任医师" value="副主任医师" />
|
||||
<el-option label="主任医师" value="主任医师" />
|
||||
<el-option label="教授" value="教授" />
|
||||
<el-option label="副教授" value="副教授" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执业证书 URL"><el-input v-model="editForm.practiceCertUrl" /></el-form-item>
|
||||
<el-form-item label="职称证明 URL"><el-input v-model="editForm.titleCertUrl" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核 -->
|
||||
<el-dialog v-model="auditOpen" title="审核" width="520px">
|
||||
<p style="margin-bottom:12px">专家: <b>{{ auditForm.name }}</b> ({{ auditForm.phone }})</p>
|
||||
<el-form :model="auditForm" label-width="100px">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio value="1">通过</el-radio>
|
||||
<el-radio value="2">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="auditForm.opinion" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量导入专家 -->
|
||||
<el-dialog v-model="importOpen" title="批量导入专家" width="520px">
|
||||
<div style="margin-bottom:12px;font-size:13px">
|
||||
<el-link type="primary" @click="onDownloadTpl">批量导入模板下载</el-link>
|
||||
</div>
|
||||
<p style="color:#606266;margin-bottom:8px">*批量导入模板与列表表项一致</p>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="importAuditResult">
|
||||
<el-radio value="1">通过</el-radio>
|
||||
<el-radio value="2">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择文件">
|
||||
<el-input v-model="importFileUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量导入执业证书 -->
|
||||
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px">
|
||||
<p style="color:#606266;margin-bottom:8px">提示: 需将证书文件名称修改为手机号进行导入</p>
|
||||
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
<template #footer>
|
||||
<el-button @click="importCertOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量导入职称证明 -->
|
||||
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px">
|
||||
<p style="color:#606266;margin-bottom:8px">提示: 需将证明文件名称修改为手机号进行导入</p>
|
||||
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
<template #footer>
|
||||
<el-button @click="importTitleCertOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情查看 -->
|
||||
<el-dialog v-model="viewOpen" title="专家详情" width="640px">
|
||||
<el-descriptions :column="2" border v-if="currentRow">
|
||||
<el-descriptions-item label="姓名">{{ currentRow.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ currentRow.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="工作单位">{{ currentRow.workUnit }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ currentRow.department }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职称">{{ currentRow.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">{{ auditStatusText(currentRow.auditStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执业证书">
|
||||
<el-link v-if="currentRow.practiceCertUrl" type="primary" @click="onViewCert(currentRow.practiceCertUrl)">查看证书</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="职称证明">
|
||||
<el-link v-if="currentRow.titleCertUrl" type="primary" @click="onViewCert(currentRow.titleCertUrl)">查看证书</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审批时间">{{ fmtTime(currentRow.auditTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批人">{{ currentRow.auditBy }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizAdd, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = reactive({ name: '', phone: '', workUnit: '', auditStatus: '', status: '' })
|
||||
const rows = ref([])
|
||||
const selection = ref([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('expert', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(q, { name: '', phone: '', workUnit: '', auditStatus: '', status: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
function onSel(rs) { selection.value = rs }
|
||||
|
||||
function auditStatusText(s) {
|
||||
if (s === '1') return '通过'
|
||||
if (s === '2') return '退回'
|
||||
return '待审核'
|
||||
}
|
||||
function auditStatusClass(s) {
|
||||
if (s === '1') return 'stage-done'
|
||||
if (s === '2') return 'stage-reject'
|
||||
return 'stage-reviewing'
|
||||
}
|
||||
function fmtTime(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
// ===== 新建/编辑 =====
|
||||
const editOpen = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({
|
||||
expertId: '', name: '', phone: '', workUnit: '', department: '', title: '',
|
||||
practiceCertUrl: '', titleCertUrl: ''
|
||||
})
|
||||
const editRules = {
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
|
||||
department: [{ required: true, message: '请输入科室', trigger: 'blur' }],
|
||||
title: [{ required: true, message: '请选择职称', trigger: 'change' }]
|
||||
}
|
||||
function openAdd() {
|
||||
Object.assign(editForm, { expertId: '', name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
|
||||
editOpen.value = true
|
||||
}
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
expertId: row.expertId, name: row.name, phone: row.phone, workUnit: row.workUnit,
|
||||
department: row.department, title: row.title,
|
||||
practiceCertUrl: row.practiceCertUrl || '', titleCertUrl: row.titleCertUrl || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
async function submitEdit() {
|
||||
await editFormRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editForm.expertId) {
|
||||
await bizUpdate('expert', { ...editForm })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await bizAdd('expert', {
|
||||
...editForm,
|
||||
auditStatus: '1', auditBy: 'admin',
|
||||
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
status: '1'
|
||||
})
|
||||
ElMessage.success('新建成功')
|
||||
}
|
||||
editOpen.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
// ===== 审核 =====
|
||||
const auditOpen = ref(false)
|
||||
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '1', opinion: '' })
|
||||
function onAudit(row) {
|
||||
auditForm.expertId = row.expertId
|
||||
auditForm.name = row.name
|
||||
auditForm.phone = row.phone
|
||||
auditForm.result = '1'
|
||||
auditForm.opinion = ''
|
||||
auditOpen.value = true
|
||||
}
|
||||
async function submitAudit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await bizUpdate('expert', {
|
||||
expertId: auditForm.expertId,
|
||||
auditStatus: auditForm.result,
|
||||
auditOpinion: auditForm.opinion,
|
||||
auditBy: 'admin',
|
||||
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
ElMessage.success(auditForm.result === '1' ? '审核通过' : '已退回')
|
||||
auditOpen.value = false
|
||||
load()
|
||||
} catch { ElMessage.error('审核失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
// ===== 批量审核 =====
|
||||
async function onBatchAudit() {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选行')
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定批量审核选中的 ${selection.value.length} 位专家吗?`, '批量审核', { type: 'warning' })
|
||||
} catch { return }
|
||||
submitting.value = true
|
||||
let ok = 0
|
||||
for (const r of selection.value) {
|
||||
try {
|
||||
await bizUpdate('expert', {
|
||||
expertId: r.expertId, auditStatus: '1', auditOpinion: '批量通过',
|
||||
auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
submitting.value = false
|
||||
ElMessage.success(`批量审核通过 ${ok}/${selection.value.length} 条`)
|
||||
selection.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
// ===== 启用/禁用 =====
|
||||
async function onDisable(row) {
|
||||
try {
|
||||
await bizUpdate('expert', { expertId: row.expertId, status: '0' })
|
||||
ElMessage.success('已禁用'); load()
|
||||
} catch { ElMessage.error('禁用失败') }
|
||||
}
|
||||
async function onEnable(row) {
|
||||
try {
|
||||
await bizUpdate('expert', { expertId: row.expertId, status: '1' })
|
||||
ElMessage.success('已恢复'); load()
|
||||
} catch { ElMessage.error('恢复失败') }
|
||||
}
|
||||
|
||||
// ===== 详情 / 退回意见 =====
|
||||
const viewOpen = ref(false)
|
||||
const currentRow = ref(null)
|
||||
function onView(row) { currentRow.value = row; viewOpen.value = true }
|
||||
function onViewAuditOpinion(row) {
|
||||
ElMessageBox.alert(
|
||||
`退回时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n退回意见: ${row.auditOpinion || '(无)'}`,
|
||||
`专家 ${row.name} 审核已退回`,
|
||||
{ type: 'warning', confirmButtonText: '知道了' }
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
// ===== 证书查看/下载 =====
|
||||
function onViewCert(url) { if (url) window.open(url, '_blank') }
|
||||
function onDownloadCert(url, phone) {
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${phone || 'cert'}_${Date.now()}`
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
}
|
||||
|
||||
// ===== 批量导入 dialog =====
|
||||
const importOpen = ref(false)
|
||||
const importAuditResult = ref('1')
|
||||
const importFileUrl = ref('')
|
||||
const importing = ref(false)
|
||||
function openImport() {
|
||||
importAuditResult.value = '1'; importFileUrl.value = ''; importOpen.value = true
|
||||
}
|
||||
function onDownloadTpl() { ElMessage.info('批量导入模板下载 — 待后端提供静态文件') }
|
||||
async function submitImport() {
|
||||
if (!importFileUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info(`已提交批量导入任务 — 待后端实现`)
|
||||
importOpen.value = false
|
||||
}
|
||||
|
||||
// 批量导入执业证书
|
||||
const importCertOpen = ref(false)
|
||||
const importCertUrl = ref('')
|
||||
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true }
|
||||
async function submitImportCert() {
|
||||
if (!importCertUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
|
||||
importCertOpen.value = false
|
||||
}
|
||||
|
||||
// 批量导入职称证明
|
||||
const importTitleCertOpen = ref(false)
|
||||
const importTitleCertUrl = ref('')
|
||||
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true }
|
||||
async function submitImportTitleCert() {
|
||||
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现')
|
||||
importTitleCertOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-experts { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 0; }
|
||||
.batch-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
|
||||
.stage-tag { display: inline-block; padding: 2px 10px; border-radius: 3px; font-size: 12px; border: 1px solid; }
|
||||
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
|
||||
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
|
||||
.stage-reject { color: #f56c6c; border-color: #fbc4c4; background: #fef0f0; }
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="admin-meetings">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看系统中所有会议 (跨角色全量)</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="会议主题:">
|
||||
<el-input v-model="q.title" placeholder="输入会议主题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable>
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.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>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="title" label="会议主题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_TAG[row.status] || 'info'">{{ STATUS_LABEL[row.status] || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="开始时间" width="170" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="170" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="q.pageSize"
|
||||
:current-page="q.pageNum"
|
||||
@current-change="(p) => { q.pageNum = p; load() }"
|
||||
@size-change="(s) => { q.pageSize = s; q.pageNum = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const STATUS_LABEL = {
|
||||
PENDING: '待执行', EXECUTING: '执行中', COMPLETED: '已执行', CANCELLED: '已取消'
|
||||
}
|
||||
const STATUS_TAG = {
|
||||
PENDING: 'warning', EXECUTING: 'primary', COMPLETED: 'success', CANCELLED: 'info'
|
||||
}
|
||||
const statusOptions = Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const q = reactive({ title: '', status: '', pageNum: 1, pageSize: 20 })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await bizList('meeting', { ...q })
|
||||
rows.value = r.rows || r.data?.rows || []
|
||||
total.value = r.total || r.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { title: '', status: '', pageNum: 1 })
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-meetings { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.pager { margin-top: 16px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<!--
|
||||
角色: 🟦 后台 公司管理 (admin/orgs)
|
||||
重构说明 (2026-08-15):
|
||||
原 biz_support_unit / biz_execution_unit / biz_service_org 三表合并为 biz_org
|
||||
通过 org_type 区分 sponsor(赞助方) / execution(执行方)
|
||||
biz_service_org 整体删除
|
||||
接口: bizList('org', q) / bizUpdate
|
||||
-->
|
||||
<div class="admin-orgs">
|
||||
<div class="page-card">
|
||||
<div class="desc">管理公司档案 (赞助方 + 执行方共用一张表, 通过 org_type 区分)</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="公司类型">
|
||||
<el-select v-model="q.orgType" placeholder="全部" clearable style="width:140px">
|
||||
<el-option label="赞助方" value="sponsor" />
|
||||
<el-option label="执行方" value="executor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
<el-input v-model="q.taxNo" placeholder="输入税号" clearable style="width:220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="合作状态">
|
||||
<el-select v-model="q.status" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="正常" value="正常" />
|
||||
<el-option label="合作中" value="合作中" />
|
||||
<el-option label="禁用" value="禁用" />
|
||||
<el-option label="0正常" value="0" />
|
||||
<el-option label="1禁用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAdd">新增公司</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column label="公司类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
|
||||
{{ row.orgType === 'sponsor' ? '赞助方' : '执行方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
|
||||
<el-table-column label="合作状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ formatDate(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
|
||||
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
|
||||
{{ isDisabled(row) ? '恢复' : '禁用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<p class="hint-bottom">*点击禁用后, 该公司下所有账号均不可登录</p>
|
||||
</div>
|
||||
|
||||
<!-- 查看 / 编辑 dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑公司' : '新增公司'" width="640px">
|
||||
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="公司类型" prop="orgType">
|
||||
<el-radio-group v-model="form.orgType">
|
||||
<el-radio value="sponsor">赞助方</el-radio>
|
||||
<el-radio value="executor">执行方</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称" prop="orgName">
|
||||
<el-input v-model="form.orgName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司地址">
|
||||
<el-input v-model="form.address" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码">
|
||||
<el-input v-model="form.taxNo" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contactName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="form.contactPhone" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态">
|
||||
<el-select v-model="form.status" style="width:160px">
|
||||
<el-option label="正常/合作中" value="0" />
|
||||
<el-option label="合作中" value="合作中" />
|
||||
<el-option label="禁用" value="禁用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizAdd, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = reactive({ orgType: '', orgName: '', taxNo: '', status: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
function statusTagType(s) {
|
||||
if (s === '正常' || s === '0' || s === '合作中') return 'success'
|
||||
if (s === '禁用' || s === '1') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
function isDisabled(row) {
|
||||
return row.status === '禁用' || row.status === '1'
|
||||
}
|
||||
function formatDate(d) {
|
||||
if (!d) return ''
|
||||
const str = String(d)
|
||||
return str.length >= 10 ? str.slice(0, 10) : str
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('org', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(q, { orgType: '', orgName: '', taxNo: '', status: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function onToggleStatus(row) {
|
||||
const disabled = isDisabled(row)
|
||||
const newStatus = disabled ? (row.orgType === 'sponsor' ? '正常' : '0') : '禁用'
|
||||
const label = disabled ? '恢复' : '禁用'
|
||||
if (!disabled) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${label}「${row.orgName}」吗? ${label}后该企业下所有账号均不可登录`,
|
||||
'确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
}
|
||||
try {
|
||||
await bizUpdate('org', { orgId: row.orgId, status: newStatus })
|
||||
ElMessage.success('已' + label)
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
// 查看 / 新增 / 编辑
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
orgId: null, orgType: 'sponsor', orgName: '', address: '', taxNo: '',
|
||||
contactName: '', contactPhone: '', status: '0'
|
||||
})
|
||||
const rules = {
|
||||
orgType: [{ required: true, message: '请选择公司类型', trigger: 'change' }],
|
||||
orgName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
isEdit.value = false
|
||||
Object.assign(form, {
|
||||
orgId: null, orgType: 'sponsor', orgName: '', address: '', taxNo: '',
|
||||
contactName: '', contactPhone: '', status: '0'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function onEdit(row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, { ...row })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function onView(row) {
|
||||
// 只读查看用 ElMessageBox 弹窗即可, 这里简单 alert 一下
|
||||
ElMessageBox.alert(
|
||||
`类型: ${row.orgType === 'sponsor' ? '赞助方' : '执行方'}\n` +
|
||||
`名称: ${row.orgName}\n` +
|
||||
`地址: ${row.address || '-'}\n` +
|
||||
`税号: ${row.taxNo || '-'}\n` +
|
||||
`联系人: ${row.contactName || '-'} (${row.contactPhone || '-'})\n` +
|
||||
`状态: ${row.status}\n` +
|
||||
`创建日期: ${formatDate(row.createTime)}`,
|
||||
'公司详情',
|
||||
{ confirmButtonText: '关闭' }
|
||||
).catch(() => {})
|
||||
}
|
||||
async function onSave() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await bizUpdate('org', { ...form })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await bizAdd('org', { ...form })
|
||||
ElMessage.success('新增成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-orgs { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 0; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.hint-bottom { font-size: 12px; color: #ff4d4f; margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="admin-projects">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看系统中所有项目 (跨角色全量)</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="项目编号:">
|
||||
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称:">
|
||||
<el-input v-model="q.projectName" placeholder="输入项目名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable>
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题:">
|
||||
<el-select v-model="q.isFinished" placeholder="全部" clearable>
|
||||
<el-option label="未结题" value="0" />
|
||||
<el-option label="已结题" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="projectNo" label="项目编号" width="140" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_TAG[row.status] || 'info'">{{ STATUS_LABEL[row.status] || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isFinished" label="结题" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFinished === '1' ? 'success' : 'info'">{{ row.isFinished === '1' ? '已结题' : '未结题' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="q.pageSize"
|
||||
:current-page="q.pageNum"
|
||||
@current-change="(p) => { q.pageNum = p; load() }"
|
||||
@size-change="(s) => { q.pageSize = s; q.pageNum = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const STATUS_LABEL = {
|
||||
DRAFT: '草稿', PENDING: '待审批', APPROVED: '已审批', REJECTED: '已驳回', FINISHED: '已结题'
|
||||
}
|
||||
const STATUS_TAG = {
|
||||
DRAFT: 'info', PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger', FINISHED: 'success'
|
||||
}
|
||||
const statusOptions = Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const q = reactive({ projectNo: '', projectName: '', status: '', isFinished: '', pageNum: 1, pageSize: 20 })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await bizList('project', { ...q })
|
||||
rows.value = r.rows || r.data?.rows || []
|
||||
total.value = r.total || r.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', projectName: '', status: '', isFinished: '', pageNum: 1 })
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-projects { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.pager { margin-top: 16px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="admin-roles">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看 6 种角色的用户分布与人员明细</div>
|
||||
|
||||
<el-row :gutter="16" class="role-cards">
|
||||
<el-col v-for="r in roleStats" :key="r.code" :xs="12" :sm="8" :md="4">
|
||||
<div class="role-card" :class="{ active: r.code === selected }" @click="selectRole(r.code)">
|
||||
<div class="role-label">{{ r.label }}</div>
|
||||
<div class="role-count">{{ r.count }}</div>
|
||||
<div class="role-hint">点击查看详情</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 选中角色详情 -->
|
||||
<div v-if="selected" class="page-card" style="margin-top: 16px">
|
||||
<h3 class="sub-title">{{ ROLE_LABEL[selected] }} - 成员列表</h3>
|
||||
<el-table :data="rows" border stripe v-loading="loading" style="margin-top: 12px">
|
||||
<el-table-column prop="userId" label="用户ID" width="80" />
|
||||
<el-table-column prop="userName" label="账号" width="140" />
|
||||
<el-table-column prop="nickName" label="姓名" width="140" />
|
||||
<el-table-column prop="phonenumber" label="手机号" width="140" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listUser } from '@/api/system'
|
||||
|
||||
const ROLE_LABEL = {
|
||||
admin: '后台管理员', leader: '项目负责人', manager: '合规人员',
|
||||
doctor: '评审专家', executor: '执行人', sponsor: '赞助方'
|
||||
}
|
||||
|
||||
const roleStats = ref([])
|
||||
const selected = ref('')
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadStats() {
|
||||
const codes = Object.keys(ROLE_LABEL)
|
||||
const arr = []
|
||||
for (const code of codes) {
|
||||
try {
|
||||
const r = await listUser({ pageNum: 1, pageSize: 1, roleType: code })
|
||||
arr.push({ code, label: ROLE_LABEL[code], count: r.total || 0 })
|
||||
} catch (e) {
|
||||
arr.push({ code, label: ROLE_LABEL[code], count: 0 })
|
||||
}
|
||||
}
|
||||
roleStats.value = arr
|
||||
}
|
||||
|
||||
async function selectRole(code) {
|
||||
selected.value = code
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listUser({ pageNum: 1, pageSize: 200, roleType: code })
|
||||
rows.value = r.rows || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-roles { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.role-cards { margin-top: 8px; }
|
||||
.role-card { background: #fafbfc; border: 1px solid #ebeef5; border-radius: 4px; padding: 16px; text-align: center; cursor: pointer; transition: all .2s; }
|
||||
.role-card:hover, .role-card.active { border-color: var(--brand-primary); background: #fff; box-shadow: 0 2px 8px rgba(24,144,255,.12); }
|
||||
.role-label { font-size: 14px; color: #606266; margin-bottom: 8px; }
|
||||
.role-count { font-size: 28px; font-weight: 600; color: var(--brand-primary); margin-bottom: 4px; }
|
||||
.role-hint { font-size: 12px; color: #c0c4cc; }
|
||||
.sub-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="admin-title">
|
||||
<div class="page-card">
|
||||
<div class="desc">维护医生可用的职称字典。停用的职称不会出现在医生选择下拉中。</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="职称名称:">
|
||||
<el-input v-model="q.name" placeholder="输入名称模糊搜索" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable>
|
||||
<el-option label="启用" value="0" />
|
||||
<el-option label="停用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button type="success" @click="openAdd">新增职称</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="titleId" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="职称名称" min-width="200" />
|
||||
<el-table-column prop="sort" label="排序" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'info'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" link :type="row.status === '0' ? 'warning' : 'success'" @click="toggleStatus(row)">
|
||||
{{ row.status === '0' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeOne(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="q.pageSize"
|
||||
:current-page="q.pageNum"
|
||||
@current-change="(p) => { q.pageNum = p; load() }"
|
||||
@size-change="(s) => { q.pageSize = s; q.pageNum = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑 dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑职称' : '新增职称'" width="420px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="职称名称">
|
||||
<el-input v-model="form.name" placeholder="请输入职称名称" maxlength="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="0">启用</el-radio>
|
||||
<el-radio value="1">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { listTitles, addTitle, updateTitle, deleteTitles } from '@/api/dict'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = reactive({ name: '', status: '', pageNum: 1, pageSize: 20 })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listTitles({ ...q })
|
||||
rows.value = r.rows || []
|
||||
total.value = r.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { name: '', status: '', pageNum: 1 })
|
||||
load()
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ titleId: null, name: '', sort: 0, status: '0' })
|
||||
|
||||
function openAdd() {
|
||||
isEdit.value = false
|
||||
Object.assign(form, { titleId: null, name: '', sort: 0, status: '0' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
isEdit.value = true
|
||||
Object.assign(form, { titleId: row.titleId, name: row.name, sort: row.sort || 0, status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) return ElMessage.warning('请输入职称名称')
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateTitle({ titleId: form.titleId, name: form.name, sort: form.sort, status: form.status })
|
||||
} else {
|
||||
await addTitle({ name: form.name, sort: form.sort })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const newStatus = row.status === '0' ? '1' : '0'
|
||||
try {
|
||||
await updateTitle({ titleId: row.titleId, name: row.name, sort: row.sort, status: newStatus })
|
||||
ElMessage.success('已' + (newStatus === '0' ? '启用' : '停用'))
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOne(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除职称"${row.name}"? 该操作不可恢复`, '删除确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await deleteTitles(row.titleId)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-title { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.pager { margin-top: 16px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="admin-users">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看系统中所有用户、修改角色与状态</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="用户账号:">
|
||||
<el-input v-model="q.userName" placeholder="输入用户账号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名:">
|
||||
<el-input v-model="q.nickName" placeholder="输入姓名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色:">
|
||||
<el-select v-model="q.roleType" placeholder="全部角色" clearable>
|
||||
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable>
|
||||
<el-option label="启用" value="0" />
|
||||
<el-option label="停用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="80" />
|
||||
<el-table-column prop="userName" label="账号" width="120" />
|
||||
<el-table-column prop="nickName" label="姓名" width="140" />
|
||||
<el-table-column prop="roleType" label="角色" width="100">
|
||||
<template #default="{ row }">{{ ROLE_LABEL[row.roleType] || row.roleType }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phonenumber" label="手机号" width="130" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" link :type="row.status === '0' ? 'danger' : 'success'" @click="toggleStatus(row)">
|
||||
{{ row.status === '0' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
class="pager"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="q.pageSize"
|
||||
:current-page="q.pageNum"
|
||||
@current-change="(p) => { q.pageNum = p; load() }"
|
||||
@size-change="(s) => { q.pageSize = s; q.pageNum = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 编辑 dialog -->
|
||||
<el-dialog v-model="editVisible" title="编辑用户" width="520px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="editForm.userName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="editForm.nickName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="editForm.roleType">
|
||||
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="editForm.phonenumber" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { listUser } from '@/api/system'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const ROLE_LABEL = {
|
||||
admin: '后台管理员', leader: '项目负责人', manager: '合规人员',
|
||||
doctor: '评审专家', executor: '执行人', sponsor: '赞助方'
|
||||
}
|
||||
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const q = reactive({ userName: '', nickName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await listUser({ ...q })
|
||||
rows.value = r.rows || []
|
||||
total.value = r.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.userName = ''; q.nickName = ''; q.roleType = ''; q.status = ''
|
||||
q.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ userId: null, userName: '', nickName: '', roleType: '', phonenumber: '', email: '' })
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
userId: row.userId, userName: row.userName, nickName: row.nickName,
|
||||
roleType: row.roleType, phonenumber: row.phonenumber, email: row.email
|
||||
})
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
try {
|
||||
await request({ url: '/system/user/edit', method: 'put', data: { ...editForm } })
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const newStatus = row.status === '0' ? '1' : '0'
|
||||
try {
|
||||
await request({
|
||||
url: '/system/user/changeStatus',
|
||||
method: 'put',
|
||||
data: { userId: row.userId, status: newStatus }
|
||||
})
|
||||
ElMessage.success('已' + (newStatus === '0' ? '启用' : '停用'))
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-users { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.desc { font-size: 13px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #ebeef5; }
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.pager { margin-top: 16px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="admin-workbench">
|
||||
|
||||
<!-- 标题 -->
|
||||
<h2 class="page-title">系统总览</h2>
|
||||
|
||||
<!-- KPI 卡: 用户/项目/会议/已结题 -->
|
||||
<div class="stats-grid">
|
||||
<router-link class="stat-card" to="/admin/users">
|
||||
<div class="stat-label">系统用户总数</div>
|
||||
<div class="stat-value">{{ stats.totalUsers }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/admin/roles">
|
||||
<div class="stat-label">角色类型数</div>
|
||||
<div class="stat-value">{{ stats.roleCount }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/admin/projects">
|
||||
<div class="stat-label">项目总数量</div>
|
||||
<div class="stat-value">{{ stats.totalProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/admin/projects">
|
||||
<div class="stat-label">已结题项目</div>
|
||||
<div class="stat-value">{{ stats.finishedProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/admin/meetings">
|
||||
<div class="stat-label">会议总数量</div>
|
||||
<div class="stat-value">{{ stats.totalMeetings }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="hint-text">*点击数字,跳转到对应的列表页</p>
|
||||
|
||||
<!-- 各角色用户数分布 -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">各角色用户数</h2>
|
||||
<div class="role-grid">
|
||||
<div v-for="r in roleStats" :key="r.code" class="role-card">
|
||||
<div class="role-name">{{ r.label }}</div>
|
||||
<div class="role-count">{{ r.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listUser } from '@/api/system'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const stats = ref({
|
||||
totalUsers: 0, roleCount: 6, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
|
||||
})
|
||||
const roleStats = ref([])
|
||||
|
||||
const ROLE_LABEL = {
|
||||
admin: '后台管理员', leader: '项目负责人', manager: '合规人员',
|
||||
doctor: '评审专家', executor: '执行人', sponsor: '赞助方'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
// 用户总数 (sys_user)
|
||||
const u = await listUser({ pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalUsers = u.total || 0
|
||||
|
||||
// 各角色用户数
|
||||
const all = []
|
||||
const codes = Object.keys(ROLE_LABEL)
|
||||
for (const code of codes) {
|
||||
try {
|
||||
const r = await listUser({ pageNum: 1, pageSize: 1, roleType: code })
|
||||
all.push({ code, label: ROLE_LABEL[code], count: r.total || 0 })
|
||||
} catch (e) {
|
||||
all.push({ code, label: ROLE_LABEL[code], count: 0 })
|
||||
}
|
||||
}
|
||||
roleStats.value = all
|
||||
|
||||
// 项目数
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || ps.data?.total || 0
|
||||
// 已结题
|
||||
const fp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' })
|
||||
stats.value.finishedProjects = fp.total || fp.data?.total || 0
|
||||
// 会议数
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalMeetings = ms.total || ms.data?.total || 0
|
||||
} catch (e) {
|
||||
console.warn('admin workbench load failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-workbench { padding: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 12px; }
|
||||
.stat-card { background: #fff; border-radius: 4px; padding: 24px; border: 1px solid #ebeef5; cursor: pointer; text-decoration: none; color: inherit; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: opacity .15s, border-color .15s; min-height: 120px; }
|
||||
.stat-card:hover { border-color: var(--brand-primary); opacity: .9; }
|
||||
.stat-label { font-size: 14px; color: #606266; margin-bottom: 16px; }
|
||||
.stat-value { font-size: 32px; font-weight: 600; color: var(--brand-primary); line-height: 1.2; }
|
||||
.hint-text { font-size: 12px; color: #ff4d4f; margin: 12px 0 20px; text-align: center; }
|
||||
.section { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; margin-top: 16px; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.role-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
|
||||
.role-card { background: #fafbfc; border: 1px solid #ebeef5; border-radius: 4px; padding: 16px; text-align: center; }
|
||||
.role-name { font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||
.role-count { font-size: 24px; font-weight: 600; color: var(--brand-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="header">
|
||||
<a class="logo" @click.prevent="$router.push('/portal/home')">
|
||||
<div class="logo-icon">医</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">北京整合医学学会</span>
|
||||
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
|
||||
</div>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- 主体 -->
|
||||
<main class="main-content">
|
||||
<!-- 左侧 banner -->
|
||||
<div class="banner-section">
|
||||
<div class="banner-inner">
|
||||
<span class="banner-tag">2025-2030</span>
|
||||
<h2 class="banner-title">协同创新 共建共享<br>整合医学发展新格局</h2>
|
||||
<div class="banner-divider"></div>
|
||||
<p class="banner-desc">
|
||||
聚焦医学整合创新,系统推进七大专项计划,<br>
|
||||
全面构建覆盖诊疗、科研、人才、管理、公益、<br>
|
||||
政学协作与组织建设的协同发展体系。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录卡片 -->
|
||||
<section class="login-section">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">账号登录</h1>
|
||||
<p class="login-subtitle">请输入您的账号信息</p>
|
||||
|
||||
<form id="loginForm" @submit.prevent="onSubmit">
|
||||
<div class="form-group">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<input v-model="form.username" type="text" id="username" class="form-input" placeholder="用户名 / 手机号" autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<input v-model="form.password" type="password" id="password" class="form-input" placeholder="密码" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div v-if="captchaEnabled" class="captcha-group">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<input v-model="form.code" type="text" id="captcha" class="form-input captcha-input" placeholder="验证码" maxlength="6">
|
||||
</div>
|
||||
<img v-if="captchaImg" :src="captchaImg" class="captcha-image" @click="loadCaptcha" title="点击刷新" alt="验证码">
|
||||
<div v-else class="captcha-image" @click="loadCaptcha" title="点击刷新">加载中</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="loading">{{ loading ? '登录中...' : '登 录' }}</button>
|
||||
|
||||
<div class="form-bottom-links">
|
||||
<a class="bottom-link" @click="onRegister">新用户注册</a>
|
||||
<a class="bottom-link" @click="onForgot">忘记密码</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 底部 -->
|
||||
<footer class="footer">
|
||||
<p>© 北京整合医学学会 BAHIM</p>
|
||||
<p>京ICP备2020035479号-1 京公网安备11010802034820</p>
|
||||
</footer>
|
||||
|
||||
<!-- 注册弹窗 (原型: 选择用户类别) -->
|
||||
<div class="modal-overlay" :class="{ active: showRoleModal }" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">选择用户类别</h2>
|
||||
<div class="user-type-list">
|
||||
<label v-for="t in registerTypes" :key="t.value" class="user-type-item">
|
||||
<input type="radio" name="registerUserType" :value="t.value" v-model="registerUserType">
|
||||
<div class="info">
|
||||
<div class="name">{{ t.name }}</div>
|
||||
<div class="desc">{{ t.desc }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn secondary" type="button" @click="closeModal">取消</button>
|
||||
<button class="modal-btn primary" type="button" @click="confirmRegister">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { login, getInfo, getCaptcha } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const form = reactive({ username: '', password: '', code: '', uuid: '', role: 'leader' })
|
||||
const captchaImg = ref('')
|
||||
const captchaEnabled = ref(true)
|
||||
const loading = ref(false)
|
||||
const showRoleModal = ref(false)
|
||||
const registerUserType = ref('')
|
||||
|
||||
const registerTypes = [
|
||||
{ value: 'expert', name: '项目参与专家', desc: '负责项目方案设计与评审' },
|
||||
{ value: 'executor', name: '项目执行单位(供应商)', desc: '承担项目执行,需走入库流程' },
|
||||
{ value: 'sponsor', name: '项目支持单位', desc: '注册后默认为监察员角色' }
|
||||
]
|
||||
|
||||
const userTypes = [
|
||||
{ value: 'admin', name: '系统管理员', desc: '系统配置、用户管理、权限控制' },
|
||||
{ value: 'leader', name: '学会领导', desc: '查看全局数据、审批重要事项' },
|
||||
{ value: 'manager', name: '项目经理', desc: '项目管理、方案审核、过程监督' },
|
||||
{ value: 'doctor', name: '评审专家', desc: '项目评审、评分反馈、查看任务' },
|
||||
{ value: 'executor', name: '执行单位', desc: '会议组织、人员安排、劳务结算' },
|
||||
{ value: 'sponsor', name: '支持单位 / 赞助方', desc: '支持函管理、人员管理、合作记录' },
|
||||
]
|
||||
|
||||
async function loadCaptcha() {
|
||||
try {
|
||||
const res = await getCaptcha()
|
||||
captchaEnabled.value = res?.captchaEnabled !== false
|
||||
if (res?.uuid) {
|
||||
form.uuid = res.uuid
|
||||
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
|
||||
}
|
||||
} catch (e) {
|
||||
captchaImg.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
if (captchaEnabled.value && !form.code) {
|
||||
ElMessage.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
|
||||
const role = autoRole(form.username)
|
||||
userStore.setToken(res.token)
|
||||
// /login 接口只返回 token; 调 /getInfo 拿完整 user (含 accountType/parentUserId, 让 isMain/isSub 判断正确)
|
||||
try {
|
||||
const info = await getInfo()
|
||||
const u = info.user || {}
|
||||
userStore.setUser({
|
||||
userId: u.userId,
|
||||
userName: u.userName || form.username,
|
||||
nickName: u.nickName || form.username,
|
||||
accountType: u.accountType || 'MAIN',
|
||||
parentUserId: u.parentUserId || null,
|
||||
role
|
||||
})
|
||||
} catch {
|
||||
// /getInfo 失败时回退: 只存基本信息
|
||||
userStore.setUser({
|
||||
userId: res.userId,
|
||||
userName: form.username,
|
||||
nickName: form.username,
|
||||
accountType: 'MAIN',
|
||||
parentUserId: null,
|
||||
role
|
||||
})
|
||||
}
|
||||
ElMessage.success(`欢迎,${form.username}(${userTypes.find(u => u.value === role)?.name || role})`)
|
||||
router.replace(roleHome[role] || '/leader/home')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '登录失败')
|
||||
loadCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function autoRole(username) {
|
||||
const u = (username || '').toLowerCase()
|
||||
if (u === 'admin' || u === 'ry') return 'admin'
|
||||
if (u.startsWith('leader')) return 'leader'
|
||||
if (u.startsWith('manager')) return 'manager'
|
||||
if (u.startsWith('doctor')) return 'doctor'
|
||||
if (u.startsWith('executor')) return 'executor'
|
||||
if (u.startsWith('sponsor')) return 'sponsor'
|
||||
return 'leader'
|
||||
}
|
||||
|
||||
const roleHome = {
|
||||
admin: '/admin/workbench',
|
||||
leader: '/leader/home',
|
||||
manager: '/manager/workbench',
|
||||
doctor: '/doctor/home',
|
||||
executor: '/executor/meetings',
|
||||
sponsor: '/sponsor/home',
|
||||
}
|
||||
|
||||
function onRegister() {
|
||||
showRoleModal.value = true
|
||||
registerUserType.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showRoleModal.value = false
|
||||
}
|
||||
|
||||
function confirmRegister() {
|
||||
if (!registerUserType.value) {
|
||||
ElMessage.warning('请选择用户类别')
|
||||
return
|
||||
}
|
||||
const routeMap = {
|
||||
expert: '/register-expert',
|
||||
executor: '/register-executor',
|
||||
sponsor: '/register-sponsor'
|
||||
}
|
||||
const target = routeMap[registerUserType.value]
|
||||
if (!target) {
|
||||
ElMessage.warning('该类别暂未开放注册')
|
||||
return
|
||||
}
|
||||
showRoleModal.value = false
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
function onForgot() {
|
||||
ElMessage.info('请联系系统管理员重置密码')
|
||||
}
|
||||
|
||||
onMounted(() => { loadCaptcha() })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* === 原型 CSS 1:1 复制自 /home/john/ry8080/proto/html/登录.html === */
|
||||
|
||||
.login-page *{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-page{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #333;
|
||||
background: #f5f6f8;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.login-page .header{
|
||||
height: 64px;
|
||||
padding: 0 60px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-page .logo{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-page .logo-icon{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #1e3a8a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-page .logo-text{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.login-page .logo-title{
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.login-page .logo-subtitle{
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* 主体 */
|
||||
.login-page .main-content{
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 60px;
|
||||
gap: 60px;
|
||||
}
|
||||
|
||||
.login-page .banner-section{
|
||||
flex: 1;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.login-page .banner-inner{
|
||||
background: #1e3a8a;
|
||||
color: #fff;
|
||||
padding: 56px 56px;
|
||||
height: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-page .banner-tag{
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 24px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.login-page .banner-title{
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-page .banner-divider{
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-page .banner-desc{
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-page .login-section{
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-page .login-card{
|
||||
background: #ffffff;
|
||||
padding: 40px 40px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.login-page .login-title{
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-page .login-subtitle{
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-page .form-group{
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-page .form-input{
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px 0 42px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-page .form-input:focus{
|
||||
border-color: #1e3a8a;
|
||||
}
|
||||
|
||||
.login-page .form-input::placeholder{
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.login-page .input-icon{
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-page .captcha-group{
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page .captcha-group .form-group{
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-page .captcha-input{
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.login-page .captcha-input + .input-icon{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-page .captcha-image{
|
||||
width: 110px;
|
||||
height: 44px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4px;
|
||||
color: #1e3a8a;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.login-page .form-bottom-links{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-page .form-bottom-links .bottom-link{
|
||||
color: #1e3a8a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-page .form-bottom-links .bottom-link:hover{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-page .login-btn{
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: #1e3a8a;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.login-page .login-btn:hover{
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.login-page .footer{
|
||||
padding: 20px 60px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-page .footer p{ margin-bottom: 2px; }
|
||||
|
||||
/* 弹窗 */
|
||||
.login-page .modal-overlay{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.login-page .modal-overlay.active{ display: flex; }
|
||||
|
||||
.login-page .modal{
|
||||
background: #fff;
|
||||
padding: 32px 36px;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.login-page .modal-title{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-page .user-type-list{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-page .user-type-item{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-page .user-type-item:hover{
|
||||
border-color: #1e3a8a;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.login-page .user-type-item input[type="radio"]{
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
accent-color: #1e3a8a;
|
||||
}
|
||||
|
||||
.login-page .user-type-item .info{ flex: 1; }
|
||||
|
||||
.login-page .user-type-item .name{
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.login-page .user-type-item .desc{
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.login-page .modal-actions{
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-page .modal-btn{
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.login-page .modal-btn.primary{
|
||||
background: #1e3a8a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-page .modal-btn.primary:hover{ background: #1e40af; }
|
||||
|
||||
.login-page .modal-btn.secondary{
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.login-page .modal-btn.secondary:hover{
|
||||
border-color: #1e3a8a;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
padding: 30px 30px;
|
||||
gap: 30px;
|
||||
}
|
||||
.banner-section { width: 100%; max-width: 100%; }
|
||||
.banner-inner { height: auto; padding: 40px 30px; }
|
||||
.login-section { width: 100%; max-width: 400px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header { padding: 0 20px; }
|
||||
.main-content { padding: 20px; }
|
||||
.login-card { padding: 30px 24px; }
|
||||
.footer { padding: 16px 20px; }
|
||||
}
|
||||
|
||||
/* === 用户要求: 左侧保持 440px 不动, 右侧登录卡片高度追平左侧, 两栏在 main-content 中居中 === */
|
||||
.login-page .main-content { align-items: center; }
|
||||
.login-page .login-card { height: 440px; display: flex; flex-direction: column; justify-content: center; }
|
||||
|
||||
/* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
.modal-overlay.active { opacity: 1; visibility: visible; }
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
padding: 32px 36px;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 20px; text-align: center; }
|
||||
.user-type-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; }
|
||||
.user-type-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.user-type-item:hover { border-color: var(--brand-primary, #1890ff); background: #f7faff; }
|
||||
.user-type-item input[type="radio"] {
|
||||
width: 18px; height: 18px;
|
||||
accent-color: var(--brand-primary, #1890ff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-type-item .info { flex: 1; }
|
||||
.user-type-item .name { font-size: 15px; color: #1a1a1a; font-weight: 500; margin-bottom: 2px; }
|
||||
.user-type-item .desc { font-size: 12px; color: #909399; }
|
||||
|
||||
.modal-actions { display: flex; gap: 12px; }
|
||||
.modal-btn {
|
||||
flex: 1; height: 40px;
|
||||
border: none; cursor: pointer;
|
||||
font-size: 14px; font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.modal-btn.secondary { background: #f5f7fa; color: #606266; }
|
||||
.modal-btn.secondary:hover { background: #ebeef5; }
|
||||
.modal-btn.primary { background: var(--brand-primary, #1890ff); color: #fff; }
|
||||
.modal-btn.primary:hover { background: var(--brand-primary-deep, #096dd9); }
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<div class="page-card">
|
||||
<div class="page-title">新执行单位(供应商)注册</div>
|
||||
|
||||
<el-steps :active="step" finish-status="success" simple class="steps">
|
||||
<el-step title="基本信息" />
|
||||
<el-step title="注册成功" />
|
||||
</el-steps>
|
||||
|
||||
<div class="step-body">
|
||||
<!-- ============ Step 0: 基本信息 (按 proto 供应商注册.html) ============ -->
|
||||
<!-- TODO: 后续如要做 sponsor 也用同样模板, 拆出 BizOrgRegisterVO 公共组件 -->
|
||||
<template v-if="step === 0">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="企业名称" prop="unitName">
|
||||
<el-input v-model="form.unitName" placeholder="请输入企业全称" maxlength="200" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="企业性质" prop="businessNature">
|
||||
<el-select v-model="form.businessNature" placeholder="请选择企业性质" style="width:100%">
|
||||
<el-option label="私营" value="私营" />
|
||||
<el-option label="国营" value="国营" />
|
||||
<el-option label="中外合资" value="中外合资" />
|
||||
<el-option label="外资" value="外资" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号码" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="短信验证码" prop="smsCode">
|
||||
<div class="sms-row">
|
||||
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" />
|
||||
<el-button class="sms-btn" :disabled="smsCountdown > 0 || !form.phone" @click="sendSms">
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="登录密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">《平台协议》</a></el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :disabled="!agreed" :loading="submitting" @click="onSubmit" style="width:100%;">
|
||||
同意协议并注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<!-- ============ Step 1: 注册成功 ============ -->
|
||||
<template v-else>
|
||||
<el-result icon="success" title="注册成功" sub-title="我们会在 3 个工作日内完成审核">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/login')">前往登录</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="login-link">
|
||||
已有账号? <a href="javascript:void(0)" @click="$router.push('/login')">立即登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台协议弹窗 -->
|
||||
<el-dialog v-model="showAgreement" title="平台协议" width="560px">
|
||||
<div class="agreement-content">
|
||||
<p>1. 本平台仅提供项目撮合与流程管理服务, 不参与任何具体项目的执行.</p>
|
||||
<p>2. 注册企业需提供真实有效的资质材料, 平台有权随时核查.</p>
|
||||
<p>3. 平台对企业的商业信息负有保密义务.</p>
|
||||
<p>4. 注册即表示同意上述条款.</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { registerExecutor } from '@/api/public'
|
||||
|
||||
const step = ref(0)
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
const agreed = ref(false)
|
||||
const showAgreement = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
unitName: '',
|
||||
businessNature: '',
|
||||
phone: '',
|
||||
smsCode: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
|
||||
{ min: 4, max: 20, message: '用户名长度 4-20 位', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' }
|
||||
],
|
||||
unitName: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
|
||||
businessNature: [{ required: true, message: '请选择企业性质', trigger: 'change' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
|
||||
],
|
||||
smsCode: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入登录密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入登录密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, cb) => {
|
||||
if (value !== form.password) cb(new Error('两次密码输入不一致'))
|
||||
else cb()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ===== 短信验证码 =====
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer = null
|
||||
const smsUuid = ref('')
|
||||
async function sendSms() {
|
||||
if (!/^1\d{10}$/.test(form.phone)) {
|
||||
return ElMessage.warning('请先输入正确的手机号')
|
||||
}
|
||||
try {
|
||||
const resp = await request({
|
||||
url: '/business/auth/registerSendSms',
|
||||
method: 'post',
|
||||
data: { phone: form.phone }
|
||||
})
|
||||
smsUuid.value = resp?.data || ''
|
||||
ElMessage.success('验证码已发送')
|
||||
smsCountdown.value = 60
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
ElMessage.warning(e?.msg || '验证码发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 提交 =====
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await registerExecutor({ ...form, uuid: smsUuid.value })
|
||||
ElMessage.success('注册成功')
|
||||
step.value = 1
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '注册失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.steps { max-width: 720px; margin: 24px auto 0; }
|
||||
.step-body { max-width: 640px; margin: 32px auto 0; }
|
||||
.sms-row { display: flex; gap: 12px; width: 100%; }
|
||||
.sms-row .el-input { flex: 1; }
|
||||
.sms-btn { flex-shrink: 0; }
|
||||
.agreement-content { line-height: 1.8; color: #606266; }
|
||||
.agreement-content p { margin-bottom: 12px; }
|
||||
.login-link { text-align: center; margin-top: 16px; font-size: 14px; color: #606266; }
|
||||
.login-link a { color: #1890ff; text-decoration: none; margin-left: 4px; }
|
||||
.login-link a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<div class="page-card">
|
||||
<div class="page-title">专家注册</div>
|
||||
|
||||
<!-- Stepper (原型: 1 基本信息 → 2 注册成功) -->
|
||||
<div class="stepper">
|
||||
<div class="step">
|
||||
<div class="step-circle" :class="{ active: step === 0, done: step > 0 }">1</div>
|
||||
<div class="step-label" :class="{ active: step === 0, done: step > 0 }">基本信息</div>
|
||||
</div>
|
||||
<div class="step-line" :class="{ done: step > 0 }"></div>
|
||||
<div class="step">
|
||||
<div class="step-circle" :class="{ active: step === 1 }">2</div>
|
||||
<div class="step-label" :class="{ active: step === 1 }">注册成功</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单 (原型 1:1 抄: 11 个字段) -->
|
||||
<el-form v-if="step === 0" :model="form" :rules="rules" ref="formRef" label-width="100px" style="max-width:760px;margin:24px auto;">
|
||||
<el-form-item label="姓名" prop="realName">
|
||||
<el-input v-model="form.realName" placeholder="请输入您的真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位" prop="workUnit">
|
||||
<el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="科室" prop="department">
|
||||
<DoctorDeptSelect v-model="form.department" placeholder="请输入所在科室" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称" prop="doctorTitle">
|
||||
<DoctorTitleSelect v-model="form.doctorTitle" placeholder="请输入职称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号码" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信验证码" prop="code">
|
||||
<div class="sms-row">
|
||||
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" />
|
||||
<el-button :disabled="smsCountdown > 0 || !form.phone" @click="sendCode">
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="执业证书/证明">
|
||||
<OssImageUploader v-model="form.licenseCertUrl" dir="ry8080/cert/license/" placeholder="+" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称证明">
|
||||
<OssImageUploader v-model="form.titleCertUrl" dir="ry8080/cert/title/" placeholder="+" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">《隐私保护条款及授权书》</a></el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :disabled="!agreed" @click="onSubmit" :loading="submitting" style="width: 100%;">同意协议并注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 注册成功 -->
|
||||
<div v-else class="success-page">
|
||||
<el-result icon="success" title="注册成功" sub-title="请等待管理员审核,审核通过后即可登录">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/login')">前往登录</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私协议弹窗 -->
|
||||
<el-dialog v-model="showAgreement" title="隐私保护条款及授权书" width="600px">
|
||||
<div class="agreement-content">
|
||||
<p>1. 我们仅在为您提供专家注册、项目评审等服务的过程中,收集您的姓名、手机号、工作单位、职称等必要信息。</p>
|
||||
<p>2. 您的个人信息将严格保密,仅用于项目流程内部使用,不会向任何第三方披露。</p>
|
||||
<p>3. 您有权随时查看、更正、删除您的个人信息。</p>
|
||||
<p>4. 注册即表示您同意上述条款。</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { registerExpert } from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
|
||||
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
|
||||
import OssImageUploader from '@/components/OssImageUploader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const step = ref(0)
|
||||
const submitting = ref(false)
|
||||
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
realName: '',
|
||||
workUnit: '',
|
||||
department: '',
|
||||
doctorTitle: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
licenseCertUrl: '',
|
||||
titleCertUrl: ''
|
||||
})
|
||||
const rules = {
|
||||
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
|
||||
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
|
||||
doctorTitle: [{ required: true, message: '请选择职称', trigger: 'change' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
|
||||
],
|
||||
code: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入登录密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入登录密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, cb) => {
|
||||
if (value !== form.password) cb(new Error('两次密码不一致'))
|
||||
else cb()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const agreed = ref(false)
|
||||
const showAgreement = ref(false)
|
||||
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer = null
|
||||
const smsUuid = ref('') // 短信验证码 uuid (verify 时回传)
|
||||
async function sendCode() {
|
||||
if (!/^1\d{10}$/.test(form.phone)) {
|
||||
return ElMessage.warning('请先输入正确的手机号')
|
||||
}
|
||||
try {
|
||||
const resp = await request({ url: '/business/auth/registerSendSms', method: 'post', data: { phone: form.phone } })
|
||||
smsUuid.value = resp?.data || ''
|
||||
ElMessage.success('验证码已发送')
|
||||
smsCountdown.value = 60
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
ElMessage.warning(e?.msg || '验证码发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
await registerExpert({ ...form, uuid: smsUuid.value })
|
||||
ElMessage.success('注册成功')
|
||||
step.value = 1
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '注册失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stepper { display: flex; align-items: center; justify-content: center; margin: 24px 0 8px; }
|
||||
.step { display: flex; align-items: center; gap: 8px; }
|
||||
.step-circle { width: 28px; height: 28px; border-radius: 50%; background: #fff; border: 1px solid #d9d9d9; color: #909399; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 500; }
|
||||
.step-circle.active { background: #1890ff; border-color: #1890ff; color: #fff; }
|
||||
.step-circle.done { background: #52c41a; border-color: #52c41a; color: #fff; }
|
||||
.step-label { font-size: 14px; color: #909399; }
|
||||
.step-label.active { color: #1890ff; font-weight: 500; }
|
||||
.step-label.done { color: #52c41a; }
|
||||
.step-line { width: 80px; height: 1px; background: #d9d9d9; margin: 0 12px; }
|
||||
.step-line.done { background: #52c41a; }
|
||||
.sms-row { display: flex; gap: 12px; width: 100%; }
|
||||
.sms-row .el-input { flex: 1; }
|
||||
.agreement-content { line-height: 1.8; color: #606266; }
|
||||
.agreement-content p { margin-bottom: 12px; }
|
||||
.success-page { padding: 60px 0; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<div class="page-card">
|
||||
<div class="page-title">赞助方注册</div>
|
||||
<el-steps :active="step" finish-status="success" simple class="steps">
|
||||
<el-step title="账号信息" /><el-step title="单位信息" />
|
||||
</el-steps>
|
||||
<div class="step-body">
|
||||
<template v-if="step === 0">
|
||||
<el-form :model="form" label-width="100px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" /></el-form-item></el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form :model="form" label-width="100px"><el-form-item label="单位名称"><el-input v-model="form.unitName" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contact" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item><el-form-item label="赞助意向"><el-input v-model="form.intent" type="textarea" /></el-form-item></el-form>
|
||||
</template>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:16px">
|
||||
<el-button @click="prev" :disabled="step === 0">上一步</el-button>
|
||||
<el-button type="primary" @click="next" v-if="step < 2">下一步</el-button>
|
||||
<el-button @click="$router.push('/login')" v-else>去登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { registerSponsor } from '@/api/public'
|
||||
import { ElMessage } from 'element-plus'
|
||||
const step = ref(0)
|
||||
const form = reactive({ username: '', password: '', unitName: '', contact: '', phone: '', intent: '' })
|
||||
const prev = () => step.value = Math.max(0, step.value - 1)
|
||||
const next = async () => {
|
||||
if (step.value === 1) { try { await registerSponsor(form); ElMessage.success('已提交') } catch { /* demo */ } step.value = 2; return }
|
||||
step.value++
|
||||
}
|
||||
</script>
|
||||
<style scoped>.steps { max-width: 560px; margin: 16px auto; } .step-body { max-width: 560px; margin: 16px auto; }</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
<span @click="goHome" class="link">首页</span>
|
||||
<span class="sep">/</span>
|
||||
<span>账号信息</span>
|
||||
</div>
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<h1 class="page-title">账号信息</h1>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<el-tabs v-model="tab" class="account-tabs">
|
||||
<!-- 个人信息 -->
|
||||
<el-tab-pane label="个人信息" name="profile">
|
||||
<div class="card">
|
||||
<!-- 头像 + 用户信息 -->
|
||||
<div class="avatar-block">
|
||||
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
|
||||
<template v-else>{{ avatarChar }}</template>
|
||||
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
|
||||
</div>
|
||||
<div class="avatar-meta">
|
||||
<div class="name">{{ form.name || '专家账号' }}</div>
|
||||
<div class="role">专家账号</div>
|
||||
<div class="phone-row">
|
||||
<span class="phone-label">联系电话:</span>
|
||||
<span class="phone-value">{{ form.phone || '未设置' }}</span>
|
||||
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
|
||||
<el-icon><EditPen /></el-icon> 修改
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本资料 (2 列) -->
|
||||
<div class="section-title">基本资料</div>
|
||||
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
|
||||
<el-form-item label="专家姓名" :required="true">
|
||||
<el-input v-model="form.name" placeholder="请填写姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="科室" :required="true">
|
||||
<doctor-dept-select v-model="form.department" value-field="label" placeholder="输入关键词搜索" filterable />
|
||||
</el-form-item>
|
||||
<el-form-item label="地区" :required="true">
|
||||
<area-cascader v-model="form.region" format="string" join-sep="/" placeholder="请选择省/市/区" />
|
||||
</el-form-item>
|
||||
<el-form-item label="证件号码" :required="true">
|
||||
<el-input v-model="form.idCardNo" placeholder="请填写证件号码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称" :required="true">
|
||||
<doctor-title-select v-model="form.doctorTitle" value-field="label" placeholder="输入关键词搜索" filterable />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位" :required="true">
|
||||
<el-input v-model="form.workUnit" placeholder="请填写工作单位" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 银行信息 -->
|
||||
<div class="section-title">银行信息</div>
|
||||
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
|
||||
<el-form-item label="开户姓名" :required="true">
|
||||
<el-input v-model="form.bankName" placeholder="请填写开户姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行卡号" :required="true">
|
||||
<el-input v-model="form.bankCardNo" placeholder="请填写银行卡号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户省" :required="true">
|
||||
<el-input v-model="form.bankProvince" placeholder="请填写开户省" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户市" :required="true">
|
||||
<el-input v-model="form.bankCity" placeholder="请填写开户市" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户支行" :required="true">
|
||||
<el-input v-model="form.bankAddress" placeholder="请填写开户支行" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 资质证件 -->
|
||||
<div class="section-title">资质证件</div>
|
||||
<el-form :model="form" class="form-grid form-grid-single" label-position="right" label-width="120px">
|
||||
<el-form-item label="身份证照片">
|
||||
<id-card-uploader
|
||||
v-model:front-url="form.idCardFrontUrl"
|
||||
v-model:back-url="form.idCardBackUrl"
|
||||
:dir="'ry8080/idcard/'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="执业证书/证明">
|
||||
<oss-image-uploader
|
||||
v-model="form.licenseCertUrl"
|
||||
:dir="'ry8080/license/'"
|
||||
placeholder="+"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="职称证明">
|
||||
<oss-image-uploader
|
||||
v-model="form.titleCertUrl"
|
||||
:dir="'ry8080/title/'"
|
||||
placeholder="+"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-error">{{ saveError }}</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button type="default" @click="onCancel">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<div class="card">
|
||||
<el-form :model="pwd" label-position="right" label-width="120px" class="password-form">
|
||||
<el-form-item label="原密码" :required="true">
|
||||
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" :required="true">
|
||||
<el-input v-model="pwd.new" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" :required="true">
|
||||
<el-input v-model="pwd.confirm" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
<div class="form-error">{{ pwdError }}</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" @click="onChangePwd">保存</el-button>
|
||||
<el-button type="default" @click="onCancelPwd">取消</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 隐藏的 avatar 上传 input -->
|
||||
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
|
||||
|
||||
<!-- 修改手机号 dialog (独立于其它字段) -->
|
||||
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="phoneForm" label-width="100px">
|
||||
<el-form-item label="新手机号" :required="true">
|
||||
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信验证码" :required="true">
|
||||
<div class="sms-row">
|
||||
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
|
||||
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
|
||||
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="form-error">{{ phoneError }}</div>
|
||||
<template #footer>
|
||||
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
|
||||
</template>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import request from '@/utils/request'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
import AreaCascader from '@/components/AreaCascader.vue'
|
||||
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
|
||||
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
|
||||
import IdCardUploader from '@/components/IdCardUploader.vue'
|
||||
import OssImageUploader from '@/components/OssImageUploader.vue'
|
||||
|
||||
const store = useUserStore()
|
||||
const tab = ref('profile')
|
||||
|
||||
// 短信验证码状态 (修改手机号 dialog 用)
|
||||
const phoneDialogVisible = ref(false)
|
||||
const phoneSmsCountdown = ref(0)
|
||||
let phoneSmsTimer = null
|
||||
const phoneSmsUuid = ref('')
|
||||
const phoneForm = reactive({ phone: '', smsCode: '' })
|
||||
const phoneError = ref('')
|
||||
const phoneSaving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
workUnit: '',
|
||||
department: '',
|
||||
doctorTitle: '',
|
||||
region: '',
|
||||
idCardNo: '',
|
||||
idCardFrontUrl: '',
|
||||
idCardBackUrl: '',
|
||||
licenseCertUrl: '',
|
||||
titleCertUrl: '',
|
||||
bankName: '',
|
||||
bankCardNo: '',
|
||||
bankProvince: '',
|
||||
bankCity: '',
|
||||
bankAddress: ''
|
||||
})
|
||||
|
||||
const pwd = reactive({ old: '', new: '', confirm: '' })
|
||||
const pwdError = ref('')
|
||||
|
||||
const saveError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const avatarUrl = ref(store.user?.avatar || '')
|
||||
const avatarChar = computed(() => (form.name || '专').slice(0, 1))
|
||||
const avatarUploading = ref(false)
|
||||
const avatarInput = ref(null)
|
||||
|
||||
let snapshot = ref(null)
|
||||
|
||||
function onAvatarClick() {
|
||||
if (avatarUploading.value) return
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAvatarFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = '' // 重置以便可重选
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return ElMessage.warning('请选择图片文件')
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return ElMessage.warning('图片大小不能超过 5MB')
|
||||
}
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, 'ry8080/avatar/')
|
||||
await request({
|
||||
url: '/system/user/profile/avatarUrl',
|
||||
method: 'put',
|
||||
data: { avatarUrl: url }
|
||||
})
|
||||
avatarUrl.value = url
|
||||
if (store.user) {
|
||||
store.user.avatar = url
|
||||
// 同步 localStorage, 否则刷新后头像丢失
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
ElMessage.success('头像更新成功')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.msg || '头像上传失败')
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||
window.parent.goHome?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExpertProfile() {
|
||||
try {
|
||||
const res = await request({ url: '/business/expert/profile', method: 'get' })
|
||||
const d = res.data
|
||||
if (!d) return
|
||||
form.name = d.name || ''
|
||||
form.phone = d.phone || ''
|
||||
form.workUnit = d.workUnit || ''
|
||||
form.department = d.department || ''
|
||||
form.doctorTitle = d.title || ''
|
||||
form.region = d.region || ''
|
||||
form.idCardNo = d.idCard || ''
|
||||
form.bankCardNo = d.bankCard || ''
|
||||
form.bankName = d.bankName || ''
|
||||
form.bankProvince = d.bankProvince || ''
|
||||
form.bankCity = d.bankCity || ''
|
||||
form.bankAddress = d.bankAddress || ''
|
||||
form.idCardFrontUrl = d.idCardFrontUrl || ''
|
||||
form.idCardBackUrl = d.idCardBackUrl || ''
|
||||
form.licenseCertUrl = d.practiceCertUrl || ''
|
||||
form.titleCertUrl = d.titleCertUrl || ''
|
||||
} catch (e) {
|
||||
console.warn('loadExpertProfile:', e?.msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserProfile() {
|
||||
try {
|
||||
const res = await request({ url: '/system/user/profile', method: 'get' })
|
||||
const u = res.data
|
||||
if (!u) return
|
||||
if (store.user) {
|
||||
// 同步 store (顺便同步 localStorage, 否则刷新头像丢失)
|
||||
store.user.avatar = u.avatar || store.user.avatar
|
||||
store.user.userName = u.userName || store.user.userName
|
||||
store.user.nickName = u.nickName || store.user.nickName
|
||||
store.user.phonenumber = u.phonenumber || store.user.phonenumber
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
avatarUrl.value = u.avatar || avatarUrl.value
|
||||
} catch (e) {
|
||||
console.warn('loadUserProfile:', e?.msg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
form.name = store.user?.userName || ''
|
||||
form.phone = store.user?.phonenumber || ''
|
||||
avatarUrl.value = store.user?.avatar || ''
|
||||
await Promise.all([loadUserProfile(), loadExpertProfile()])
|
||||
snapshot = ref(JSON.parse(JSON.stringify(form)))
|
||||
})
|
||||
|
||||
function onAvatarChange(url) {
|
||||
avatarUrl.value = url
|
||||
if (store.user) store.user.avatar = url
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saveError.value = ''
|
||||
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankProvince', 'bankCity', 'bankAddress']
|
||||
for (const k of required) {
|
||||
if (!(form[k] || '').toString().trim()) {
|
||||
return ElMessage.warning('请填写完整必填字段')
|
||||
}
|
||||
}
|
||||
// 字段名映射 (前端 → 后端 BizExpert):
|
||||
// idCardNo → idCard, bankCardNo → bankCard, doctorTitle → title,
|
||||
// licenseCertUrl → practiceCertUrl
|
||||
const payload = {
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
workUnit: form.workUnit,
|
||||
department: form.department,
|
||||
title: form.doctorTitle,
|
||||
region: form.region,
|
||||
idCard: form.idCardNo,
|
||||
bankCard: form.bankCardNo,
|
||||
bankName: form.bankName,
|
||||
bankProvince: form.bankProvince,
|
||||
bankCity: form.bankCity,
|
||||
bankAddress: form.bankAddress,
|
||||
idCardFrontUrl: form.idCardFrontUrl,
|
||||
idCardBackUrl: form.idCardBackUrl,
|
||||
practiceCertUrl: form.licenseCertUrl,
|
||||
titleCertUrl: form.titleCertUrl
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await request({
|
||||
url: '/business/expert/profile',
|
||||
method: 'put',
|
||||
data: payload
|
||||
})
|
||||
snapshot.value = JSON.parse(JSON.stringify(form))
|
||||
ElMessage.success('个人信息已保存')
|
||||
} catch (e) {
|
||||
saveError.value = e?.msg || '保存失败'
|
||||
ElMessage.error(saveError.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (snapshot.value) {
|
||||
Object.assign(form, JSON.parse(JSON.stringify(snapshot.value)))
|
||||
}
|
||||
ElMessage.info('已恢复修改前内容')
|
||||
}
|
||||
|
||||
function onChangePwd() {
|
||||
pwdError.value = ''
|
||||
if (!pwd.old) return (pwdError.value = '请先填写原密码')
|
||||
if (pwd.new.length < 6) return (pwdError.value = '新密码长度不能少于 6 位')
|
||||
if (pwd.new !== pwd.confirm) return (pwdError.value = '两次输入的新密码不一致')
|
||||
request({
|
||||
url: '/system/user/profile/updatePwd',
|
||||
method: 'put',
|
||||
data: { oldPassword: pwd.old, newPassword: pwd.new }
|
||||
}).then(() => {
|
||||
ElMessage.success('密码修改成功')
|
||||
pwd.old = pwd.new = pwd.confirm = ''
|
||||
}).catch(e => {
|
||||
pwdError.value = e?.msg || '密码修改失败'
|
||||
ElMessage.error(pwdError.value)
|
||||
})
|
||||
}
|
||||
|
||||
function onCancelPwd() {
|
||||
pwd.old = pwd.new = pwd.confirm = ''
|
||||
pwdError.value = ''
|
||||
}
|
||||
|
||||
// 手机号 dialog
|
||||
function openPhoneDialog() {
|
||||
phoneForm.phone = ''
|
||||
phoneForm.smsCode = ''
|
||||
phoneError.value = ''
|
||||
phoneDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function sendSms() {
|
||||
const phone = (phoneForm.phone || '').trim()
|
||||
if (!/^(1[0-9])\d{9}$/.test(phone)) {
|
||||
return ElMessage.warning('请输入正确的手机号')
|
||||
}
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/system/user/profile/sendSmsCode',
|
||||
method: 'post',
|
||||
params: { phone }
|
||||
})
|
||||
phoneSmsUuid.value = res.uuid || ''
|
||||
ElMessage.success('验证码已发送')
|
||||
phoneSmsCountdown.value = 60
|
||||
phoneSmsTimer = setInterval(() => {
|
||||
phoneSmsCountdown.value--
|
||||
if (phoneSmsCountdown.value <= 0) {
|
||||
clearInterval(phoneSmsTimer)
|
||||
phoneSmsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangePhone() {
|
||||
phoneError.value = ''
|
||||
if (!phoneForm.phone || !phoneForm.smsCode || !phoneSmsUuid.value) {
|
||||
return (phoneError.value = '请填写完整信息')
|
||||
}
|
||||
phoneSaving.value = true
|
||||
try {
|
||||
await request({
|
||||
url: '/system/user/profile/changePhone',
|
||||
method: 'post',
|
||||
data: {
|
||||
phone: phoneForm.phone,
|
||||
smsCode: phoneForm.smsCode,
|
||||
uuid: phoneSmsUuid.value
|
||||
}
|
||||
})
|
||||
form.phone = phoneForm.phone
|
||||
if (store.user) store.user.phonenumber = phoneForm.phone
|
||||
ElMessage.success('手机号修改成功')
|
||||
phoneDialogVisible.value = false
|
||||
} catch (e) {
|
||||
phoneError.value = e?.msg || '修改失败'
|
||||
} finally {
|
||||
phoneSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (phoneSmsTimer) clearInterval(phoneSmsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; }
|
||||
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.breadcrumb .link { color: #409eff; cursor: pointer; }
|
||||
.breadcrumb .link:hover { text-decoration: underline; }
|
||||
.breadcrumb .sep { margin: 0 6px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: #262626; margin: 0 0 16px; }
|
||||
|
||||
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.account-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
.form-grid-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid :deep(.el-form-item.grid-span-2) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.form-grid :deep(.el-form-item) { margin-bottom: 14px; }
|
||||
.form-grid :deep(.el-form-item__label) { color: #606266; }
|
||||
|
||||
/* 头像区 (含电话显示) */
|
||||
.avatar-block { display: flex; align-items: center; gap: 24px; padding: 16px 0 24px; border-bottom: 1px dashed #ebeef5; margin-bottom: 24px; }
|
||||
.avatar-circle {
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 600;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.avatar-circle:hover { transform: scale(1.04); }
|
||||
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
|
||||
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.avatar-tip {
|
||||
font-size: 11px; opacity: 0.85; margin-top: 4px;
|
||||
position: absolute; bottom: 4px; left: 0; right: 0;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
|
||||
.avatar-meta .role { font-size: 13px; color: #909399; }
|
||||
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
|
||||
.phone-label { color: #909399; }
|
||||
.phone-value { color: #262626; font-weight: 500; }
|
||||
.btn-edit-phone { padding: 0 4px; }
|
||||
|
||||
/* 银行信息 + 密码 form 单列 */
|
||||
.password-form { max-width: 480px; }
|
||||
.password-form :deep(.el-form-item) { margin-bottom: 16px; }
|
||||
|
||||
.form-error { color: #f56c6c; font-size: 13px; margin: 4px 0 12px; min-height: 18px; }
|
||||
.form-actions { margin-top: 20px; display: flex; gap: 12px; }
|
||||
|
||||
/* 手机号 dialog 验证码 row */
|
||||
.sms-row { display: flex; gap: 8px; align-items: center; width: 100%; }
|
||||
.sms-row .el-input { flex: 1; }
|
||||
.btn-sms { flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="page-card doctor-home">
|
||||
<div class="breadcrumb">首页</div>
|
||||
|
||||
<!-- 欢迎栏 -->
|
||||
<div class="welcome-bar">
|
||||
<div class="welcome-text">
|
||||
<h2>下午好,{{ store.user?.userName || '专家' }}专家</h2>
|
||||
<p>欢迎使用项目管理系统</p>
|
||||
</div>
|
||||
<div class="welcome-time">
|
||||
<div class="now">{{ nowTime }}</div>
|
||||
<div class="date">{{ nowDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待参加的会议 + 待签署的协议 -->
|
||||
<div class="cols-row">
|
||||
<div class="section">
|
||||
<h2 class="section-title">
|
||||
待参加的会议
|
||||
<a class="more" @click.prevent="$router.push('/doctor/meetings')">更多 →</a>
|
||||
</h2>
|
||||
<ul class="simple-list">
|
||||
<li class="simple-item" v-for="m in upcomingMeetings" :key="m.meetingId" @click="$router.push('/doctor/meetings')">
|
||||
<div class="item-main">
|
||||
<span class="item-title">{{ m.meetingName || m.title }}</span>
|
||||
</div>
|
||||
<span class="item-status">{{ formatTime(m.startTime) }}</span>
|
||||
</li>
|
||||
<li v-if="!upcomingMeetings.length" class="empty">暂无待参加会议</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2 class="section-title">
|
||||
待签署的协议
|
||||
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 →</a>
|
||||
</h2>
|
||||
<ul class="simple-list">
|
||||
<li class="simple-item" v-for="s in pendingAgreements" :key="s.subId">
|
||||
<div class="item-main">
|
||||
<span class="item-title">{{ s.title }}</span>
|
||||
</div>
|
||||
<span class="item-status" :class="{ done: s.status === 'done' }">
|
||||
{{ s.status === 'done' ? '已完成' : '待签署' }}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知消息 -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
通知消息
|
||||
<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="n.read = true">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
|
||||
const store = useUserStore()
|
||||
|
||||
const upcomingMeetings = ref([])
|
||||
const pendingAgreements = ref([])
|
||||
const notices = ref([])
|
||||
|
||||
const nowTime = ref('')
|
||||
const nowDate = ref('')
|
||||
let timer = null
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
const today = new Date()
|
||||
const diff = Math.floor((d - today) / 86400000)
|
||||
if (diff === 0) return d.toTimeString().slice(0, 5)
|
||||
if (diff === 1) return '明天'
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
nowTime.value = now.toTimeString().slice(0, 5)
|
||||
nowDate.value = now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
// 待参加会议
|
||||
try {
|
||||
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 })
|
||||
upcomingMeetings.value = (data?.rows || []).slice(0, 5)
|
||||
} catch (e) { upcomingMeetings.value = [] }
|
||||
|
||||
// 待签署协议
|
||||
try {
|
||||
const { data } = await bizList('submission', { pageNum: 1, pageSize: 5 })
|
||||
pendingAgreements.value = (data?.rows || []).slice(0, 5).map(s => ({
|
||||
subId: s.subId,
|
||||
title: s.title,
|
||||
status: s.status === '审核通过' ? 'done' : 'pending'
|
||||
}))
|
||||
} catch (e) { 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 || i >= 2
|
||||
}))
|
||||
} catch (e) { notices.value = [] }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
timer = setInterval(updateClock, 60000)
|
||||
load()
|
||||
})
|
||||
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doctor-home { padding: 16px 20px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
|
||||
.welcome-text p { font-size: 13px; opacity: 0.85; }
|
||||
.welcome-time .now { font-size: 14px; font-weight: 500; }
|
||||
.welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; }
|
||||
.section { background: #fff; padding: 16px 20px; margin-bottom: 16px; border: 1px solid #f0f0f0; border-radius: 4px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.simple-item { padding: 10px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; }
|
||||
.simple-item:last-child { border-bottom: none; }
|
||||
.simple-item:hover .item-title { color: var(--brand-primary); }
|
||||
.item-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
|
||||
.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; }
|
||||
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
|
||||
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 我参与的会议</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||||
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable>
|
||||
<el-option label="未开始" value="未开始" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已结束" 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>
|
||||
</el-form>
|
||||
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="stageTag(row.currentStage)" size="small">{{ row.currentStage || '-' }}</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-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
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>
|
||||
<template #footer>
|
||||
<el-button @click="signOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!signForm.signedPdfUrl" @click="submitSign">签署劳务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看劳务 dialog: 已签状态显示, 含 PDF iframe 预览 -->
|
||||
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称">{{ detail.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签署状态">已签署</el-descriptions-item>
|
||||
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="pdf-preview">
|
||||
<iframe v-if="detail.signedPdfUrl" :src="detail.signedPdfUrl" 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 @click="laborOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const rows = ref([])
|
||||
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)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('meeting', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) {
|
||||
rows.value = []
|
||||
page.total = 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.projectNo = ''
|
||||
q.meetingName = ''
|
||||
q.currentStage = ''
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function stageTag(s) {
|
||||
if (s === '已结束') return 'info'
|
||||
if (s === '执行中') return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function onViewLabor(row) {
|
||||
detail.value = row
|
||||
laborOpen.value = true
|
||||
}
|
||||
|
||||
function onView(row) {
|
||||
detail.value = row
|
||||
// 复用 detail dialog: 复用 meetingInfo 字段直接展示
|
||||
// 这里直接展示一个只读 dialog, 没有劳务签署按钮
|
||||
ElMessageBox.alert(
|
||||
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${row.currentStage || '-'}`,
|
||||
'会议详情',
|
||||
{ confirmButtonText: '关闭' }
|
||||
)
|
||||
}
|
||||
|
||||
function onDownloadFile(row) {
|
||||
if (!row.invitationUrl) { ElMessage.warning('该会议暂无邀请函附件'); return }
|
||||
const a = document.createElement('a')
|
||||
a.href = row.invitationUrl
|
||||
a.download = `${row.meetingName || '会议'}_邀请函.pdf`
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
function onSign(row) {
|
||||
signForm.meetingId = row.meetingId
|
||||
signForm.projectNo = row.projectNo || ''
|
||||
signForm.meetingName = row.meetingName || ''
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
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) {
|
||||
try {
|
||||
const url = await uploadToOss(opts.file, 'ry8080/labor/signed/')
|
||||
signForm.signedPdfUrl = url
|
||||
ElMessage.success('签字 PDF 上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
function onSignRemove() {
|
||||
signForm.signedPdfUrl = ''
|
||||
signFileList.value = []
|
||||
}
|
||||
|
||||
async function submitSign() {
|
||||
if (!signForm.signedPdfUrl) return ElMessage.warning('请先上传签字 PDF')
|
||||
submitting.value = true
|
||||
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()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '签署失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPdf(url, name) {
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name || '劳务'}_${Date.now()}.pdf`
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
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; }
|
||||
.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; }
|
||||
: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, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 消息通知</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, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
|
||||
const rows = ref([])
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: 50 }); const list = 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
|
||||
}
|
||||
|
||||
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; }
|
||||
.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>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 我报名的项目</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||||
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
|
||||
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="filter-tip">*下载项目公示的红头文件</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="status">{{ row.status || '已报名' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onPublicity(row)">查看公示</el-button>
|
||||
<el-button link type="primary" :disabled="!hasFile(row)" @click="onDownload(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="项目详情" width="560px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目形式">{{ detail.projectForm || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总场次">{{ detail.totalSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总金额">{{ detail.totalAmount || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间" :span="2">{{ detail.endTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList, bizAdd } from '@/api/public'
|
||||
import request from '@/utils/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const q = reactive({ projectNo: '', projectName: '', meetingName: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await request.get('/business/project/myProjects', { params: { ...q, pageNum: page.pageNum, pageSize: page.pageSize } })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.projectNo = ''
|
||||
q.projectName = ''
|
||||
q.meetingName = ''
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function hasFile(row) {
|
||||
// 兼容 publicityFiles JSON 数组或旧字段
|
||||
if (Array.isArray(row.publicityFiles) && row.publicityFiles.length) return true
|
||||
return !!(row.publishUrl || row.supportLetterUrl || row.invitationUrl)
|
||||
}
|
||||
|
||||
function onView(row) {
|
||||
detail.value = row
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function onPublicity(row) {
|
||||
// 跳转到公开门户的项目公示详情
|
||||
const id = row.projectId || row.publishId || row.id
|
||||
if (!id) { ElMessage.warning('该项目暂无公示信息'); return }
|
||||
router.push(`/publicity/${id}`)
|
||||
}
|
||||
|
||||
function onDownload(row) {
|
||||
// 优先 publicityFiles JSON 数组, 否则兼容旧字段
|
||||
let url = ''
|
||||
let name = ''
|
||||
if (Array.isArray(row.publicityFiles) && row.publicityFiles.length) {
|
||||
const f = row.publicityFiles[0]
|
||||
url = f.url; name = f.name || (f.url ? f.url.split('/').pop() : '')
|
||||
} else {
|
||||
url = row.publishUrl || row.supportLetterUrl || row.invitationUrl
|
||||
name = url ? url.split('/').pop() : ''
|
||||
}
|
||||
if (!url) { ElMessage.warning('该项目尚未发布公示,暂无可下载文件'); return }
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name || `公示_${row.projectNo || Date.now()}`
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
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; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.filter-tip { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-bottom: 12px; }
|
||||
.status { font-size: 13px; color: #595959; }
|
||||
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
|
||||
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="submission-detail">
|
||||
<!-- 面包屑 (与 SubmissionNew 一致) -->
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">我的项目设计投稿</a> > 投稿详情
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 (与 SubmissionNew 同款) -->
|
||||
<el-card v-loading="loading" shadow="never" class="detail-card">
|
||||
<el-form label-width="120px" class="readonly-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="策划方案名称">
|
||||
<el-input :model-value="display.title" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目方向">
|
||||
<el-input :model-value="display.direction" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目形式">
|
||||
<el-input :model-value="display.projectForm" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目类别">
|
||||
<el-input :model-value="display.projectCategory" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="设计文件">
|
||||
<div class="readonly-cell">
|
||||
<template v-if="detail.designFileUrl">
|
||||
<el-link type="primary" :href="detail.designFileUrl" target="_blank">查看</el-link>
|
||||
<el-link type="primary" style="margin-left: 12px" :href="detail.designFileUrl" :download="downloadName">下载</el-link>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注">
|
||||
<el-input :model-value="display.remark" type="textarea" :rows="4" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-tag v-if="detail.status !== null && detail.status !== undefined && detail.status !== ''" :type="statusType(detail.status)" disable-transitions>
|
||||
{{ statusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="投稿时间">
|
||||
<el-input :model-value="display.createTime" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 (与 SubmissionNew 同位置同样式) -->
|
||||
<div class="detail-actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
// 显示用: 空值显示 "-", 时间格式化
|
||||
function fmt(v) {
|
||||
if (v === null || v === undefined || v === '') return '-'
|
||||
return v
|
||||
}
|
||||
function fmtTime(t) {
|
||||
if (!t) return '-'
|
||||
try { return new Date(t).toLocaleString('zh-CN', { hour12: false }) } catch { return t }
|
||||
}
|
||||
|
||||
// 状态 → el-tag 类型映射
|
||||
const STATUS_TYPE_MAP = {
|
||||
'待审核': 'warning',
|
||||
'审核通过': 'success',
|
||||
'已退回': 'danger',
|
||||
'未结题': 'info',
|
||||
'已结题': 'success'
|
||||
}
|
||||
function statusType(s) {
|
||||
return STATUS_TYPE_MAP[s] || 'info'
|
||||
}
|
||||
const display = computed(() => ({
|
||||
title: fmt(detail.value.title),
|
||||
direction: fmt(detail.value.direction),
|
||||
projectForm: fmt(detail.value.projectForm),
|
||||
projectCategory: fmt(detail.value.projectCategory),
|
||||
remark: fmt(detail.value.remark),
|
||||
status: fmt(detail.value.status),
|
||||
createTime: fmtTime(detail.value.createTime)
|
||||
}))
|
||||
|
||||
function goBack() {
|
||||
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
|
||||
}
|
||||
|
||||
function downloadName() {
|
||||
const url = detail.value.designFileUrl || ''
|
||||
const last = url.split('/').pop()
|
||||
return last || `submission-${detail.value.subId}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const subId = route.params.subId
|
||||
if (!subId) { ElMessage.warning('参数缺失'); goBack(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(`/business/submission/${subId}`)
|
||||
if (res?.code === 200) {
|
||||
detail.value = res.data || {}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[submission-detail] load failed', e)
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.submission-detail { max-width: 1000px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
|
||||
.detail-card { border-radius: 6px; }
|
||||
.detail-card :deep(.el-card__body) { padding: 24px 28px; }
|
||||
|
||||
/* 只读表单样式 - 模拟 el-input 视觉, 但只显示文字 */
|
||||
.readonly-form :deep(.el-form-item) { margin-bottom: 22px; }
|
||||
.readonly-form :deep(.el-form-item__label) { color: #606266; font-weight: normal; }
|
||||
|
||||
.readonly-cell {
|
||||
min-height: 40px;
|
||||
line-height: 38px;
|
||||
padding: 0 14px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.remark-cell {
|
||||
min-height: 110px;
|
||||
line-height: 1.6;
|
||||
padding: 12px 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.readonly-form :deep(.el-link) { font-weight: 500; }
|
||||
|
||||
.detail-actions { margin-top: 16px; }
|
||||
:deep(.el-button) { border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="submission-new">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">我的项目设计投稿</a> > {{ isEdit ? '修改项目设计投稿' : '新建项目设计投稿' }}
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<el-card v-loading="loadingDetail" shadow="never" class="form-card">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="策划方案名称" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入策划方案名称" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目方向" prop="direction">
|
||||
<el-select v-model="form.direction" placeholder="请选择" style="width:100%">
|
||||
<el-option label="学术项目" value="学术项目" />
|
||||
<el-option label="科研项目" value="科研项目" />
|
||||
<el-option label="共识" value="共识" />
|
||||
<el-option label="指南" value="指南" />
|
||||
<el-option label="会议项目" value="会议项目" />
|
||||
<el-option label="对外交流" value="对外交流" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目形式" prop="projectForm">
|
||||
<el-select v-model="form.projectForm" placeholder="请选择" style="width:100%">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目类别" prop="projectCategory">
|
||||
<el-select v-model="form.projectCategory" placeholder="请选择" style="width:100%">
|
||||
<el-option label="学术会议类" value="学术会议类" />
|
||||
<el-option label="专项科研类" value="专项科研类" />
|
||||
<el-option label="调研征集类" value="调研征集类" />
|
||||
<el-option label="慈善帮扶类" value="慈善帮扶类" />
|
||||
<el-option label="标准制定类" value="标准制定类" />
|
||||
<el-option label="患者援助类" value="患者援助类" />
|
||||
<el-option label="专业培训类" value="专业培训类" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="设计文件" prop="designFileUrl">
|
||||
<el-upload
|
||||
class="design-uploader"
|
||||
drag
|
||||
action="#"
|
||||
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
|
||||
:show-file-list="true"
|
||||
:file-list="designFileList"
|
||||
:before-upload="beforeDesignUpload"
|
||||
:http-request="uploadDesign"
|
||||
:on-remove="onDesignRemove"
|
||||
>
|
||||
<i class="el-icon-upload"></i>
|
||||
<div class="el-upload__text">将设计文件拖到此处,或<em>点击上传</em></div>
|
||||
<div class="el-upload__tip" slot="tip">支持 PDF / Word / 图片, 单文件 ≤ 20MB</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" placeholder="请输入备注 (0/500)" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button @click="confirmCancel">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import request from '@/utils/request'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const designFileList = ref([])
|
||||
const designUploading = ref(false)
|
||||
const isEdit = !!route.params.subId
|
||||
|
||||
const form = reactive({
|
||||
subId: null,
|
||||
title: '',
|
||||
direction: '',
|
||||
projectForm: '',
|
||||
projectCategory: '',
|
||||
designFileUrl: '',
|
||||
status: 'DRAFT',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入策划方案名称', trigger: 'blur' }],
|
||||
direction: [{ required: true, message: '请选择项目方向', trigger: 'change' }],
|
||||
projectForm: [{ required: true, message: '请选择项目形式', trigger: 'change' }],
|
||||
projectCategory: [{ required: true, message: '请选择项目类别', trigger: 'change' }],
|
||||
designFileUrl: [{ required: true, message: '请上传设计文件', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function beforeDesignUpload(file) {
|
||||
const max = 20 * 1024 * 1024
|
||||
if (file.size > max) { ElMessage.error('设计文件不能超过 20MB'); return false }
|
||||
const accept = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg']
|
||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||
if (!accept.includes(ext)) { ElMessage.error('仅支持 PDF / Word / 图片格式'); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadDesign(opts) {
|
||||
const file = opts.file
|
||||
designUploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, 'ry8080/submission/design/')
|
||||
form.designFileUrl = url
|
||||
designFileList.value = [{ name: file.name, url }]
|
||||
ElMessage.success('设计文件上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
||||
// 上传失败时清空 file-list,避免 el-upload 保留失败文件
|
||||
designFileList.value = []
|
||||
} finally {
|
||||
designUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDesignRemove() {
|
||||
form.designFileUrl = ''
|
||||
designFileList.value = []
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const subId = route.params.subId
|
||||
if (!subId) return
|
||||
loadingDetail.value = true
|
||||
try {
|
||||
const res = await request.get(`/business/submission/${subId}`)
|
||||
if (res?.code === 200 && res.data) {
|
||||
const d = res.data
|
||||
form.subId = d.subId
|
||||
form.title = d.title || ''
|
||||
form.direction = d.direction || ''
|
||||
form.projectForm = d.projectForm || ''
|
||||
form.projectCategory = d.projectCategory || ''
|
||||
form.designFileUrl = d.designFileUrl || ''
|
||||
form.status = d.status && String(d.status).trim() ? String(d.status).trim().toUpperCase() : 'DRAFT'
|
||||
form.remark = d.remark || ''
|
||||
// 已上传的设计文件回显到 file-list
|
||||
if (d.designFileUrl) {
|
||||
const name = d.designFileUrl.split('/').pop() || `${d.title || '设计文件'}.pdf`
|
||||
designFileList.value = [{ name, url: d.designFileUrl }]
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
goBack()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[submission-new/edit] loadDetail failed', e)
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
goBack()
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ path: '/doctor/submissions', query: { _t: Date.now() } })
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
// 检查表单是否有内容
|
||||
const hasContent = form.title || form.direction || form.projectForm || form.projectCategory || form.designFileUrl || form.remark
|
||||
if (!hasContent) { goBack(); return }
|
||||
ElMessageBox.confirm('确定取消新建?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (e) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
let payload = { ...form }
|
||||
if (!isEdit) {
|
||||
// 新建: 强制设为 DRAFT(待提交)
|
||||
payload.status = 'DRAFT'
|
||||
// 写入当前登录用户信息
|
||||
payload.submitterId = userStore.user?.userId || null
|
||||
payload.submitterName = userStore.user?.userName || userStore.user?.nickName || ''
|
||||
} else if (!payload.status) {
|
||||
// 修改: 如果状态丢失, 兜底 DRAFT(待提交)
|
||||
payload.status = 'DRAFT'
|
||||
}
|
||||
let res
|
||||
if (isEdit) {
|
||||
// 修改: 走 PUT /business/submission
|
||||
res = await request.put('/business/submission', payload)
|
||||
} else {
|
||||
// 新建: 走 POST /business/submission
|
||||
res = await request.post('/business/submission', payload)
|
||||
}
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(isEdit ? '修改成功' : '新建成功')
|
||||
goBack()
|
||||
} else {
|
||||
ElMessage.error(res?.msg || (isEdit ? '修改失败' : '保存失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[submission-new] save failed', e)
|
||||
ElMessage.error(e?.msg || (isEdit ? '修改失败' : '保存失败'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit) loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.submission-new { max-width: 1000px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
|
||||
.form-card { border-radius: 6px; }
|
||||
:deep(.form-card .el-card__body) { padding: 24px 28px; }
|
||||
|
||||
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||
|
||||
: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, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-button) { border-radius: 4px; }
|
||||
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
|
||||
|
||||
/* 设计文件上传控件: 占满表单行宽 */
|
||||
.design-uploader { width: 100%; }
|
||||
.design-uploader :deep(.el-upload) { width: 100%; }
|
||||
.design-uploader :deep(.el-upload-dragger) { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 我的项目设计投稿</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="投稿名称"><el-input v-model="q.title" placeholder="输入投稿名称" clearable style="width: 200px" /></el-form-item>
|
||||
<el-form-item label="学科方向"><el-input v-model="q.direction" placeholder="输入学科方向" clearable style="width: 160px" /></el-form-item>
|
||||
<el-form-item label="形式">
|
||||
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width: 160px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="q.status" placeholder="请选择" clearable style="width: 180px">
|
||||
<el-option v-for="opt in STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width: 200px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="onCreate">新建投稿</el-button>
|
||||
<el-button @click="onBatch" :disabled="!selected.length">批量提交</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="title" label="投稿名称" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="direction" label="学科方向" width="120" />
|
||||
<el-table-column prop="projectForm" label="形式" width="100" />
|
||||
<el-table-column label="设计文件" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.designFileUrl" type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.status !== null && row.status !== undefined && row.status !== ''" :type="statusType(row.status)" disable-transitions>
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button v-if="canModify(row)" link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button v-if="canSubmit(row)" link type="primary" @click="onSubmit(row)">提交</el-button>
|
||||
<el-button link type="primary" :disabled="!row.designFileUrl" @click="onDownload(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { STATUS_OPTIONS, statusLabel, statusType, isLocked } from '@/utils/submissionStatus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const q = reactive({ title: '', direction: '', projectForm: '', status: '', remark: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('submission', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.title = ''
|
||||
q.direction = ''
|
||||
q.projectForm = ''
|
||||
q.status = ''
|
||||
q.remark = ''
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// 原型规则: "待审核和审核通过后, 操作只显示查看按钮, 无法进行修改"
|
||||
// 字典驱动: 通过 isLocked() 判断, true 表示锁定(待审核 0 / 审核通过 1)
|
||||
function canModify(row) {
|
||||
return !isLocked(row.status)
|
||||
}
|
||||
function canSubmit(row) {
|
||||
// 锁定的不能再次提交; 其他状态(未结题/已结题/已退回/待提交)均可提交进入待审核
|
||||
return !isLocked(row.status)
|
||||
}
|
||||
|
||||
function onSelectionChange(arr) { selected.value = arr }
|
||||
|
||||
function onCreate() {
|
||||
router.push({ path: '/doctor/submission/new' })
|
||||
}
|
||||
function onView(row) {
|
||||
router.push({ path: `/doctor/submission/detail/${row.subId}` })
|
||||
}
|
||||
function onEdit(row) {
|
||||
router.push({ path: `/doctor/submission/edit/${row.subId}` })
|
||||
}
|
||||
function onDownload(row) {
|
||||
if (!row.designFileUrl) { ElMessage.warning('该投稿暂无设计文件'); return }
|
||||
window.open(row.designFileUrl, '_blank')
|
||||
}
|
||||
|
||||
async function onSubmit(row) {
|
||||
if (row.status === 'APPROVED') { ElMessage.info('该投稿已审核通过,无需重复提交'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要提交投稿「${row.title}」吗?提交后将进入审核流程`, '提交确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
// 提交后状态变为 PENDING(待审核)
|
||||
await bizUpdate('submission', { subId: row.subId, status: 'PENDING' })
|
||||
ElMessage.success('已提交,等待审核')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '提交失败') }
|
||||
}
|
||||
|
||||
async function onBatch() {
|
||||
if (!selected.value.length) { ElMessage.warning('请先勾选要提交的投稿'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要批量提交选中的 ${selected.value.length} 条投稿吗?`, '批量提交', { type: 'warning' })
|
||||
} catch { return }
|
||||
let ok = 0
|
||||
for (const r of selected.value) {
|
||||
// 锁定状态 (PENDING 待审核 / APPROVED 审核通过) 跳过提交
|
||||
if (isLocked(r.status)) continue
|
||||
try { await bizUpdate('submission', { subId: r.subId, status: 'PENDING' }); ok++ } catch {}
|
||||
}
|
||||
ElMessage.success(`已提交 ${ok} 条`)
|
||||
load()
|
||||
}
|
||||
|
||||
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; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.status { font-size: 13px; color: #595959; }
|
||||
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
|
||||
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="executor-account">
|
||||
<div class="breadcrumb">首页 / 账号信息</div>
|
||||
<div class="page-card">
|
||||
<el-form :model="form" label-width="120px" style="max-width:520px;">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="form.oldPwd" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="form.newPwd" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="form.confirmPwd" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSave">保存</el-button>
|
||||
<el-button @click="onCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const store = useUserStore()
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
oldPwd: '',
|
||||
newPwd: '',
|
||||
confirmPwd: ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const u = store.user || {}
|
||||
form.name = u.nickName || u.userName || ''
|
||||
form.phone = u.phonenumber || ''
|
||||
})
|
||||
|
||||
function onSave() {
|
||||
if (!form.name) { ElMessage.warning('请输入姓名'); return }
|
||||
if (!form.phone) { ElMessage.warning('请输入手机号'); return }
|
||||
if (form.newPwd || form.confirmPwd) {
|
||||
if (!form.oldPwd) { ElMessage.warning('请输入原密码'); return }
|
||||
if (form.newPwd !== form.confirmPwd) { ElMessage.error('两次新密码不一致'); return }
|
||||
}
|
||||
ElMessage.success('保存成功(占位)')
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
const u = store.user || {}
|
||||
form.name = u.nickName || u.userName || ''
|
||||
form.phone = u.phonenumber || ''
|
||||
form.oldPwd = ''
|
||||
form.newPwd = ''
|
||||
form.confirmPwd = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-account { padding: 0; }
|
||||
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 24px; border: 1px solid #ebeef5; }
|
||||
: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); }
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="executor-labor">
|
||||
<div class="breadcrumb">首页 / 劳务凭证</div>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="q" size="default">
|
||||
<el-form-item label="会议名称:">
|
||||
<el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="专家姓名:">
|
||||
<el-input v-model="q.expertName" clearable placeholder="输入专家姓名" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<p class="tip">*结算前,执行方和支持方无法看到凭证材料菜单,结算后,才能看到结算材料</p>
|
||||
<p class="tip">*结算后,已完结状态 ,执行方不能再进行编辑</p>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="voucherNo" label="凭证编号" width="140" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="expertName" label="专家姓名" width="110" />
|
||||
<el-table-column prop="idCard" label="身份证号" width="180" />
|
||||
<el-table-column prop="bankAccount" label="银行账号" width="220" />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submitDate" label="提交日期" width="160" />
|
||||
<el-table-column label="付款凭证.pdf" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.paymentVoucherUrl" type="primary" @click="previewVoucher(row.paymentVoucherUrl)">查看</el-link>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="success" @click="onUploadVoucher(row)">上传付款凭证</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="劳务凭证详情" width="640px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="凭证编号">{{ detail.voucherNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称" :span="2">{{ detail.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="专家姓名">{{ detail.expertName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="金额">¥ {{ formatMoney(detail.amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="身份证号" :span="2">{{ detail.idCard }}</el-descriptions-item>
|
||||
<el-descriptions-item label="银行账号" :span="2">{{ detail.bankAccount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交日期" :span="2">{{ detail.submitDate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="付款凭证" :span="2">
|
||||
<el-link v-if="detail.paymentVoucherUrl" type="primary" @click="previewVoucher(detail.paymentVoucherUrl)">查看</el-link>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 上传付款凭证 dialog -->
|
||||
<el-dialog v-model="voucherOpen" title="上传付款凭证" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="凭证编号">{{ voucherRow.voucherNo }}</el-form-item>
|
||||
<el-form-item label="会议名称">{{ voucherRow.meetingName }}</el-form-item>
|
||||
<el-form-item label="付款凭证">
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:before-upload="beforeVoucherUpload"
|
||||
:http-request="uploadVoucherPdf"
|
||||
:file-list="voucherFileList"
|
||||
:on-remove="onVoucherRemove"
|
||||
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>
|
||||
<template #footer>
|
||||
<el-button @click="voucherOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!voucherForm.paymentVoucherUrl" @click="submitVoucher">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const q = ref({ meetingName: '', expertName: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
// 付款凭证上传相关
|
||||
const voucherOpen = ref(false)
|
||||
const voucherRow = ref({})
|
||||
const voucherForm = reactive({ voucherId: null, paymentVoucherUrl: '' })
|
||||
const voucherFileList = ref([])
|
||||
const submitting = ref(false)
|
||||
|
||||
function formatMoney(n) {
|
||||
if (n == null) return '0.00'
|
||||
return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('laborVoucher', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.value = { meetingName: '', expertName: '' }
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onView(row) { detail.value = row; detailOpen.value = true }
|
||||
|
||||
// ========== 上传付款凭证 ==========
|
||||
function onUploadVoucher(row) {
|
||||
voucherRow.value = row
|
||||
voucherForm.voucherId = row.voucherId
|
||||
voucherForm.paymentVoucherUrl = row.paymentVoucherUrl || ''
|
||||
voucherFileList.value = row.paymentVoucherUrl ? [{ name: row.paymentVoucherUrl.split('/').pop() || '付款凭证.pdf', url: row.paymentVoucherUrl }] : []
|
||||
voucherOpen.value = true
|
||||
}
|
||||
|
||||
function previewVoucher(url) {
|
||||
if (!url) return
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function beforeVoucherUpload(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 uploadVoucherPdf(opts) {
|
||||
try {
|
||||
const url = await uploadToOss(opts.file, 'ry8080/labor/voucher/')
|
||||
voucherForm.paymentVoucherUrl = url
|
||||
ElMessage.success('上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败: ' + (e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
function onVoucherRemove() {
|
||||
voucherForm.paymentVoucherUrl = ''
|
||||
voucherFileList.value = []
|
||||
}
|
||||
|
||||
async function submitVoucher() {
|
||||
if (!voucherForm.paymentVoucherUrl) return ElMessage.warning('请先上传付款凭证')
|
||||
submitting.value = true
|
||||
try {
|
||||
await bizUpdate('labor', {
|
||||
id: voucherForm.voucherId,
|
||||
paymentVoucherUrl: voucherForm.paymentVoucherUrl
|
||||
})
|
||||
ElMessage.success('付款凭证已保存')
|
||||
voucherOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-labor { padding: 0; }
|
||||
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-card, .page-card { background: #fff; border-radius: 4px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.tip { font-size: 12px; color: #f56c6c; margin: 4px 0; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
: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; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
|
||||
:deep(.el-button--warning) { background: #e6a23c; border-color: #e6a23c; border-radius: 4px; }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="executor-meetings">
|
||||
<div class="breadcrumb">首页 / 会议列表</div>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="q" size="default">
|
||||
<el-form-item label="项目编号:">
|
||||
<el-input v-model="q.projectNo" clearable placeholder="输入项目编号" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称:">
|
||||
<el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否系列会:">
|
||||
<el-select v-model="q.isSeries" clearable placeholder="请选择" style="width:140px">
|
||||
<el-option label="是" value="1" />
|
||||
<el-option label="否" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题:">
|
||||
<el-select v-model="q.isFinished" clearable placeholder="请选择" style="width:140px">
|
||||
<el-option label="已结题" value="1" />
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-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>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<span class="hint">已选 {{ selected.length }} 条</span>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelect">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="120" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="isSeries" label="是否系列会" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isSeries === '1' || row.isSeries === '是' ? 'success' : 'info'" size="small">
|
||||
{{ row.isSeries === '1' || row.isSeries === '是' ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="seriesTopic" label="系列会主题" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="期数/总期数" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.currentStage === '执行中' ? 'success' : 'info'" size="small">{{ row.currentStage || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isFinished" label="是否结题" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFinished === '1' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
|
||||
{{ row.isFinished === '1' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link type="warning" @click="onSupervision(row)">查看监督意见</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 监督意见 弹窗 -->
|
||||
<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-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'
|
||||
|
||||
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
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
|
||||
try {
|
||||
const { data } = await bizList('meeting', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' }
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(arr) { selected.value = arr }
|
||||
|
||||
function onView(row) { view.value = row; viewOpen.value = true }
|
||||
|
||||
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)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-meetings { padding: 0; }
|
||||
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-card, .page-card { background: #fff; border-radius: 4px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.hint { font-size: 13px; color: #909399; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
: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; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="executor-overview">
|
||||
<h1 class="page-title">首页</h1>
|
||||
<p class="hint">*点击数字,跳转到相应的列表页</p>
|
||||
|
||||
<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">
|
||||
<div class="stat-label">已结算数量</div>
|
||||
<div class="stat-value">{{ stats.settledProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/executor/projects">
|
||||
<div class="stat-label">已结题数量</div>
|
||||
<div class="stat-value">{{ stats.finishedProjects }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">消息通知</h2>
|
||||
<ul class="notice-list">
|
||||
<li class="notice-item" v-for="n in notices" :key="n.title">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const stats = ref({ totalProjects: 0, settledProjects: 0, finishedProjects: 0 })
|
||||
const notices = ref([
|
||||
{ title: '结算审核', text: 'ZH-2026-650 结算已通过', time: '2小时前' },
|
||||
{ title: '会议邀请', text: '您有一场新会议待确认', time: '昨天' },
|
||||
{ title: '项目阶段', text: '智慧医院建设项目进入实施阶段', time: '2026-08-01' },
|
||||
{ title: '系统升级公告', text: '系统将于本周日 02:00-04:00 升级维护', time: '2026-07-28' }
|
||||
])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || 0
|
||||
const settled = await bizList('project', { isSettled: '1', pageNum: 1, pageSize: 1 })
|
||||
stats.value.settledProjects = settled.total || 0
|
||||
const finished = await bizList('project', { isFinished: '1', pageNum: 1, pageSize: 1 })
|
||||
stats.value.finishedProjects = finished.total || 0
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-overview { padding: 0; }
|
||||
.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin: 0 0 8px; }
|
||||
.hint { font-size: 12px; color: #f56c6c; margin: 0 0 16px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-card { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; padding: 32px 24px; text-decoration: none; color: inherit; display: block; text-align: center; }
|
||||
.stat-card:hover { border-color: var(--brand-primary); }
|
||||
.stat-label { font-size: 14px; color: #909399; margin-bottom: 16px; }
|
||||
.stat-value { font-size: 40px; font-weight: 700; color: var(--brand-primary); line-height: 1; }
|
||||
.section { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 16px; }
|
||||
.notice-list { list-style: none; padding: 0; margin: 0; }
|
||||
.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 .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title { font-size: 13px; color: #1a1a1a; }
|
||||
.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>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="page-card executor-people">
|
||||
<p class="page-sub">管理执行方人员,分配会务执行角色</p>
|
||||
|
||||
<!-- ========== 筛选区 (按原型 人员管理_1.html top=162) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" /></el-form-item>
|
||||
<el-form-item label="所属公司"><el-input v-model="q.orgName" placeholder="所属公司" clearable style="width:160px" /></el-form-item>
|
||||
<el-form-item label="部门"><el-input v-model="q.department" placeholder="部门" clearable style="width:140px" /></el-form-item>
|
||||
<el-form-item label="职务"><el-input v-model="q.position" placeholder="职务" clearable style="width:140px" /></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>
|
||||
|
||||
<!-- ========== 批量按钮 (top=203: 新建人员 / 批量导入) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="openAdd">新建人员</el-button>
|
||||
<el-button @click="importOpen = true">批量导入</el-button>
|
||||
<span class="filter-tip">*批量导入模板与列表表项一致</span>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 (按原型 列头 top=270: 姓名/手机号/工作单位/部门/职务/角色/状态/操作) ========== -->
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="orgName" label="所属公司" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" min-width="100" />
|
||||
<el-table-column prop="position" label="职务" min-width="100" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.role === '管理员' ? 'warning' : 'info'">{{ row.role || '监察员' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === '正常' ? 'success' : 'danger'">{{ row.status || '正常' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button size="small" link type="primary" @click="onEdit(row)">编辑</el-button>
|
||||
<el-button v-if="(row.status || '正常') === '正常'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
|
||||
<el-button v-else size="small" link type="success" @click="onToggleStatus(row, '恢复')">恢复</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadList" @size-change="loadList" />
|
||||
</div>
|
||||
|
||||
<!-- ========== Dialog: 新增/编辑 ========== -->
|
||||
<el-dialog v-model="formOpen" :title="form._id ? '编辑人员' : '新增人员'" width="520px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="所属公司">
|
||||
<el-input v-model="form.orgName" placeholder="所属公司 (TODO: 改 biz_org 下拉)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门"><el-input v-model="form.department" /></el-form-item>
|
||||
<el-form-item label="职务"><el-input v-model="form.position" /></el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width:100%" :disabled="userRole === '管理员'">
|
||||
<el-option label="管理员" value="管理员" />
|
||||
<el-option label="监察员" value="监察员" />
|
||||
</el-select>
|
||||
<span class="filter-tip">*新建时,管理员只能新建监察员</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 批量导入 ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择文件">
|
||||
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item><span class="filter-tip">*批量导入模板xls与列表表项一致</span></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitImport">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 查看 ========== -->
|
||||
<el-dialog v-model="viewOpen" title="人员详情" width="520px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="姓名">{{ view.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ view.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属公司" :span="2">{{ view.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">{{ view.department }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职务">{{ view.position }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ view.role || '监察员' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ view.status || '正常' }}</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 { bizList, bizAdd, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const userRole = '监察员' // 简化: 默认监察员
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const q = reactive({ name: '', phone: '', orgName: '', department: '', position: '' })
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const formOpen = ref(false)
|
||||
const form = reactive({ _id: '', name: '', phone: '', orgName: '', department: '', position: '', role: '监察员' })
|
||||
const importOpen = ref(false)
|
||||
const view = ref({})
|
||||
const viewOpen = ref(false)
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await bizList('person', { pageNum: page.pageNum, pageSize: page.pageSize, ...q })
|
||||
list.value = r.rows || []
|
||||
page.total = r.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() { Object.assign(q, { name:'',phone:'',orgName:'',department:'',position:'' }); page.pageNum = 1; loadList() }
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, { _id:'', name:'', phone:'', orgName:'', department:'', position:'', role: '监察员' })
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function onView(row) { view.value = row; viewOpen.value = true }
|
||||
|
||||
function onEdit(row) {
|
||||
Object.assign(form, { _id: row.personId, name:row.name, phone:row.phone, orgName:row.orgName, department:row.department, position:row.position, role: row.role || '监察员' })
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name) { ElMessage.error('请填写姓名'); return }
|
||||
try {
|
||||
if (form._id) await bizUpdate('person', { personId: form._id, ...form })
|
||||
else await bizAdd('person', { ...form })
|
||||
ElMessage.success('保存成功')
|
||||
formOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
}
|
||||
|
||||
async function onToggleStatus(row, action) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定${action}「${row.name}」吗?`, action, { type: 'warning' })
|
||||
const newStatus = action === '禁用' ? '禁用' : '正常'
|
||||
await bizUpdate('person', { personId: row.personId, status: newStatus })
|
||||
ElMessage.success(`${action}成功`)
|
||||
loadList()
|
||||
} catch (e) { if (e !== 'cancel') ElMessage.error(e?.msg || `${action}失败`) }
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
ElMessage.info('模板下载中...')
|
||||
}
|
||||
|
||||
function submitImport() {
|
||||
ElMessage.success('导入成功')
|
||||
importOpen.value = false
|
||||
loadList()
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-people { padding: 16px; }
|
||||
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
|
||||
.batch-bar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.filter-tip { color: #909399; font-size: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="executor-projects">
|
||||
<div class="breadcrumb">首页 / 项目列表</div>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="query" size="default">
|
||||
<el-form-item label="项目编号:">
|
||||
<el-input v-model="query.projectNo" clearable placeholder="项目编号" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称:">
|
||||
<el-input v-model="query.projectName" clearable placeholder="项目名称" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目时间:">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:240px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题:">
|
||||
<el-select v-model="query.isFinished" clearable placeholder="请选择" style="width:140px">
|
||||
<el-option label="已结题" value="1" />
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-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>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" :disabled="!selected.length" @click="onAssign">项目分配</el-button>
|
||||
<el-button type="primary" :disabled="!selected.length" @click="onBatchRate">批量评价</el-button>
|
||||
<span class="hint">已选 {{ selected.length }} 条</span>
|
||||
<span class="tip">*支持方管理员在分配项目的时候,只能分配监察员</span>
|
||||
</div>
|
||||
<el-table :data="list" border stripe v-loading="loading" @selection-change="sel=>selected=sel">
|
||||
<el-table-column 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">
|
||||
<template #default="{ row }">{{ row.totalSessions || 0 }}/{{ row.totalPeriods || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column label="总金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatNum(row.totalAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatNum(row.availableAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已支付劳务费" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatNum(row.paidLaborAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已支付会务费" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ formatNum(row.paidMeetingAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行单位评价(支持方)" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-rate v-model="row.ratingScore" disabled show-score :max="5" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFinished === '1' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
|
||||
{{ row.isFinished === '1' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="项目开始时间" width="150" />
|
||||
<el-table-column prop="endTime" label="项目结束时间" width="150" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="onAssign(row)">分配</el-button>
|
||||
<el-button link type="primary" @click="onRate(row)">项目评价</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadList" @size-change="loadList" />
|
||||
</div>
|
||||
<p class="footnote">*点击后可查看具体评价维度,评价标准与供应商系统保持一致</p>
|
||||
</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" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="onSaveAssign">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量评价 弹窗 -->
|
||||
<el-dialog v-model="rateOpen" :title="rateRow ? '项目评价' : '批量评价'" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item v-if="rateRow" label="项目名称">
|
||||
<span>{{ rateRow.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="已选项目">
|
||||
<span>{{ selected.length }} 个</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="评分">
|
||||
<el-rate v-model="rateForm.score" :max="5" show-text />
|
||||
</el-form-item>
|
||||
<el-form-item label="评价说明">
|
||||
<el-input v-model="rateForm.comment" type="textarea" :rows="3" placeholder="请输入评价说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rateOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="onSaveRate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<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.orgName || '-' }}</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const query = reactive({ projectNo: '', projectName: '', isFinished: '' })
|
||||
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)
|
||||
|
||||
const assignOpen = ref(false)
|
||||
const assignRow = ref({})
|
||||
const assignForm = reactive({ assignee: '' })
|
||||
|
||||
const rateOpen = ref(false)
|
||||
const rateRow = ref(null)
|
||||
const rateForm = reactive({ score: 5, comment: '' })
|
||||
|
||||
function formatNum(n) {
|
||||
if (n == null) return '0.00'
|
||||
return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { pageNum: page.pageNum, pageSize: page.pageSize, ...query }
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.startTime = dateRange.value[0]
|
||||
params.endTime = dateRange.value[1]
|
||||
}
|
||||
const r = await bizList('project', params)
|
||||
list.value = r.rows || []
|
||||
page.total = r.total || 0
|
||||
} catch (e) { list.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.projectNo = ''
|
||||
query.projectName = ''
|
||||
query.isFinished = ''
|
||||
dateRange.value = []
|
||||
page.pageNum = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
function viewDetail(row) { detail.value = row; detailOpen.value = true }
|
||||
|
||||
async function loadPeople() {
|
||||
try { const r = await bizList('person', { pageNum: 1, pageSize: 200 }); people.value = (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 }
|
||||
try {
|
||||
await bizUpdate('project', { projectId: assignRow.value.projectId, supervisorId: assignForm.assignee })
|
||||
ElMessage.success('分配成功')
|
||||
assignOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '分配失败') }
|
||||
}
|
||||
|
||||
function onRate(row) {
|
||||
rateRow.value = row
|
||||
rateForm.score = row?.ratingScore || 5
|
||||
rateForm.comment = row?.ratingComment || ''
|
||||
rateOpen.value = true
|
||||
}
|
||||
|
||||
function onBatchRate() {
|
||||
if (!selected.value.length) return
|
||||
rateRow.value = null
|
||||
rateForm.score = 5
|
||||
rateForm.comment = ''
|
||||
rateOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaveRate() {
|
||||
try {
|
||||
let ok = 0
|
||||
const payload = { ratingScore: rateForm.score, ratingComment: rateForm.comment }
|
||||
if (rateRow.value) {
|
||||
await bizUpdate('project', { projectId: rateRow.value.projectId, ...payload })
|
||||
ok = 1
|
||||
} else {
|
||||
for (const r of selected.value) {
|
||||
try { await bizUpdate('project', { projectId: r.projectId, ...payload }); ok++ } catch {}
|
||||
}
|
||||
}
|
||||
ElMessage.success(rateRow.value ? '评价成功' : `已评价 ${ok} 个项目`)
|
||||
rateOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '评价失败') }
|
||||
}
|
||||
|
||||
onMounted(() => { loadList(); loadPeople() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.executor-projects { padding: 0; }
|
||||
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-card, .page-card { background: #fff; border-radius: 4px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.batch-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.hint { font-size: 13px; color: #909399; }
|
||||
.tip { font-size: 12px; color: #f56c6c; margin-left: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.footnote { font-size: 12px; color: #f56c6c; margin-top: 8px; }
|
||||
: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; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep); }
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<!--
|
||||
原型: 账号信息.html
|
||||
SIDEBAR: ['项目列表', '人员管理', '会议管理']
|
||||
列头 (top 285-335, 按 left): 姓名
|
||||
说明: 项目主管的个人账号信息页, 含手机号/修改密码
|
||||
-->
|
||||
<div class="leader-account">
|
||||
<p class="page-sub">查看与维护您的账号信息</p>
|
||||
|
||||
<div class="page-card">
|
||||
<el-form :model="form" label-width="120px" style="max-width:560px">
|
||||
<el-form-item label="账号">
|
||||
<el-input :model-value="store.user?.userName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名">
|
||||
<el-input :model-value="store.user?.nickName || store.user?.userName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-input :model-value="roleLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>修改密码</el-divider>
|
||||
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="form.oldPwd" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="form.newPwd" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="form.confirmPwd" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSave">保存</el-button>
|
||||
<el-button @click="onCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const store = useUserStore()
|
||||
const roleLabel = computed(() => {
|
||||
const r = store.user?.role
|
||||
if (r === 'leader') return '项目主管'
|
||||
if (r === 'sponsor') return '支持方'
|
||||
if (r === 'expert') return '专家'
|
||||
if (r === 'executor') return '执行方'
|
||||
return r || '-'
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
phone: store.user?.phonenumber || '',
|
||||
oldPwd: '', newPwd: '', confirmPwd: ''
|
||||
})
|
||||
|
||||
function onSave() {
|
||||
if (form.newPwd && form.newPwd !== form.confirmPwd) {
|
||||
ElMessage.error('两次输入密码不一致'); return
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
}
|
||||
function onCancel() { form.oldPwd = ''; form.newPwd = ''; form.confirmPwd = '' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.leader-account { padding: 16px; }
|
||||
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 24px; border: 1px solid #ebeef5; }
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<!--
|
||||
原型: 首页.html
|
||||
SIDEBAR: ['项目列表', '人员管理', '会议管理']
|
||||
筛选区 (top 165-205, 按 left): 项目列表, 项目总数量, 已结算数量, 已结题数量
|
||||
列头 (top 285-335, 按 left): 首页
|
||||
-->
|
||||
<div class="leader-home">
|
||||
|
||||
<!-- Row 1: 项目总数量 / 已结算数量 / 已结题数量 -->
|
||||
<div class="stats-grid stats-grid-3">
|
||||
<router-link class="stat-card" to="/leader/projects?isFinished=0">
|
||||
<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="/leader/projects?isSettled=1">
|
||||
<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="/leader/projects?isFinished=1">
|
||||
<div class="stat-label">已结题数量</div>
|
||||
<div class="stat-value">{{ stats.completedProjects }}</div>
|
||||
<div class="stat-desc">查看已结题项目</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: 未执行会议 / 已执行会议 -->
|
||||
<div class="stats-grid stats-grid-2">
|
||||
<router-link class="stat-card" to="/leader/meetings?currentStage=未执行">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
<div class="stat-desc">查看未执行会议</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/leader/meetings?currentStage=已执行">
|
||||
<div class="stat-label">已执行会议</div>
|
||||
<div class="stat-value">{{ stats.executedMeetings }}</div>
|
||||
<div class="stat-desc">查看已执行会议</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<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.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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
|
||||
const stats = ref({
|
||||
totalProjects: 0, settledProjects: 0, completedProjects: 0,
|
||||
pendingMeetings: 0, executedMeetings: 0
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || 0
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.executedMeetings = ms.total || 0
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
.leader-home { padding: 16px; }
|
||||
|
||||
.stats-grid { display: grid; gap: 16px; margin-bottom: 16px; }
|
||||
.stats-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.stats-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
.stat-label { font-size: 14px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.stat-value { font-size: 32px; font-weight: 600; color: #1a1a1a; line-height: 1.2; margin-bottom: 8px; }
|
||||
.stat-desc { font-size: 12px; color: #bfbfbf; }
|
||||
|
||||
.section { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #f0f0f0; }
|
||||
.section-title {
|
||||
font-size: 15px; font-weight: 600; color: #1a1a1a;
|
||||
margin-bottom: 16px; padding-left: 10px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.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 .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); }
|
||||
.stats-grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!--
|
||||
原型: 会议列表.html (执行方 / leader)
|
||||
SIDEBAR: ['项目列表', '人员管理', '会议列表']
|
||||
列头: 项目编号 / 会议名称 / 系列会主题 / 期数 / 当前阶段 / 是否结算 / 操作
|
||||
操作列 (按 left 顺序): 查看 / 查看监察意见 / 确认无误
|
||||
注: 支持方状态机 — 待监管→确认无误(→监管通过)→待结算; 待监管→存在需整改的问题→待整改
|
||||
-->
|
||||
<template>
|
||||
<div class="leader-meetings">
|
||||
<p class="page-sub">查看您参与的会议</p>
|
||||
|
||||
<!-- ========== 筛选区 ========== -->
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="q" size="default">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" clearable placeholder="项目编号" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称">
|
||||
<el-input v-model="q.meetingName" clearable placeholder="会议名称" style="width:180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否系列会">
|
||||
<el-select v-model="q.isSeries" clearable placeholder="全部" style="width:110px">
|
||||
<el-option label="是" value="1" />
|
||||
<el-option label="否" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算">
|
||||
<el-select v-model="q.isSettled" clearable placeholder="全部" style="width:110px">
|
||||
<el-option label="已结算" value="1" />
|
||||
<el-option label="未结算" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="期数">
|
||||
<el-input v-model="q.periodNo" clearable placeholder="期数" style="width:90px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格区 (无批量按钮,按原型) ========== -->
|
||||
<div class="page-card">
|
||||
<el-table :data="rows" border stripe v-loading="loading">
|
||||
<el-table-column prop="projectNo" label="项目编号" min-width="130" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="seriesTopic" label="系列会主题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="periodNo" label="期数" width="80" align="center" />
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center" />
|
||||
<el-table-column label="是否结算" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isSettled === '1' ? 'success' : 'info'" size="small">
|
||||
{{ row.isSettled === '1' ? '已结算' : '未结算' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 操作列按原型 left 顺序: 查看 / 查看监察意见 / 确认无误 -->
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button size="small" link type="primary" @click="openOpinion(row)">
|
||||
{{ hasOpinion(row) ? '查看监察意见' : '监察意见' }}
|
||||
</el-button>
|
||||
<el-button size="small" link type="primary"
|
||||
:disabled="!canConfirm(row)"
|
||||
@click="onConfirm(row)">确认无误</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Dialog: 详情 ========== -->
|
||||
<el-dialog v-model="detailOpen" title="会议详情" width="600px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="会议名称" :span="2">{{ detail.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName || detail.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="期数">{{ detail.periodNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ detail.currentStage }}</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-item label="状态" :span="2">
|
||||
<el-tag size="small">{{ detail.supervisionStatus || detail.currentStage || '-' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 监察意见 (原型 u9911 panel) ========== -->
|
||||
<el-dialog v-model="opinionOpen" title="监察意见" width="560px">
|
||||
<el-form :model="opinionForm" label-width="100px">
|
||||
<el-form-item label="意见选择" required>
|
||||
<el-radio-group v-model="opinionForm.rating">
|
||||
<el-radio value="确认无误">确认无误</el-radio>
|
||||
<el-radio value="存在需整改的问题">存在需整改的问题</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="opinionForm.rating === '存在需整改的问题'" label="详细意见" required>
|
||||
<el-input v-model="opinionForm.content" type="textarea" :rows="6" :maxlength="500" show-word-limit
|
||||
placeholder="请输入整改意见 (最多 500 字)" />
|
||||
</el-form-item>
|
||||
<p class="dialog-hint">*3 个工作日之内如果未填写, 则默认为确认无误</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="opinionOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="opinionSubmitting" @click="submitOpinion">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const q = reactive({
|
||||
projectNo: '',
|
||||
meetingName: '',
|
||||
isSeries: '',
|
||||
isSettled: '',
|
||||
periodNo: ''
|
||||
})
|
||||
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
const opinionOpen = ref(false)
|
||||
const opinionSubmitting = ref(false)
|
||||
const opinionForm = reactive({
|
||||
meetingId: null,
|
||||
rating: '确认无误',
|
||||
content: ''
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await bizList('meeting', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = r.rows || []
|
||||
page.total = r.total || 0
|
||||
} catch (e) {
|
||||
rows.value = []
|
||||
page.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', meetingName: '', isSeries: '', isSettled: '', periodNo: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detail.value = row
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
// 是否已填写过监察意见 — 用于按钮文案切换
|
||||
function hasOpinion(row) {
|
||||
return !!(row && (row.supervisionRating || row.supervisionOpinion))
|
||||
}
|
||||
|
||||
// 仅"待监管/已执行未传材料"阶段允许填写监察意见
|
||||
function canOpinion(row) {
|
||||
if (!row) return false
|
||||
return ['待监管', '已执行未传材料'].includes(row.currentStage)
|
||||
}
|
||||
|
||||
// 仅"待监管"阶段允许确认无误 (已填写监察意见为"确认无误" 或 3 天自动)
|
||||
function canConfirm(row) {
|
||||
if (!row) return false
|
||||
return row.currentStage === '待监管'
|
||||
}
|
||||
|
||||
function openOpinion(row) {
|
||||
opinionForm.meetingId = row.meetingId
|
||||
opinionForm.rating = row.supervisionRating || '确认无误'
|
||||
opinionForm.content = row.supervisionOpinion || ''
|
||||
opinionOpen.value = true
|
||||
}
|
||||
|
||||
async function submitOpinion() {
|
||||
if (!opinionForm.meetingId) return
|
||||
if (!opinionForm.rating) {
|
||||
ElMessage.warning('请选择意见')
|
||||
return
|
||||
}
|
||||
if (opinionForm.rating === '存在需整改的问题' && !opinionForm.content.trim()) {
|
||||
ElMessage.warning('请填写详细意见')
|
||||
return
|
||||
}
|
||||
opinionSubmitting.value = true
|
||||
try {
|
||||
// 选择"存在需整改的问题" → 阶段变为"待整改"
|
||||
// 选择"确认无误" → 阶段变为"监管通过" (原型: 监管通过默认只显示 1 个工作日)
|
||||
const stage = opinionForm.rating === '存在需整改的问题' ? '待整改' : '监管通过'
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: opinionForm.meetingId,
|
||||
supervisionRating: opinionForm.rating,
|
||||
supervisionOpinion: opinionForm.content,
|
||||
supervisionStatus: opinionForm.rating,
|
||||
currentStage: stage
|
||||
})
|
||||
ElMessage.success('监察意见已保存')
|
||||
opinionOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally {
|
||||
opinionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认无误 — 直接更新状态为"监管通过", 1 个工作日后自动变为"待结算"
|
||||
async function onConfirm(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将会议「${row.meetingName || ''}」确认为监管通过吗?`, '确认无误', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认无误',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: row.meetingId,
|
||||
supervisionRating: '确认无误',
|
||||
supervisionStatus: '监管通过',
|
||||
currentStage: '监管通过'
|
||||
})
|
||||
ElMessage.success('已确认无误')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.leader-meetings { padding: 16px; }
|
||||
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
|
||||
.filter-card,
|
||||
.page-card { background: #fff; border-radius: 4px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.dialog-hint { color: #909399; font-size: 12px; margin: 8px 0 0; }
|
||||
|
||||
/* 按钮: var(--brand-primary) 单色蓝, 4px 方角, 无渐变 / 无 translateY */
|
||||
.leader-meetings :deep(.el-button) {
|
||||
border-radius: 4px;
|
||||
background-image: none;
|
||||
}
|
||||
.leader-meetings :deep(.el-button--primary) {
|
||||
--el-button-bg-color: var(--brand-primary);
|
||||
--el-button-border-color: var(--brand-primary);
|
||||
--el-button-hover-bg-color: var(--brand-primary-deep);
|
||||
--el-button-hover-border-color: var(--brand-primary-deep);
|
||||
--el-button-active-bg-color: var(--brand-primary-darker);
|
||||
--el-button-active-border-color: var(--brand-primary-darker);
|
||||
transform: none;
|
||||
}
|
||||
.leader-meetings :deep(.el-button--primary:hover),
|
||||
.leader-meetings :deep(.el-button--primary:focus) {
|
||||
transform: none;
|
||||
}
|
||||
.leader-meetings :deep(.el-button.is-link) {
|
||||
background-image: none;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<!--
|
||||
原型: 项目列表_1.html
|
||||
SIDEBAR: ['项目列表', '人员管理', '会议列表']
|
||||
批量按钮 (top ~220, 按 left): 项目分配 / 批量评价
|
||||
列头 (top 285-335, 按 left): 项目编号 / 项目名称 / 总场次/总期数 / 已执行 / 未执行 / 总金额 /
|
||||
可用金额 / 已支付劳务费 / 已支付会务费 / 执行单位评价(支持方) /
|
||||
是否结题 / 项目开始时间 / 项目结束时间 / 操作
|
||||
操作列 (top 352, 按 left): 查看 / 项目评价 / 分配
|
||||
提示:
|
||||
- 支持方管理员分配时只能分配监察员(赞助方);领导方可分配执行人员
|
||||
- 项目评价: 4 维度评分,标准与供应商系统一致
|
||||
-->
|
||||
<div class="leader-projects">
|
||||
<p class="page-sub">查看您负责执行的项目</p>
|
||||
|
||||
<div class="filter-card">
|
||||
<el-form :inline="true" :model="q" size="default">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" clearable placeholder="项目编号" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<el-input v-model="q.projectName" clearable placeholder="项目名称" style="width:180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="q.projectForm" placeholder="全部" clearable style="width:120px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题">
|
||||
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width:100px">
|
||||
<el-option label="已结题" value="1" />
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算">
|
||||
<el-select v-model="q.isSettled" placeholder="全部" clearable style="width:100px">
|
||||
<el-option label="已结算" value="1" />
|
||||
<el-option label="未结算" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<!-- 批量按钮 (按原型 left 顺序): 项目分配 / 批量评价 -->
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="openBatchAssign">项目分配</el-button>
|
||||
<el-button type="primary" :disabled="!selected.length" @click="onBatchRate">批量评价</el-button>
|
||||
<span class="hint">已选 {{ selected.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" border stripe v-loading="loading" @selection-change="onSelect">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="projectNo" label="项目编号" min-width="140" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.projectNo }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="totalSessions" label="总场次/总期数" width="120" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column label="总金额" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.totalAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用金额" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.availableAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已支付劳务费" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.paidLaborAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已支付会务费" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.paidMeetingAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ratingScore" label="执行单位评价(支持方)" width="150" align="center" />
|
||||
<el-table-column label="是否结题" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFinished === '1' ? 'success' : 'info'" size="small">
|
||||
{{ row.isFinished === '1' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="项目开始时间" min-width="160" />
|
||||
<el-table-column prop="endTime" label="项目结束时间" min-width="160" />
|
||||
<!-- 操作列按原型 left 顺序: 查看 / 分配 / 项目评价 -->
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button size="small" link type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button size="small" link type="primary" @click="openRate(row)">项目评价</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-dialog v-model="detailOpen" title="项目详情" width="640px">
|
||||
<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="总场次/总期数">{{ detail.totalSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已执行/未执行">{{ detail.doneSessions || 0 }} / {{ detail.todoSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总金额">¥{{ formatMoney(detail.totalAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可用金额">¥{{ formatMoney(detail.availableAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付劳务费">¥{{ formatMoney(detail.paidLaborAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付会务费">¥{{ formatMoney(detail.paidMeetingAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行单位评价(支持方)">{{ detail.ratingScore || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结题">
|
||||
<el-tag :type="detail.isFinished === '1' ? 'success' : 'info'">
|
||||
{{ detail.isFinished === '1' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目开始时间">{{ detail.startTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目结束时间">{{ detail.endTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配 dialog -->
|
||||
<el-dialog v-model="assignOpen" :title="assignMode==='batch' ? '批量项目分配' : '项目分配'" width="520px">
|
||||
<el-form :model="assignForm" label-width="100px">
|
||||
<el-form-item v-if="assignMode==='single'" label="项目编号 / 名称">
|
||||
<span>{{ assignForm.projectNo }} / {{ assignForm.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="分配项目">
|
||||
<span>共 {{ assignForm.projectIds.length }} 个项目</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行人员">
|
||||
<el-select v-model="assignForm.executorId" filterable placeholder="请选择执行人员" style="width:100%">
|
||||
<el-option v-for="p in personList" :key="p.personId" :label="p.name" :value="p.personId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配备注">
|
||||
<el-input v-model="assignForm.remark" type="textarea" rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 项目评价 dialog -->
|
||||
<el-dialog v-model="rateOpen" :title="rateMode==='batch' ? '批量项目评价' : '项目评价'" width="520px">
|
||||
<el-form :model="rateForm" label-width="100px">
|
||||
<el-form-item v-if="rateMode==='single'" label="项目编号 / 名称">
|
||||
<span>{{ rateForm.projectNo }} / {{ rateForm.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="评价项目">
|
||||
<span>共 {{ rateForm.projectIds.length }} 个项目</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价维度">
|
||||
<div class="score-row"><span style="width:90px">履约质量:</span><el-rate v-model="rateForm.q1" :max="5" /></div>
|
||||
<div class="score-row"><span style="width:90px">时效响应:</span><el-rate v-model="rateForm.q2" :max="5" /></div>
|
||||
<div class="score-row"><span style="width:90px">配合度:</span><el-rate v-model="rateForm.q3" :max="5" /></div>
|
||||
<div class="score-row"><span style="width:90px">合规安全:</span><el-rate v-model="rateForm.q4" :max="5" /></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价备注">
|
||||
<el-input v-model="rateForm.remark" type="textarea" rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p class="dialog-hint">*评价标准与供应商系统保持一致</p>
|
||||
<template #footer>
|
||||
<el-button @click="rateOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRate">提交评价</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizUpdate, bizAdd } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = reactive({ projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const selected = ref([])
|
||||
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
// 分配
|
||||
const assignOpen = ref(false)
|
||||
const assignMode = ref('single') // 'single' | 'batch'
|
||||
const assignForm = reactive({
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
projectIds: [], executorId: '', remark: ''
|
||||
})
|
||||
const personList = ref([])
|
||||
|
||||
// 评价
|
||||
const rateOpen = ref(false)
|
||||
const rateMode = ref('single') // 'single' | 'batch'
|
||||
const rateForm = reactive({
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
projectIds: [], q1: 0, q2: 0, q3: 0, q4: 0, remark: ''
|
||||
})
|
||||
|
||||
function formatMoney(v) { return Number(v || 0).toFixed(2) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await bizList('project', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = r.rows || []
|
||||
page.total = r.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadPersons() {
|
||||
try {
|
||||
const r = await bizList('person', { pageNum: 1, pageSize: 100 })
|
||||
personList.value = r.rows || []
|
||||
} catch (e) { personList.value = [] }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', projectName: '', projectForm: '', isFinished: '', isSettled: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(arr) { selected.value = arr }
|
||||
|
||||
function openDetail(row) { detail.value = row; detailOpen.value = true }
|
||||
|
||||
// ─── 分配 ───────────────────────────────────────────────
|
||||
function openAssign(row) {
|
||||
assignMode.value = 'single'
|
||||
Object.assign(assignForm, {
|
||||
projectId: row.projectId,
|
||||
projectNo: row.projectNo,
|
||||
projectName: row.projectName,
|
||||
projectIds: [],
|
||||
executorId: '',
|
||||
remark: ''
|
||||
})
|
||||
assignOpen.value = true
|
||||
}
|
||||
|
||||
function openBatchAssign() {
|
||||
if (!selected.value.length) { ElMessage.warning('请先勾选项目'); return }
|
||||
assignMode.value = 'batch'
|
||||
Object.assign(assignForm, {
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
projectIds: selected.value.map(r => r.projectId),
|
||||
executorId: '',
|
||||
remark: ''
|
||||
})
|
||||
assignOpen.value = true
|
||||
}
|
||||
|
||||
async function submitAssign() {
|
||||
if (!assignForm.executorId) { ElMessage.warning('请选择执行人员'); return }
|
||||
const ids = assignMode.value === 'single'
|
||||
? [assignForm.projectId]
|
||||
: assignForm.projectIds
|
||||
let ok = 0
|
||||
for (const pid of ids) {
|
||||
try {
|
||||
await bizUpdate('project', {
|
||||
projectId: pid,
|
||||
executorId: assignForm.executorId,
|
||||
assignRemark: assignForm.remark
|
||||
})
|
||||
ok++
|
||||
} catch (e) { /* 跳过失败项 */ }
|
||||
}
|
||||
ElMessage.success(`已分配 ${ok} 项`)
|
||||
assignOpen.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
// ─── 项目评价 ───────────────────────────────────────────
|
||||
function openRate(row) {
|
||||
rateMode.value = 'single'
|
||||
Object.assign(rateForm, {
|
||||
projectId: row.projectId,
|
||||
projectNo: row.projectNo,
|
||||
projectName: row.projectName,
|
||||
projectIds: [],
|
||||
q1: 0, q2: 0, q3: 0, q4: 0,
|
||||
remark: ''
|
||||
})
|
||||
rateOpen.value = true
|
||||
}
|
||||
|
||||
async function onBatchRate() {
|
||||
if (!selected.value.length) return
|
||||
rateMode.value = 'batch'
|
||||
Object.assign(rateForm, {
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
projectIds: selected.value.map(r => r.projectId),
|
||||
q1: 3, q2: 3, q3: 3, q4: 3,
|
||||
remark: '批量评价'
|
||||
})
|
||||
rateOpen.value = true
|
||||
}
|
||||
|
||||
async function submitRate() {
|
||||
if (!rateForm.q1 && !rateForm.q2 && !rateForm.q3 && !rateForm.q4) {
|
||||
ElMessage.warning('请至少完成一项评分'); return
|
||||
}
|
||||
const items = rateMode.value === 'single'
|
||||
? [{ ...rateForm }]
|
||||
: rateForm.projectIds.map(pid => ({
|
||||
projectId: pid,
|
||||
q1: rateForm.q1, q2: rateForm.q2,
|
||||
q3: rateForm.q3, q4: rateForm.q4,
|
||||
remark: rateForm.remark
|
||||
}))
|
||||
let ok = 0
|
||||
for (const it of items) {
|
||||
try { await bizAdd('projectRating', it); ok++ } catch (e) { /* 跳过失败项 */ }
|
||||
}
|
||||
ElMessage.success(`已提交评价 ${ok} 项`)
|
||||
rateOpen.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadPersons() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.leader-projects { padding: 16px; }
|
||||
.page-sub { font-size: 13px; color: #909399; margin-bottom: 16px; }
|
||||
.filter-card, .page-card { background: #fff; border-radius: 4px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.batch-bar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
|
||||
.hint { color: #909399; font-size: 12px; margin-left: 8px; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.score-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.dialog-hint { color: #909399; font-size: 12px; margin: -8px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="manager-accounts">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看当前登录账号、修改个人信息与密码</div>
|
||||
<el-descriptions :column="2" border style="margin-bottom: 24px">
|
||||
<el-descriptions-item label="账号">{{ profile.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">{{ profile.nickName || profile.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ (profile.roleGroup || '').split(',')[0] || '合规人员' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ profile.status === '0' ? '正常' : '停用' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ profile.phonenumber || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ profile.email || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ profile.createTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-form :model="form" label-width="120px" style="max-width:600px" :rules="rules" ref="formRef">
|
||||
<div class="section-title">修改个人信息</div>
|
||||
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" placeholder="请输入邮箱" /></el-form-item>
|
||||
|
||||
<div class="section-title">修改密码</div>
|
||||
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入新密码" /></el-form-item>
|
||||
<div class="form-tip">* 密码长度 6-20 位,支持数字、字母、特殊字符;留空表示不修改密码</div>
|
||||
<el-form-item><el-button type="primary" :loading="saving" @click="onSave">保存</el-button><el-button @click="onCancel">取消</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const form = reactive({ nickName: '', phonenumber: '', email: '', oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
const profile = ref({})
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const rules = {
|
||||
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phonenumber: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
oldPassword: [{ required: false }],
|
||||
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
|
||||
confirmPassword: [{
|
||||
validator: (rule, val, cb) => {
|
||||
if (form.newPassword && val !== form.newPassword) return cb(new Error('两次输入密码不一致'))
|
||||
cb()
|
||||
}, trigger: 'blur'
|
||||
}],
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const { data } = await request({ url: '/system/user/profile', method: 'get' })
|
||||
profile.value = data || {}
|
||||
form.nickName = profile.value.nickName || ''
|
||||
form.phonenumber = profile.value.phonenumber || ''
|
||||
form.email = profile.value.email || ''
|
||||
} catch { ElMessage.error('获取个人信息失败') }
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (form.newPassword && form.newPassword !== form.confirmPassword) {
|
||||
ElMessage.error('两次输入密码不一致'); return
|
||||
}
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
await request({ url: '/system/user/profile', method: 'put', data: { nickName: form.nickName, phonenumber: form.phonenumber, email: form.email, sex: profile.value.sex } })
|
||||
if (form.newPassword) {
|
||||
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: form.oldPassword, newPassword: form.newPassword } })
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
function onCancel() {
|
||||
form.nickName = profile.value.nickName || ''
|
||||
form.phonenumber = profile.value.phonenumber || ''
|
||||
form.email = profile.value.email || ''
|
||||
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
|
||||
}
|
||||
|
||||
onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-accounts { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 24px; border: 1px solid #ebeef5; }
|
||||
.desc { color: #909399; font-size: 13px; margin-bottom: 16px; }
|
||||
.section-title { font-weight: 600; margin: 16px 0 12px; padding-left: 8px; border-left: 3px solid var(--brand-primary); }
|
||||
.form-tip { color: #909399; font-size: 12px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="manager-exec-intent">
|
||||
<div class="page-card">
|
||||
<div class="desc">查看专家对项目的执行意向(含待签约/已签约/已拒绝/已退出)</div>
|
||||
<el-form inline :model="q">
|
||||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" clearable /></el-form-item>
|
||||
<el-form-item label="项目名称:"><el-input v-model="q.projectName" clearable /></el-form-item>
|
||||
<el-form-item label="姓名:"><el-input v-model="q.name" clearable /></el-form-item>
|
||||
<el-form-item label="工作单位:"><el-input v-model="q.workUnit" clearable /></el-form-item>
|
||||
<el-form-item label="手机号:"><el-input v-model="q.phone" clearable /></el-form-item>
|
||||
<el-form-item label="签约状态:">
|
||||
<el-select v-model="q.onboardStatus" clearable>
|
||||
<el-option label="待签约" value="待签约" /><el-option label="已签约" value="已签约" />
|
||||
<el-option label="已拒绝" value="已拒绝" /><el-option label="已退出" 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>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="page-card">
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="projectNo" label="意向项目编号" width="140" />
|
||||
<el-table-column prop="projectName" label="意向项目名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="name" label="姓名" width="100" />
|
||||
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" width="120" />
|
||||
<el-table-column prop="position" label="职务" width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column label="签约状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.onboardStatus==='已签约' ? 'success' : row.onboardStatus==='已拒绝' ? 'danger' : row.onboardStatus==='已退出' ? 'info' : 'warning'">{{ row.onboardStatus }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const q = ref({ projectNo: '', projectName: '', name: '', workUnit: '', phone: '', onboardStatus: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('executionIntent', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []; page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() { q.value = { projectNo:'', projectName:'', name:'', workUnit:'', phone:'', onboardStatus:'' }; page.pageNum = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-exec-intent { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.desc { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="desc">审核专家注册信息</div>
|
||||
|
||||
<!-- ========== 筛选区 (按原型 top=174: 姓名/手机号/工作单位/审核状态) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="q.name" placeholder="" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="q.phone" placeholder="" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位">
|
||||
<el-input v-model="q.workUnit" placeholder="" clearable style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="q.auditStatus" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="待审核" value="0" />
|
||||
<el-option label="通过" value="1" />
|
||||
<el-option label="退回" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- ========== 批量按钮区 (按原型 top=226: 新建专家/批量导入/批量审核/批量导入执业证书/批量导入职称证明) ========== -->
|
||||
<div class="page-card">
|
||||
<div class="batch-bar">
|
||||
<el-button type="primary" @click="openAdd">新建专家</el-button>
|
||||
<el-button @click="openImport">批量导入</el-button>
|
||||
<el-button @click="onBatchAudit" :disabled="!selection.length">批量审核</el-button>
|
||||
<el-button @click="openImportPracticeCert">批量导入执业证书/证明</el-button>
|
||||
<el-button @click="openImportTitleCert">批量导入职称证明</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格 (按原型 11 列, 按 left 顺序) ========== -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="name" label="姓名" width="100" fixed />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="科室" width="100" />
|
||||
<el-table-column prop="title" label="职称" width="100" />
|
||||
<el-table-column label="执业证书/证明" width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.practiceCertUrl">
|
||||
<el-button link type="primary" @click="onViewCert(row.practiceCertUrl)">查看</el-button>
|
||||
<el-button link type="primary" @click="onDownloadCert(row.practiceCertUrl, row.phone)">下载</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="职称证明" width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.titleCertUrl">
|
||||
<el-button link type="primary" @click="onViewCert(row.titleCertUrl)">查看</el-button>
|
||||
<el-button link type="primary" @click="onDownloadCert(row.titleCertUrl, row.phone)">下载</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', auditStatusClass(row.auditStatus)]">{{ auditStatusText(row.auditStatus) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auditTime" label="审批时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button link :type="row.status === '1' ? 'danger' : 'success'" @click="row.status === '1' ? onDisable(row) : onEnable(row)">
|
||||
{{ row.status === '1' ? '禁用' : '恢复' }}
|
||||
</el-button>
|
||||
<el-button link type="info" v-if="row.auditStatus === '2'" @click="onViewAuditOpinion(row)">已退回</el-button>
|
||||
<el-button link type="primary" v-if="row.practiceCertUrl || row.titleCertUrl || row.resumeUrl" @click="onDownloadAll(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<!-- ========== Dialog: 新建/编辑专家 (按原型 top=239 确定/取消) ========== -->
|
||||
<el-dialog v-model="editOpen" :title="editForm.expertId ? '编辑专家' : '新建专家'" width="640px">
|
||||
<el-form :model="editForm" label-width="120px" :rules="editRules" ref="editFormRef">
|
||||
<el-form-item label="姓名" prop="name"><el-input v-model="editForm.name" /></el-form-item>
|
||||
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" maxlength="11" /></el-form-item>
|
||||
<el-form-item label="工作单位" prop="workUnit"><el-input v-model="editForm.workUnit" /></el-form-item>
|
||||
<el-form-item label="科室" prop="department"><el-input v-model="editForm.department" placeholder="如:呼吸科" /></el-form-item>
|
||||
<el-form-item label="职称" prop="title">
|
||||
<el-select v-model="editForm.title" placeholder="请选择" style="width:100%">
|
||||
<el-option label="住院医师" value="住院医师" />
|
||||
<el-option label="主治医师" value="主治医师" />
|
||||
<el-option label="副主任医师" value="副主任医师" />
|
||||
<el-option label="主任医师" value="主任医师" />
|
||||
<el-option label="教授" value="教授" />
|
||||
<el-option label="副教授" value="副教授" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执业证书 URL"><el-input v-model="editForm.practiceCertUrl" /></el-form-item>
|
||||
<el-form-item label="职称证明 URL"><el-input v-model="editForm.titleCertUrl" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 审核 (原型 top=404 确认/取消) ========== -->
|
||||
<el-dialog v-model="auditOpen" title="审核" width="520px">
|
||||
<p style="margin-bottom:12px">专家: <b>{{ auditForm.name }}</b> ({{ auditForm.phone }})</p>
|
||||
<el-form :model="auditForm" label-width="100px">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio value="1">通过</el-radio>
|
||||
<el-radio value="2">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="auditForm.opinion" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 批量导入 (原型 top=239 确定/取消, 含 radio 通过/退回) ========== -->
|
||||
<el-dialog v-model="importOpen" title="批量导入专家" width="520px">
|
||||
<div style="margin-bottom:12px;font-size:13px">
|
||||
<el-link type="primary" @click="downloadImportTpl">批量导入模板下载</el-link>
|
||||
</div>
|
||||
<p style="color:#606266;margin-bottom:8px">*批量导入模板 xls 与列表表项一致</p>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="importAuditResult">
|
||||
<el-radio value="1">通过</el-radio>
|
||||
<el-radio value="2">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input v-model="importAuditOpinion" type="textarea" :rows="3" placeholder="*新建和批量导入审核状态直接为审核通过" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择文件">
|
||||
<el-input v-model="importFileUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 批量导入执业证书/证明 (原型 u6124) ========== -->
|
||||
<el-dialog v-model="importCertOpen" title="批量导入执业证书/证明" width="520px">
|
||||
<p style="color:#606266;margin-bottom:8px">*退回后,如专家重新提交,则审核状态变为待审核</p>
|
||||
<p style="color:#606266;margin-bottom:8px">提示:需将证明文件名称修改为手机号进行导入</p>
|
||||
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
<template #footer>
|
||||
<el-button @click="importCertOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 批量导入职称证明 (原型 u6126) ========== -->
|
||||
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px">
|
||||
<p style="color:#606266;margin-bottom:8px">提示:需将证明文件名称修改为手机号进行导入</p>
|
||||
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" />
|
||||
<template #footer>
|
||||
<el-button @click="importTitleCertOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 查看专家详情 (原型 操作列 "查看" 按钮) ========== -->
|
||||
<el-dialog v-model="viewOpen" title="专家详情" width="640px">
|
||||
<el-descriptions :column="2" border v-if="currentRow">
|
||||
<el-descriptions-item label="姓名">{{ currentRow.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ currentRow.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="工作单位">{{ currentRow.workUnit }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ currentRow.department }}</el-descriptions-item>
|
||||
<el-descriptions-item label="职称">{{ currentRow.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">{{ auditStatusText(currentRow.auditStatus) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执业证书">
|
||||
<el-link v-if="currentRow.practiceCertUrl" type="primary" @click="onViewCert(currentRow.practiceCertUrl)">查看证书</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="职称证明">
|
||||
<el-link v-if="currentRow.titleCertUrl" type="primary" @click="onViewCert(currentRow.titleCertUrl)">查看证书</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审批时间">{{ fmtTime(currentRow.auditTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批人">{{ currentRow.auditBy }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizAdd, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const q = ref({ name: '', phone: '', workUnit: '', auditStatus: '' })
|
||||
const rows = ref([])
|
||||
const selection = ref([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('expert', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
q.value = { name: '', phone: '', workUnit: '', auditStatus: '' }
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
function onSel(rs) { selection.value = rs }
|
||||
|
||||
// ========== 工具: 审核状态显示/颜色 ==========
|
||||
function auditStatusText(s) {
|
||||
if (s === '1') return '通过'
|
||||
if (s === '2') return '退回'
|
||||
return '待审核'
|
||||
}
|
||||
function auditStatusClass(s) {
|
||||
if (s === '1') return 'stage-done'
|
||||
if (s === '2') return 'stage-reject'
|
||||
return 'stage-reviewing'
|
||||
}
|
||||
function fmtTime(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
// ========== 新建/编辑 dialog ==========
|
||||
const editOpen = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({
|
||||
expertId: '', name: '', phone: '', workUnit: '', department: '', title: '',
|
||||
practiceCertUrl: '', titleCertUrl: ''
|
||||
})
|
||||
const editRules = {
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
|
||||
department: [{ required: true, message: '请输入科室', trigger: 'blur' }],
|
||||
title: [{ required: true, message: '请选择职称', trigger: 'change' }]
|
||||
}
|
||||
function openAdd() {
|
||||
Object.assign(editForm, { expertId: '', name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
|
||||
editOpen.value = true
|
||||
}
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
expertId: row.expertId, name: row.name, phone: row.phone, workUnit: row.workUnit,
|
||||
department: row.department, title: row.title,
|
||||
practiceCertUrl: row.practiceCertUrl || '', titleCertUrl: row.titleCertUrl || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
async function submitEdit() {
|
||||
await editFormRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editForm.expertId) {
|
||||
await bizUpdate('expert', { ...editForm })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await bizAdd('expert', { ...editForm, auditStatus: '1', auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ') })
|
||||
ElMessage.success('新建成功')
|
||||
}
|
||||
editOpen.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
// ========== 审核 dialog ==========
|
||||
const auditOpen = ref(false)
|
||||
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '1', opinion: '' })
|
||||
function onAudit(row) {
|
||||
auditForm.expertId = row.expertId
|
||||
auditForm.name = row.name
|
||||
auditForm.phone = row.phone
|
||||
auditForm.result = '1'
|
||||
auditForm.opinion = ''
|
||||
auditOpen.value = true
|
||||
}
|
||||
async function submitAudit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await bizUpdate('expert', {
|
||||
expertId: auditForm.expertId,
|
||||
auditStatus: auditForm.result,
|
||||
auditOpinion: auditForm.opinion,
|
||||
auditBy: 'admin', // 后端应取登录用户
|
||||
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
})
|
||||
ElMessage.success(auditForm.result === '1' ? '审核通过' : '已退回')
|
||||
auditOpen.value = false
|
||||
load()
|
||||
} catch { ElMessage.error('审核失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
// ========== 批量审核 ==========
|
||||
async function onBatchAudit() {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选行')
|
||||
try { await ElMessage.confirm(`确定批量审核选中的 ${selection.value.length} 位专家吗?`, '批量审核', { type: 'warning' }).catch(() => { throw new Error('cancel') }) } catch { return }
|
||||
submitting.value = true
|
||||
let ok = 0
|
||||
for (const r of selection.value) {
|
||||
try { await bizUpdate('expert', { expertId: r.expertId, auditStatus: '1', auditOpinion: '批量通过', auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ') }); ok++ } catch {}
|
||||
}
|
||||
submitting.value = false
|
||||
ElMessage.success(`批量审核通过 ${ok}/${selection.value.length} 条`)
|
||||
selection.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
// ========== 禁用/恢复 ==========
|
||||
async function onDisable(row) {
|
||||
try { await bizUpdate('expert', { expertId: row.expertId, status: '0' }); ElMessage.success('已禁用'); load() } catch { ElMessage.error('禁用失败') }
|
||||
}
|
||||
async function onEnable(row) {
|
||||
try { await bizUpdate('expert', { expertId: row.expertId, status: '1' }); ElMessage.success('已恢复'); load() } catch { ElMessage.error('恢复失败') }
|
||||
}
|
||||
|
||||
// ========== 查看详情 ==========
|
||||
const viewOpen = ref(false)
|
||||
const currentRow = ref(null)
|
||||
function onView(row) { currentRow.value = row; viewOpen.value = true }
|
||||
|
||||
// ========== 已退回: 查看退回意见 ==========
|
||||
function onViewAuditOpinion(row) {
|
||||
ElMessageBox.alert(
|
||||
`退回时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n退回意见: ${row.auditOpinion || '(无)'}`,
|
||||
`专家 ${row.name} 审核已退回`,
|
||||
{ type: 'warning', confirmButtonText: '知道了' }
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 证书查看/下载 ==========
|
||||
function onViewCert(url) {
|
||||
if (!url) return
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
function onDownloadCert(url, phone) {
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${phone || 'cert'}_${Date.now()}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
// 操作列 下载:按 执业证书 > 职称证明 > 简历 优先级下载第一个可用附件
|
||||
function onDownloadAll(row) {
|
||||
const url = row.practiceCertUrl || row.titleCertUrl || row.resumeUrl
|
||||
if (!url) return ElMessage.warning('暂无可下载的附件')
|
||||
onDownloadCert(url, row.phone)
|
||||
}
|
||||
|
||||
// ========== 批量导入 dialog ==========
|
||||
const importOpen = ref(false)
|
||||
const importAuditResult = ref('1')
|
||||
const importAuditOpinion = ref('')
|
||||
const importFileUrl = ref('')
|
||||
const importing = ref(false)
|
||||
function openImport() {
|
||||
importAuditResult.value = '1'
|
||||
importAuditOpinion.value = ''
|
||||
importFileUrl.value = ''
|
||||
importOpen.value = true
|
||||
}
|
||||
function downloadImportTpl() {
|
||||
ElMessage.info('批量导入模板下载 — 待后端提供静态文件')
|
||||
}
|
||||
async function submitImport() {
|
||||
if (!importFileUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info(`已提交批量导入任务 (审核结果: ${importAuditResult.value === '1' ? '通过' : '退回'}) — 待后端实现`)
|
||||
importOpen.value = false
|
||||
}
|
||||
|
||||
// ========== 批量导入执业证书/证明 ==========
|
||||
const importCertOpen = ref(false)
|
||||
const importCertUrl = ref('')
|
||||
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true }
|
||||
async function submitImportCert() {
|
||||
if (!importCertUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
|
||||
importCertOpen.value = false
|
||||
}
|
||||
|
||||
// ========== 批量导入职称证明 ==========
|
||||
const importTitleCertOpen = ref(false)
|
||||
const importTitleCertUrl = ref('')
|
||||
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true }
|
||||
async function submitImportTitleCert() {
|
||||
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件')
|
||||
importing.value = true
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
importing.value = false
|
||||
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现')
|
||||
importTitleCertOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 16px; margin-bottom: 12px; }
|
||||
.desc { color: #606266; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-form .el-form-item { margin-right: 12px; margin-bottom: 8px; }
|
||||
.batch-bar { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.stage-tag { display: inline-block; padding: 2px 10px; border-radius: 3px; font-size: 12px; border: 1px solid; }
|
||||
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
|
||||
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
|
||||
.stage-reject { color: #f56c6c; border-color: #fbc4c4; background: #fef0f0; }
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 (与列表页一致) -->
|
||||
<div class="breadcrumb">
|
||||
首页 / 项目管理 / <span class="current">项目详情</span>
|
||||
</div>
|
||||
|
||||
<!-- ================= 1. 项目信息 (11 字段, 一列竖排) ================= -->
|
||||
<el-form v-if="row && row.projectId" label-position="left" class="info-form">
|
||||
<el-form-item label="项目编号">
|
||||
<span class="info-value">{{ row.projectNo || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<span class="info-value">{{ row.projectName || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="总场次/总期数">
|
||||
<span class="info-value">{{ row.totalSessions || 0 }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="总金额">
|
||||
<span class="info-value money">¥ {{ fmtMoney(row.totalAmount) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="管理费及税金">
|
||||
<span class="info-value money">¥ {{ fmtMoney(row.manageFee) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目开始时间">
|
||||
<span class="info-value">{{ fmtDate(row.startTime) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目结束时间">
|
||||
<span class="info-value">{{ fmtDate(row.endTime) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="赞助公司">
|
||||
<span class="info-value">{{ row.orgName || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<span class="info-value">{{ row.projectForm || '-' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目评价">
|
||||
<a class="op-link" href="javascript:void(0)" @click="openScoreDialog">{{ fmtScore(row.ratingScore) }}</a>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- ================= 2. 角色劳务设置 ================= -->
|
||||
<div class="section-title">角色劳务设置</div>
|
||||
<p class="hint-red">*角色劳务设置针对该项目所有会议生效</p>
|
||||
<el-table :data="form.roleRows" border>
|
||||
<el-table-column prop="role" label="角色" width="120" />
|
||||
<el-table-column label="劳务金额" align="right">
|
||||
<template #default="{ row }">¥ {{ fmtMoney(row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:8px">
|
||||
<el-button link type="primary" @click="addRoleRow">+ 增加角色</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ================= 3. 支持单位监督员 ================= -->
|
||||
<div class="section-title">支持单位监督员</div>
|
||||
<p class="hint-red">*增加只显示已点支持意向的</p>
|
||||
<div class="monitor-list">
|
||||
<div v-for="(m, idx) in monitors" :key="idx" class="monitor-row">
|
||||
<span class="monitor-num">{{ idx + 1 }}.</span>
|
||||
<span class="monitor-name">{{ m.userName || m.name || '-' }}</span>
|
||||
<el-button link type="danger" @click="removeMonitorAt(idx)">删除</el-button>
|
||||
</div>
|
||||
<div v-if="!monitors.length" class="empty-hint">暂无监督员</div>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<el-button link type="primary" @click="openAddMonitor">+ 增加监督员</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ================= 4. 已发布公告 ================= -->
|
||||
<div class="section-title">已发布公告</div>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openUpload">发布公告</el-button>
|
||||
<el-button @click="loadAnnouncements">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="announcements" border>
|
||||
<el-table-column prop="title" label="公告标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="发布时间" width="160" align="center" :formatter="(r)=>fmtDate(r.createTime)" />
|
||||
<el-table-column prop="creatorName" label="发布人" width="120" align="center" />
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openPreview(row.fileUrl || row.url, row.title)">查看</el-button>
|
||||
<el-button link type="danger" @click="deleteAnnouncement(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- ================= dialogs ================= -->
|
||||
<!-- 发布公告 dialog -->
|
||||
<el-dialog v-model="uploadOpen" title="发布公告" width="560px" @closed="resetUploadForm">
|
||||
<el-form :model="uploadForm" label-width="100px">
|
||||
<el-form-item label="公告类型">
|
||||
<el-select v-model="uploadForm.announceType" placeholder="请选择" style="width:100%">
|
||||
<el-option v-for="t in announceTypes" :key="t.key" :label="t.label" :value="t.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="公告标题">
|
||||
<el-input v-model="uploadForm.title" placeholder="请输入公告标题" maxlength="80" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="uploadForm.remark" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAnnouncement">发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 评分 dialog -->
|
||||
<el-dialog v-model="scoreOpen" title="项目评分" width="560px">
|
||||
<el-form :model="scoreForm" label-width="120px">
|
||||
<el-form-item v-for="d in scoreDims" :key="d.key" :label="d.label">
|
||||
<el-rate v-model="scoreForm[d.key]" :max="5" show-score />
|
||||
</el-form-item>
|
||||
<el-form-item label="总分">
|
||||
<span class="score-num">{{ totalScore }}</span> / 20
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="scoreOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitScore">保存评分</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 增加监督员 dialog -->
|
||||
<el-dialog v-model="addMonitorOpen" title="增加监督员" width="420px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="监督员">
|
||||
<el-select v-model="newMonitor.userId" placeholder="请选择监督员" filterable style="width:100%">
|
||||
<el-option v-for="u in monitorCandidates" :key="u.userId"
|
||||
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`" :value="u.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addMonitorOpen=false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmAddMonitor">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 公告预览 -->
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizGet, bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ===================== 状态 =====================
|
||||
const projectId = ref('')
|
||||
const row = ref({})
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const announceTypes = [
|
||||
{ key: 'invitation', label: '邀请函' },
|
||||
{ key: 'support', label: '支持函' },
|
||||
{ key: 'notice', label: '通知' },
|
||||
{ key: 'agenda', label: '日程' }
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
roleRows: [
|
||||
{ role: '主席', customName: '', amount: 5000 },
|
||||
{ role: '主持', customName: '', amount: 3000 }
|
||||
]
|
||||
})
|
||||
const monitors = ref([])
|
||||
const addMonitorOpen = ref(false)
|
||||
const newMonitor = reactive({ userId: null })
|
||||
const monitorCandidates = ref([])
|
||||
|
||||
const announcements = ref([])
|
||||
const uploadOpen = ref(false)
|
||||
const uploadForm = reactive({ announceType: 'notice', title: '', remark: '' })
|
||||
|
||||
const scoreOpen = ref(false)
|
||||
const scoreForm = reactive({ scoreQuality: 0, scoreSpeed: 0, scoreCoordination: 0, scoreCompliance: 0 })
|
||||
const scoreDims = [
|
||||
{ key: 'scoreQuality', label: '履约质量' },
|
||||
{ key: 'scoreSpeed', label: '时效响应' },
|
||||
{ key: 'scoreCoordination', label: '配合度' },
|
||||
{ key: 'scoreCompliance', label: '合规安全' }
|
||||
]
|
||||
const totalScore = computed(() =>
|
||||
scoreForm.scoreQuality + scoreForm.scoreSpeed + scoreForm.scoreCoordination + scoreForm.scoreCompliance
|
||||
)
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('公告预览')
|
||||
|
||||
// ===================== 工具 =====================
|
||||
function numOrZero(v) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? 0 : n
|
||||
}
|
||||
function fmtScore(v) {
|
||||
if (v == null || v === '') return '-'
|
||||
return Number(v).toFixed(1)
|
||||
}
|
||||
function fmtMoney(v) {
|
||||
if (v == null || v === '' || v === 0) return '0.00'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function fmtDate(v) {
|
||||
if (!v) return '-'
|
||||
const s = String(v)
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
return m ? `${m[1]}.${m[2]}.${m[3]}` : s
|
||||
}
|
||||
|
||||
// ===================== 加载 =====================
|
||||
async function loadProject() {
|
||||
projectId.value = route.params.projectId
|
||||
if (!projectId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await bizGet('project', projectId.value)
|
||||
// 后端 success(BizProject) 返回 {code, msg, data: BizProject}
|
||||
const data = (resp && resp.data) || resp || {}
|
||||
row.value = data || {}
|
||||
scoreForm.scoreQuality = numOrZero(row.value.scoreQuality)
|
||||
scoreForm.scoreSpeed = numOrZero(row.value.scoreSpeed)
|
||||
scoreForm.scoreCoordination = numOrZero(row.value.scoreCoordination)
|
||||
scoreForm.scoreCompliance = numOrZero(row.value.scoreCompliance)
|
||||
// 角色劳务 (JSON 字段, 缺则用默认)
|
||||
try {
|
||||
const rl = JSON.parse(row.value.roleLabor || '[]')
|
||||
if (Array.isArray(rl) && rl.length) form.roleRows = rl
|
||||
} catch (e) { /* keep default */ }
|
||||
// 监督员 (JSON 字段)
|
||||
try {
|
||||
const mn = JSON.parse(row.value.monitors || '[]')
|
||||
if (Array.isArray(mn)) monitors.value = mn
|
||||
} catch (e) { /* keep empty */ }
|
||||
} catch (e) {
|
||||
console.error('[project-detail] load failed', e)
|
||||
row.value = {}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
const resp = await bizList('publicity', { projectId: projectId.value })
|
||||
const arr = (resp && resp.rows) || ((resp && resp.data) && (resp && resp.data).rows) || (resp && resp.data) || []
|
||||
announcements.value = Array.isArray(arr) ? arr : []
|
||||
} catch (e) {
|
||||
console.error('[project-detail] announcements load failed', e)
|
||||
announcements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 角色劳务 =====================
|
||||
function addRoleRow() {
|
||||
if (form.roleRows.length >= 20) { ElMessage.warning('最多 20 行角色'); return }
|
||||
form.roleRows.push({ role: '', customName: '', amount: 0 })
|
||||
}
|
||||
function removeRoleRowAt(idx) {
|
||||
if (form.roleRows.length <= 1) { ElMessage.warning('至少保留 1 行角色'); return }
|
||||
form.roleRows.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ===================== 监督员 =====================
|
||||
async function openAddMonitor() {
|
||||
addMonitorOpen.value = true
|
||||
if (!monitorCandidates.value.length) {
|
||||
try {
|
||||
const resp = await bizList('person', { pageNum: 1, pageSize: 100, role: '监察员', unitType: 'sponsor' })
|
||||
monitorCandidates.value = (resp && resp.rows) || ((resp && resp.data) && (resp && resp.data).rows) || (resp && resp.data) || []
|
||||
} catch (e) { monitorCandidates.value = [] }
|
||||
}
|
||||
}
|
||||
function confirmAddMonitor() {
|
||||
if (!newMonitor.userId) { ElMessage.warning('请选择监督员'); return }
|
||||
const u = monitorCandidates.value.find(x => x.userId === newMonitor.userId)
|
||||
if (u) monitors.value.push({ userId: u.userId, userName: u.userName, name: u.nickName || u.userName })
|
||||
addMonitorOpen.value = false
|
||||
newMonitor.userId = null
|
||||
}
|
||||
function removeMonitorAt(idx) {
|
||||
if (monitors.value.length <= 1) { ElMessage.warning('至少保留 1 行监督员'); return }
|
||||
monitors.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ===================== 公告 =====================
|
||||
function openUpload() {
|
||||
resetUploadForm()
|
||||
uploadOpen.value = true
|
||||
}
|
||||
function resetUploadForm() {
|
||||
uploadForm.announceType = 'notice'
|
||||
uploadForm.title = ''
|
||||
uploadForm.remark = ''
|
||||
}
|
||||
async function submitAnnouncement() {
|
||||
if (!uploadForm.title) { ElMessage.warning('请输入公告标题'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
await bizAdd('publicity', {
|
||||
projectId: projectId.value,
|
||||
announceType: uploadForm.announceType,
|
||||
title: uploadForm.title,
|
||||
remark: uploadForm.remark,
|
||||
status: '1'
|
||||
})
|
||||
ElMessage.success('已发布')
|
||||
uploadOpen.value = false
|
||||
await loadAnnouncements()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('发布失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
async function deleteAnnouncement(a) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除公告【${a.title || '未命名'}】?`, '提示', { type: 'warning' })
|
||||
await bizDelete('publicity', a.id || a.publicityId)
|
||||
ElMessage.success('已删除')
|
||||
await loadAnnouncements()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 评分 =====================
|
||||
function openScoreDialog() {
|
||||
scoreOpen.value = true
|
||||
}
|
||||
async function submitScore() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await bizUpdate('project', {
|
||||
...row.value,
|
||||
scoreQuality: scoreForm.scoreQuality,
|
||||
scoreSpeed: scoreForm.scoreSpeed,
|
||||
scoreCoordination: scoreForm.scoreCoordination,
|
||||
scoreCompliance: scoreForm.scoreCompliance,
|
||||
ratingScore: totalScore.value
|
||||
})
|
||||
ElMessage.success('评分已保存')
|
||||
scoreOpen.value = false
|
||||
await loadProject()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 导航 =====================
|
||||
function goBack() { router.push('/manager/projects') }
|
||||
function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) }
|
||||
|
||||
// ===================== 预览 =====================
|
||||
function openPreview(url, title) {
|
||||
if (!url) { ElMessage.warning('暂无附件'); return }
|
||||
previewUrl.value = url
|
||||
previewTitle.value = title || '公告预览'
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
onMounted(async () => {
|
||||
await loadProject()
|
||||
await loadAnnouncements()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄列表页 (manager/Projects.vue) 风格 */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.breadcrumb .current { color: #262626; font-weight: 500; }
|
||||
|
||||
.section-title {
|
||||
font-size: 14px; font-weight: 600; color: #262626; margin: 16px 0 12px;
|
||||
padding-left: 8px; border-left: 3px solid var(--brand-primary, #1890ff);
|
||||
}
|
||||
|
||||
/* 项目信息: 11 字段, 一列竖排 (el-form + 文本, 无分隔线) */
|
||||
.info-form { padding-top: 4px; }
|
||||
.info-form :deep(.el-form-item) { margin-bottom: 8px; }
|
||||
.info-form :deep(.el-form-item__label) {
|
||||
font-size: 14px; color: #595959; width: 130px; padding-right: 16px;
|
||||
}
|
||||
.info-value { font-size: 15px; color: #1a1a1a; font-weight: 500; }
|
||||
.info-value.money {
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
}
|
||||
.op-link {
|
||||
color: var(--brand-primary, #1890ff); text-decoration: none;
|
||||
font-weight: 600; cursor: pointer; font-size: 15px;
|
||||
}
|
||||
.op-link:hover { text-decoration: underline; }
|
||||
|
||||
/* 监督员 */
|
||||
.monitor-list { padding: 4px 0; }
|
||||
.monitor-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; background: #fafafa; border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.monitor-num { width: 24px; color: #8c8c8c; font-size: 13px; }
|
||||
.monitor-name { flex: 1; font-size: 14px; color: #1a1a1a; }
|
||||
.empty-hint { text-align: center; color: #c0c4cc; padding: 12px 0; font-size: 13px; }
|
||||
|
||||
/* 角色劳务表格 */
|
||||
.info-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.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; }
|
||||
|
||||
/* 红字提示 */
|
||||
.hint-red { font-size: 12px; color: #ff0000; line-height: 1.7; margin: 4px 0 16px; }
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
/* 评分 */
|
||||
.score-num { font-size: 18px; color: var(--brand-primary, #1890ff); font-weight: 700; margin-right: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="page-card manager-meeting-new">
|
||||
<h2 style="margin-bottom:16px">新建会议</h2>
|
||||
<p class="page-sub" v-if="preselectProjectId">已选择项目: {{ preselectProjectId }}</p>
|
||||
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px" style="max-width:700px">
|
||||
<el-form-item label="项目编号" prop="projectNo">
|
||||
<el-input v-model="form.projectNo" :disabled="!!preselectProjectId" placeholder="项目编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称" prop="meetingName">
|
||||
<el-input v-model="form.meetingName" placeholder="会议名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议ID" prop="meetingId">
|
||||
<el-input v-model="form.meetingId" placeholder="会议ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式" prop="projectForm">
|
||||
<el-select v-model="form.projectForm" placeholder="请选择" style="width:100%">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="会议开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</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" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="总期数" prop="totalPeriods">
|
||||
<el-input-number v-model="form.totalPeriods" :min="1" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="期数" prop="periodNo">
|
||||
<el-input-number v-model="form.periodNo" :min="1" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段" prop="currentStage">
|
||||
<el-select v-model="form.currentStage" placeholder="请选择" style="width:100%">
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="已完结" value="已完结" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSave">保存</el-button>
|
||||
<el-button @click="onCancel">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizAdd, bizGet } from '@/api/public'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const preselectProjectId = route.query.projectId || ''
|
||||
const preselectProjectNo = route.query.projectNo || ''
|
||||
const formRef = ref()
|
||||
const form = reactive({
|
||||
projectNo: preselectProjectNo || '',
|
||||
meetingName: '',
|
||||
meetingId: '',
|
||||
projectForm: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
totalPeriods: 1,
|
||||
periodNo: 1,
|
||||
currentStage: '未执行',
|
||||
remark: ''
|
||||
})
|
||||
const rules = {
|
||||
projectNo: [{ required: true, message: '请输入项目编号', trigger: 'blur' }],
|
||||
meetingName: [{ required: true, message: '请输入会议名称', trigger: 'blur' }],
|
||||
meetingId: [{ required: true, message: '请输入会议ID', trigger: 'blur' }],
|
||||
startTime: [{ required: true, message: '请选择会议开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择会议结束时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try { await formRef.value.validate() } catch (e) { return }
|
||||
try {
|
||||
await bizAdd('meeting', { ...form })
|
||||
ElMessage.success('保存成功')
|
||||
router.push('/manager/meetings')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() { router.back() }
|
||||
|
||||
onMounted(async () => {
|
||||
if (preselectProjectId) {
|
||||
try {
|
||||
const r = await bizGet('project', preselectProjectId)
|
||||
form.projectNo = r.data?.projectNo || ''
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-meeting-new { padding: 16px; }
|
||||
.page-sub { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="manager-meetings">
|
||||
<!-- ========== 筛选区 (严格按 manager 角色原型 会议管理_1.html — 第一行 6 项 + 第二行 3 项) ========== -->
|
||||
<div class="page-card">
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" placeholder="" clearable style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议ID">
|
||||
<el-input v-model="q.meetingId" placeholder="" clearable style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称">
|
||||
<el-input v-model="q.meetingName" placeholder="" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="第?期">
|
||||
<el-input v-model="q.periodNo" placeholder="" clearable style="width:80px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议时间">
|
||||
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:170px" />
|
||||
<span style="margin:0 4px">至</span>
|
||||
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:170px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" placeholder="请选择" clearable style="width:140px">
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="已完结" value="已完结" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="q.remark" placeholder="" clearable style="width:140px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格区 (按 manager 角色原型 会议管理_1.html: 操作列 4 按钮 — 查看/修改/提交/复制) ========== -->
|
||||
<div class="page-card">
|
||||
<!-- 批量提交按钮 (manager 角色才有) -->
|
||||
<div class="batch-bar" v-if="selectedIds.length">
|
||||
<el-button type="primary" plain @click="onBatchSubmit" :disabled="!selectedIds.length">批量提交</el-button>
|
||||
<span class="selected-tip">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column 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" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="会议开始时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会议结束时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
|
||||
<el-table-column label="期数" width="80" align="center">
|
||||
<template #default="{ row }">第 {{ row.periodNo || 0 }} 期</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['stage-tag', 'stage-' + stageClass(row.currentStage)]">{{ row.currentStage }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<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" @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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 会议详情 dialog -->
|
||||
<el-dialog v-model="detailOpen" title="会议详情" width="640px">
|
||||
<el-descriptions :column="3" border v-if="currentRow">
|
||||
<el-descriptions-item label="项目编号">{{ currentRow.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议ID">{{ currentRow.meetingId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目形式">{{ currentRow.projectForm }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称" :span="3">{{ currentRow.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议开始时间">{{ fmtTime(currentRow.startTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议结束时间">{{ fmtTime(currentRow.endTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交剩余时间">{{ calcRemain(currentRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总期数">{{ currentRow.totalPeriods }}</el-descriptions-item>
|
||||
<el-descriptions-item label="期数">第 {{ currentRow.periodNo || 0 }} 期</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ currentRow.currentStage }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="3">{{ currentRow.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改 dialog (基本字段编辑) -->
|
||||
<el-dialog v-model="editOpen" title="修改会议" width="640px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="会议名称"><el-input v-model="editForm.meetingName" /></el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="editForm.projectForm">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间">
|
||||
<el-date-picker v-model="editForm.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间">
|
||||
<el-date-picker v-model="editForm.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="总期数"><el-input-number v-model="editForm.totalPeriods" :min="1" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="submitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// ========== 筛选 (严格按原型 2 行 8 项: 项目编号/会议ID/会议名称/第几期/会议时间/项目形式/当前阶段/备注) ==========
|
||||
const q = ref({
|
||||
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
|
||||
projectForm: '', currentStage: '', remark: '',
|
||||
startTime: '', endTime: ''
|
||||
})
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// ========== 表格多选 ==========
|
||||
const selectedIds = ref([])
|
||||
function onSelectionChange(sel) {
|
||||
selectedIds.value = sel.map(r => r.meetingId)
|
||||
}
|
||||
|
||||
// ========== 工具: 时间格式 (yyyy.MM.dd HH:mm) ==========
|
||||
function fmtTime(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
}
|
||||
|
||||
// ========== 工具: 当前阶段颜色 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'
|
||||
}
|
||||
|
||||
// ========== 工具: 提交剩余时间 (deadlineDays - 已过天数, 单位天/小时) ==========
|
||||
function calcRemain(row) {
|
||||
if (!row) return ''
|
||||
if (!row.endTime) return ''
|
||||
// 已结束的会议: 冻结中/已完结 等阶段, 不算剩余
|
||||
if (['已完结', '已执行', '已结题'].includes(row.currentStage)) return '-'
|
||||
const end = new Date(row.endTime).getTime()
|
||||
const now = Date.now()
|
||||
if (now > end && row.currentStage !== '冻结中') return '已超时'
|
||||
// 剩余时间 = 提交截止天数 - (now - end) / day
|
||||
// 提交截止天数默认 30 天, 实际从 project.submit_deadline_days 读取
|
||||
const days = Number(row.submitDeadlineDays || 30)
|
||||
const remainMs = end + days * 86400000 - now
|
||||
if (remainMs <= 0) return '0小时'
|
||||
const remDays = Math.floor(remainMs / 86400000)
|
||||
const remHours = Math.floor((remainMs % 86400000) / 3600000)
|
||||
return `${remDays}天${remHours}小时`
|
||||
}
|
||||
|
||||
// ========== 加载 ==========
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('meeting', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() {
|
||||
q.value = {
|
||||
projectNo:'', meetingId:'', meetingName:'', periodNo:'',
|
||||
projectForm:'', currentStage:'', remark:'',
|
||||
startTime:'', endTime:''
|
||||
}
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
// ========== 查看 ==========
|
||||
const detailOpen = ref(false)
|
||||
const currentRow = ref(null)
|
||||
function onView(row) { currentRow.value = row; detailOpen.value = true }
|
||||
|
||||
// ========== 修改 ==========
|
||||
const editOpen = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editForm = reactive({ meetingId: '', meetingName: '', projectForm: '', startTime: '', endTime: '', totalPeriods: 1, remark: '' })
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
meetingId: row.meetingId, meetingName: row.meetingName || '',
|
||||
projectForm: row.projectForm || '', startTime: row.startTime || '',
|
||||
endTime: row.endTime || '', totalPeriods: row.totalPeriods || 1,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
async function submitEdit() {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await bizUpdate('meeting', { ...editForm })
|
||||
ElMessage.success('修改成功')
|
||||
editOpen.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error('修改失败') }
|
||||
finally { editSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ========== 提交 (单条) ==========
|
||||
async function onSubmit(row) {
|
||||
try { await ElMessageBox.confirm(`确定提交会议「${row.meetingName}」吗?`, '提交确认', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
await bizUpdate('meeting', { meetingId: row.meetingId, currentStage: '执行中' })
|
||||
ElMessage.success('提交成功')
|
||||
load()
|
||||
} catch { ElMessage.error('提交失败') }
|
||||
}
|
||||
|
||||
// ========== 复制 (单条) ==========
|
||||
function onCopy(row) {
|
||||
// 复制行到会议名 + ' 副本' 打开新建/编辑表单
|
||||
Object.assign(editForm, {
|
||||
meetingId: '', meetingName: (row.meetingName || '') + ' 副本',
|
||||
projectForm: row.projectForm || '', startTime: row.startTime || '',
|
||||
endTime: row.endTime || '', totalPeriods: row.totalPeriods || 1,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
// ========== 批量提交 ==========
|
||||
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: '执行中' })
|
||||
}
|
||||
ElMessage.success('批量提交成功')
|
||||
load()
|
||||
} catch { ElMessage.error('批量提交失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-meetings { padding: 16px; }
|
||||
.page-card {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.filter-form .el-form-item { margin-right: 12px; margin-bottom: 12px; }
|
||||
.batch-bar { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.batch-bar .selected-tip { color: #909399; font-size: 13px; margin-left: 8px; }
|
||||
|
||||
/* 当前阶段 tag 颜色 */
|
||||
.stage-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.stage-pending { color: #909399; border-color: #dcdfe6; background: #f5f7fa; }
|
||||
.stage-running { color: #409eff; border-color: #b3d8ff; background: #ecf5ff; }
|
||||
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
|
||||
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
|
||||
.stage-waiting { color: #f56c6c; border-color: #fbc4c4; background: #fef0f0; }
|
||||
.stage-frozen { color: #909399; border-color: #dcdfe6; background: #f5f7fa; opacity: 0.6; }
|
||||
.stage-default { color: #606266; border-color: #dcdfe6; background: #fafafa; }
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<!--
|
||||
角色: 🟨 合规 公司管理 (manager/orgs)
|
||||
重构说明: 原 manager/SupportUnits.vue (仅看支持单位) 改为可看所有公司
|
||||
接口: bizList('org', q) / bizUpdate
|
||||
-->
|
||||
<div class="manager-orgs">
|
||||
<div class="page-card">
|
||||
<div class="page-title">公司查看</div>
|
||||
<div class="desc">公司管理 > 公司查看 (赞助方 + 执行方)</div>
|
||||
<el-form inline :model="q">
|
||||
<el-form-item label="公司类型:">
|
||||
<el-select v-model="q.orgType" placeholder="全部" clearable style="width:140px">
|
||||
<el-option label="赞助方" value="sponsor" />
|
||||
<el-option label="执行方" value="executor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称:">
|
||||
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号/统一社会信用代码:">
|
||||
<el-input v-model="q.taxNo" placeholder="输入税号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="合作状态:">
|
||||
<el-select v-model="q.status" placeholder="请选择" clearable>
|
||||
<el-option label="正常/合作中" value="0" />
|
||||
<el-option label="合作中" value="合作中" />
|
||||
<el-option label="禁用" value="禁用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button class="action-btn" @click="load">查找</el-button>
|
||||
<el-button class="action-btn secondary" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<div class="page-toolbar">
|
||||
<el-button class="action-btn secondary" @click="goBack">返回</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
|
||||
{{ row.orgType === 'sponsor' ? '赞助方' : '执行方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
|
||||
<el-table-column label="合作状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.createTime ? String(row.createTime).slice(0,10) : '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="view(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="公司详情" width="640px" v-if="currentRow">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="公司类型">
|
||||
<el-tag :type="currentRow.orgType === 'sponsor' ? 'success' : 'primary'">
|
||||
{{ currentRow.orgType === 'sponsor' ? '赞助方' : '执行方' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司地址">{{ currentRow.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作状态">
|
||||
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建日期">{{ currentRow.createTime ? String(currentRow.createTime).slice(0,10) : '' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ currentRow.contactName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailOpen=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const router = useRouter()
|
||||
const q = ref({ orgType: '', orgName: '', taxNo: '', status: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const currentRow = ref(null)
|
||||
const detailOpen = ref(false)
|
||||
|
||||
function statusTagType(s) {
|
||||
if (s === '正常' || s === '0' || s === '合作中') return 'success'
|
||||
if (s === '禁用' || s === '1') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('org', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []; page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() { q.value = { orgType:'', orgName:'', taxNo:'', status:'' }; page.pageNum = 1; load() }
|
||||
function view(row) { currentRow.value = row; detailOpen.value = true }
|
||||
function goBack() { router.back() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-orgs { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.desc { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.page-toolbar { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.page-toolbar :deep(.action-btn) {
|
||||
height: 36px; padding: 0 20px; font-size: 14px; border-radius: 4px;
|
||||
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
|
||||
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
|
||||
transition: opacity .15s; margin-left: 0;
|
||||
}
|
||||
.page-toolbar :deep(.action-btn:hover) { opacity: .85; }
|
||||
.page-toolbar :deep(.action-btn.secondary) {
|
||||
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
|
||||
}
|
||||
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="manager-plans">
|
||||
<div class="page-card">
|
||||
<div class="page-title">项目策划方案管理</div>
|
||||
<div class="desc">审核 / 修改 / 结算 项目策划方案</div>
|
||||
<el-form inline :model="q">
|
||||
<!-- 按原型 top=171-178 的筛选区: 项目编号(52) / 项目编号(141) / 策划方案名称(254) / 项目方向(519) / 策划方案名称(591) / 状态(939) / 备注(1138) / 备注(1177) -->
|
||||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||||
<el-form-item label="策划方案名称:"><el-input v-model="q.planName" placeholder="输入策划方案名称" clearable /></el-form-item>
|
||||
<el-form-item label="项目方向:">
|
||||
<el-select v-model="q.planDirection" placeholder="请选择" clearable>
|
||||
<el-option label="专项计划一" value="专项计划一" /><el-option label="专项计划二" value="专项计划二" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目类别:">
|
||||
<el-select v-model="q.planCategory" placeholder="请选择" clearable>
|
||||
<el-option label="共识" value="共识" /><el-option label="对外交流" value="对外交流" />
|
||||
<el-option label="学术项目" value="学术项目" /><el-option label="科研项目" value="科研项目" />
|
||||
<el-option label="指南" value="指南" /><el-option label="会议项目" value="会议项目" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态:">
|
||||
<el-select v-model="q.status" placeholder="请选择" clearable>
|
||||
<el-option label="待审核" value="0" /><el-option label="审核通过" value="1" /><el-option label="已退回" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算:">
|
||||
<el-select v-model="q.isSettled" placeholder="请选择" clearable>
|
||||
<el-option label="已结算" value="1" /><el-option label="未结算" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注:"><el-input v-model="q.remark" placeholder="输入备注" clearable /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button class="action-btn" @click="load">查找</el-button>
|
||||
<el-button class="action-btn secondary" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="filter-tip">*批量审核:策划案名称,投稿ID,审核结果,审核意见,只能审核不通过</div>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<!-- 批量按钮 (按原型 left: 批量审核 266 / 批量结算 432) -->
|
||||
<div class="page-toolbar">
|
||||
<el-button class="action-btn" :disabled="!selection.length" @click="batchAudit">
|
||||
批量审核<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="batchSettle">
|
||||
批量结算<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 列头顺序严格按原型 top=294-300 的 left: 策划方案名称 342 / 项目方向 569 / 项目类别 739 / 项目形式 864 / 设计文件 999 / 状态 1133 / 是否结算 1246 / 项目编号 1348 / 备注 1492 / 操作 1640 -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="planName" label="策划方案名称" min-width="200" fixed show-overflow-tooltip />
|
||||
<el-table-column prop="planDirection" label="项目方向" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="planCategory" label="项目类别" width="100" align="center" />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column label="设计文件" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.designFileUrl">
|
||||
<el-button link type="primary" @click="onFile(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="downloadFile(row)">下载</el-button>
|
||||
</template>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status==='1' ? 'success' : row.status==='2' ? 'danger' : 'warning'">
|
||||
{{ row.status==='1' ? '审核通过' : row.status==='2' ? '已退回' : '待审核' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否结算" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.isSettled==='1' ? 'success' : 'warning'">
|
||||
{{ row.isSettled==='1' ? '已结算' : '未结算' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectNo" label="项目编号" width="140" align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
|
||||
<!-- 操作列按原型 left 顺序: 审核 1586 / 修改 1640 / 结算 1693 -->
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
|
||||
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button link type="primary" @click="onSettle(row)" v-if="row.isSettled!=='1'">结算</el-button>
|
||||
<el-button link type="primary" v-if="row.designFileUrl" @click="downloadFile(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<!-- 审核 dialog (按原型: 通过/退回 radio + 项目编号通过时必填 + 审核意见 + 确认/取消) -->
|
||||
<el-dialog v-model="auditOpen" title="审核策划方案" width="500px">
|
||||
<el-form :model="auditForm" :rules="auditRules" ref="auditFormRef" label-width="100px">
|
||||
<el-form-item label="方案名称"><span>{{ auditForm.planName }}</span></el-form-item>
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio label="1">通过</el-radio><el-radio label="2">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auditForm.result==='1'" label="项目编号" prop="projectNo">
|
||||
<el-input v-model="auditForm.projectNo" placeholder="通过时项目编号为必填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见"><el-input v-model="auditForm.opinion" type="textarea" rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑 dialog -->
|
||||
<el-dialog v-model="editOpen" title="修改策划方案" width="640px" @closed="resetEditForm">
|
||||
<el-form :model="editForm" label-width="120px" :rules="editRules" ref="editFormRef">
|
||||
<el-form-item label="方案名称" prop="planName"><el-input v-model="editForm.planName" /></el-form-item>
|
||||
<el-form-item label="项目编号" prop="projectNo"><el-input v-model="editForm.projectNo" /></el-form-item>
|
||||
<el-form-item label="项目方向">
|
||||
<el-select v-model="editForm.planDirection" placeholder="请选择" style="width:100%">
|
||||
<el-option label="专项计划一" value="专项计划一" /><el-option label="专项计划二" value="专项计划二" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目类别">
|
||||
<el-select v-model="editForm.planCategory" placeholder="请选择" style="width:100%">
|
||||
<el-option label="共识" value="共识" /><el-option label="对外交流" value="对外交流" />
|
||||
<el-option label="学术项目" value="学术项目" /><el-option label="科研项目" value="科研项目" />
|
||||
<el-option label="指南" value="指南" /><el-option label="会议项目" value="会议项目" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="editForm.projectForm" placeholder="请选择" style="width:100%">
|
||||
<el-option label="线上" value="线上" /><el-option label="线下" value="线下" /><el-option label="线上+线下" value="线上+线下" /><el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设计方案">
|
||||
<el-input v-model="editForm.designFileUrl" placeholder="文件 URL(暂以文本输入)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 结算 dialog (按原型: 仅文字 + 确定/取消) -->
|
||||
<el-dialog v-model="settleOpen" title="结算策划方案" width="420px" :show-close="false">
|
||||
<p>您确定要结算吗?</p>
|
||||
<template #footer>
|
||||
<el-button @click="settleOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitSettle">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
|
||||
const q = ref({ planName: '', projectNo: '', planDirection: '', planCategory: '', status: '', isSettled: '', remark: '' })
|
||||
const rows = ref([]); const selection = ref([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('projectPlan', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() { q.value = { planName:'', projectNo:'', planDirection:'', planCategory:'', status:'', isSettled:'', remark:'' }; page.pageNum = 1; load() }
|
||||
function onSel(rs) { selection.value = rs }
|
||||
|
||||
async function batchAudit() {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选行')
|
||||
let ok = 0
|
||||
submitting.value = true
|
||||
for (const r of selection.value) {
|
||||
try { await bizUpdate('projectPlan', { planId: r.planId, status: '2', auditOpinion: '批量审核不通过' }); ok++ } catch {}
|
||||
}
|
||||
submitting.value = false
|
||||
ElMessage.success(`批量审核 ${ok}/${selection.value.length} 条`)
|
||||
selection.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
async function batchSettle() {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选行')
|
||||
submitting.value = true
|
||||
let ok = 0
|
||||
for (const r of selection.value) {
|
||||
try { await bizUpdate('projectPlan', { planId: r.planId, isSettled: '1' }); ok++ } catch {}
|
||||
}
|
||||
submitting.value = false
|
||||
ElMessage.success(`批量结算 ${ok}/${selection.value.length} 条`)
|
||||
selection.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
const auditOpen = ref(false)
|
||||
const auditFormRef = ref(null)
|
||||
const auditForm = reactive({ planId: '', planName: '', projectNo: '', result: '1', opinion: '' })
|
||||
const auditRules = {
|
||||
projectNo: [{ required: true, message: '通过时项目编号为必填', trigger: 'blur' }]
|
||||
}
|
||||
function onAudit(row) { auditForm.planId = row.planId; auditForm.planName = row.planName; auditForm.projectNo = row.projectNo || ''; auditForm.result = '1'; auditForm.opinion = ''; auditOpen.value = true }
|
||||
async function submitAudit() {
|
||||
try {
|
||||
await auditFormRef.value.validate()
|
||||
submitting.value = true
|
||||
await bizUpdate('projectPlan', { planId: auditForm.planId, status: auditForm.result, auditOpinion: auditForm.opinion, projectNo: auditForm.result === '1' ? auditForm.projectNo : undefined })
|
||||
ElMessage.success('审核已确认')
|
||||
auditOpen.value = false
|
||||
load()
|
||||
} catch (e) { if (e?.message) ElMessage.error(e.message) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const editOpen = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({ planId:'', planName:'', projectNo:'', planDirection:'', planCategory:'', projectForm:'', designFileUrl:'', remark:'' })
|
||||
const editRules = { planName:[{required:true,message:'请输入方案名称',trigger:'blur'}] }
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, {
|
||||
planId: row.planId, planName: row.planName, projectNo: row.projectNo,
|
||||
planDirection: row.planDirection, planCategory: row.planCategory,
|
||||
projectForm: row.projectForm, designFileUrl: row.designFileUrl || '', remark: row.remark || ''
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
function resetEditForm() { Object.assign(editForm, { planId:'', planName:'', projectNo:'', planDirection:'', planCategory:'', projectForm:'', designFileUrl:'', remark:'' }) }
|
||||
async function submitEdit() {
|
||||
await editFormRef.value.validate()
|
||||
try {
|
||||
submitting.value = true
|
||||
await bizUpdate('projectPlan', { ...editForm })
|
||||
ElMessage.success('已保存')
|
||||
editOpen.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const settleOpen = ref(false)
|
||||
const settleForm = reactive({ planId:'', planName:'' })
|
||||
function onSettle(row) { settleForm.planId = row.planId; settleForm.planName = row.planName; settleOpen.value = true }
|
||||
async function submitSettle() {
|
||||
try {
|
||||
submitting.value = true
|
||||
await bizUpdate('projectPlan', { planId: settleForm.planId, isSettled: '1' })
|
||||
ElMessage.success('已结算')
|
||||
settleOpen.value = false
|
||||
load()
|
||||
} catch { ElMessage.error('结算失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const previewOpen = ref(false); const previewUrl = ref(''); const previewTitle = ref('策划方案预览')
|
||||
function onFile(row) { previewUrl.value = row.designFileUrl; previewTitle.value = `策划方案 - ${row.planName || ''}`; previewOpen.value = true }
|
||||
function downloadFile(row) {
|
||||
if (!row.designFileUrl) return
|
||||
const a = document.createElement('a')
|
||||
a.href = row.designFileUrl
|
||||
a.download = row.planName || '设计文件'
|
||||
a.target = '_blank'
|
||||
a.click()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-plans { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.desc { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-tip { color: #ff4d4f; font-size: 12px; margin-top: 8px; }
|
||||
.page-toolbar { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.page-toolbar :deep(.action-btn) {
|
||||
height: 36px; padding: 0 20px; font-size: 14px; border-radius: 4px;
|
||||
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
|
||||
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
|
||||
transition: opacity .15s; margin-left: 0;
|
||||
}
|
||||
.page-toolbar :deep(.action-btn:hover) { opacity: .85; }
|
||||
.page-toolbar :deep(.action-btn.secondary) {
|
||||
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
|
||||
}
|
||||
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
|
||||
.page-toolbar :deep(.action-btn.is-disabled),
|
||||
.page-toolbar :deep(.action-btn:disabled) { background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; opacity: 1; cursor: not-allowed; }
|
||||
.page-toolbar :deep(.action-btn.secondary.is-disabled),
|
||||
.page-toolbar :deep(.action-btn.secondary:disabled) { background: #fff; color: #c0c4cc; border-color: #e4e7ed; }
|
||||
.page-toolbar :deep(.action-btn .badge) {
|
||||
display: inline-block; margin-left: 6px; padding: 0 6px; min-width: 18px; height: 18px;
|
||||
line-height: 18px; font-size: 11px; text-align: center;
|
||||
background: #fff; color: var(--brand-primary); border-radius: 9px;
|
||||
}
|
||||
.page-toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
|
||||
</style>
|
||||
@@ -0,0 +1,834 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 项目管理</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="赞助公司:"><el-input v-model="q.orgName" placeholder="输入赞助公司" clearable /></el-form-item>
|
||||
<el-form-item label="公司类型:">
|
||||
<el-select v-model="q.orgType" placeholder="全部" clearable style="width:120px">
|
||||
<el-option label="赞助方" value="sponsor" />
|
||||
<el-option label="执行方" value="executor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
|
||||
<el-form-item label="项目名称:"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
|
||||
<el-form-item label="项目形式:">
|
||||
<el-select v-model="q.projectForm" placeholder="请选择" clearable>
|
||||
<el-option label="线上" value="线上" /><el-option label="线下" value="下线" />
|
||||
<el-option label="线上+线下" value="线上+线下" /><el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目时间:">
|
||||
<el-date-picker v-model="q.startTime" type="date" placeholder="开始日期" value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
<span style="margin:0 4px;">至</span>
|
||||
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题:">
|
||||
<el-select v-model="q.isFinished" placeholder="请选择" clearable>
|
||||
<el-option label="已结题" value="1" /><el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结算:">
|
||||
<el-select v-model="q.isSettled" placeholder="请选择" clearable>
|
||||
<el-option label="已结算" value="1" /><el-option label="未结算" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价得分:"><el-input v-model="q.ratingScore" placeholder="输入评分" clearable /></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button class="action-btn" @click="openNewProject">新建项目</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openAssign">
|
||||
项目分配<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('score')">
|
||||
批量评分<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('close')">
|
||||
批量结题<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
<el-button class="action-btn secondary" :disabled="!selection.length" @click="openBatch('activate')">
|
||||
批量开通<span v-if="selection.length" class="badge">{{ selection.length }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="140" fixed="left" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="totalSessions" label="总场次/总期数" width="100" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column prop="totalAmount" label="总金额" width="130" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="availableAmount" label="可用金额" width="130" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidLaborAmount" label="已支付劳务费" width="140" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidMeetingAmount" label="已支付会务费" width="140" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="complianceScore" label="执行单位得分(合规)" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.ratingScore != null">{{ row.ratingScore }}</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="赞助公司" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="orgType" label="公司类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
|
||||
{{ row.orgType === 'sponsor' ? '赞助方' : '执行方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isFinished === '1' || row.isFinished === 1" type="success">已结题</el-tag>
|
||||
<el-tag v-else type="warning">未结题</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="项目开始时间" width="120" align="center" :formatter="fmtDate" />
|
||||
<el-table-column prop="endTime" label="项目结束时间" width="120" align="center" :formatter="fmtDate" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="op-cell">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="doEdit(row)">编辑</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd) => onAction(cmd, row)">
|
||||
<el-button link type="primary" class="op-dropdown">
|
||||
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="createMeeting">建会</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
|
||||
<el-dropdown-item command="assign">项目分配</el-dropdown-item>
|
||||
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="activate" divided>开通</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.isPublished === '1' || row.publishUrl" command="delAnn" divided>删除公告</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
|
||||
<!-- 项目详情已迁到独立页面 /manager/projects/detail/:projectId -->
|
||||
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
|
||||
<!-- ============= 新建项目 dialog(已迁移到独立页面 /manager/projects/new) ============= -->
|
||||
|
||||
<!-- ============= 项目分配 dialog(按原型 project-assign.html 1:1) ============= -->
|
||||
<el-dialog v-model="assignOpen" :title="assignBatchMode ? `批量项目分配 (${assignBatchProjects.length} 个项目)` : '项目分配'" width="780px" @closed="resetAssignForm">
|
||||
<el-form :model="assignForm" label-width="120px">
|
||||
<!-- 单选模式: 选项目 -->
|
||||
<el-form-item v-if="!assignBatchMode" label="项目">
|
||||
<el-select v-model="assignForm.projectId" placeholder="请选择项目" filterable style="width:100%" @change="onAssignProjectChange">
|
||||
<el-option v-for="r in rows" :key="r.projectId" :label="`${r.projectNo} - ${r.projectName}`" :value="r.projectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 批量模式: 显示已选项目数 -->
|
||||
<el-form-item v-else label="已选项目">
|
||||
<div style="color:#606266;font-size:13px">共 <b>{{ assignBatchProjects.length }}</b> 个项目</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="支持方">
|
||||
<el-select v-model="assignForm.orgId" placeholder="请选择赞助公司" filterable :filter-method="searchSupporters" clearable style="width:100%" @change="onSupporterPick">
|
||||
<el-option v-for="u in supporterOptions" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-divider>执行方分配</el-divider>
|
||||
<el-table :data="assignForm.execRows" border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column label="执行方名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.execUserId" filterable
|
||||
:filter-method="q => searchExecutors(q, row)"
|
||||
:loading="row._loading"
|
||||
placeholder="搜索执行方用户"
|
||||
style="width:100%"
|
||||
@change="v => onExecUserPick(row, v)">
|
||||
<el-option v-for="u in (row._options || [])" :key="u.userId"
|
||||
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`" :value="u.userId" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="场次" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sessions" :min="0" controls-position="right" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="assignForm.execRows.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:8px">
|
||||
<el-button type="primary" link @click="addExecRow">+ 增加执行方</el-button>
|
||||
</div>
|
||||
<el-form-item label="提交截止(天)" style="margin-top:12px">
|
||||
<el-input-number v-model="assignForm.deadlineDays" :min="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="assignSubmitting" @click="submitAssign">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 批量评分 dialog(按原型 4 维度评分) ============= -->
|
||||
<el-dialog v-model="batchOpen" :title="batchMap[batchKind]" width="626px" :show-close="false">
|
||||
<!-- 评分专用字段: 共享一份评分 (与 singleScoreForm 同样的三列 table 布局) -->
|
||||
<template v-if="batchKind==='score'">
|
||||
<p style="color:#606266;margin-bottom:8px">将对以下 <b>{{ selection.length }}</b> 个项目共用同一份评分:</p>
|
||||
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:#fafafa">
|
||||
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
|
||||
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
|
||||
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.q1" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.q2" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.q3" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="batchScoreForm.q4" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<!-- 结题专用字段 -->
|
||||
<template v-else-if="batchKind==='close'">
|
||||
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
|
||||
<p style="color:#606266;margin-top:8px">确定要将这些项目标记为结题吗?</p>
|
||||
</template>
|
||||
<!-- 开通专用字段 -->
|
||||
<template v-else-if="batchKind==='activate'">
|
||||
<p>已选择 <b>{{ selection.length }}</b> 个项目。</p>
|
||||
<el-form-item label="开通截止时间"><el-date-picker v-model="activateDeadline" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="batchOpen=false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchSubmitting" @click="confirmBatch">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 单行结题 modal ============= -->
|
||||
<el-dialog v-model="closeModalOpen" title="您确定要结题吗?" width="420px" :show-close="false">
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmClose">确定</el-button>
|
||||
<el-button @click="closeModalOpen=false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 单行开通 modal ============= -->
|
||||
<el-dialog v-model="activateModalOpen" title="您确定要开通吗?" width="420px" :show-close="false">
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmActivate">确定</el-button>
|
||||
<el-button @click="activateModalOpen=false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 单行删除公告 modal ============= -->
|
||||
<el-dialog v-model="delAnnModalOpen" title="您确定要删除公告吗?删除后无法恢复" width="420px" :show-close="false">
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmDeleteAnn">确定</el-button>
|
||||
<el-button @click="delAnnModalOpen=false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============= 单行执行单位评分 modal(4 维度) ============= -->
|
||||
<el-dialog v-model="singleScoreModalOpen" title="执行单位评分" width="626px" :show-close="false">
|
||||
<table class="score-table" style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:#fafafa">
|
||||
<th style="width:110px;padding:10px 12px;text-align:left">评价维度</th>
|
||||
<th style="padding:10px 12px;text-align:left">评价内容(简洁版)</th>
|
||||
<th style="width:130px;padding:10px 12px;text-align:center">评分标准(1~5 分)</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">履约质量</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">服务/活动效果达标度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.q1" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">时效响应</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">执行 & 售后响应速度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.q2" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">配合度</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">沟通配合 & 问题处理</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.q3" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
<tr><td style="padding:10px 12px;border-top:1px solid #f5f5f5">合规安全</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5">流程合规 & 无安全事故</td><td style="padding:10px 12px;border-top:1px solid #f5f5f5;text-align:center"><el-input-number v-model="singleScoreForm.q4" :min="0" :max="5" :step="1" controls-position="right" placeholder="1~5" /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmSingleScore">确定</el-button>
|
||||
<el-button @click="singleScoreModalOpen=false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
|
||||
import { listExecutor, listSupporters } from '@/api/system'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const q = ref({ orgName: '', orgType: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', ratingScore: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
console.info('[manager-projects.load] START, q=', JSON.stringify(q.value), 'page=', JSON.stringify(page))
|
||||
// axios 拦截器已将 TableDataInfo 归一化为 {code, msg, data: {total, rows}}
|
||||
const { data } = await bizList('project', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
console.info('[manager-projects.load] OK, total=', data?.total, 'rows.length=', data?.rows?.length, 'firstRow=', JSON.stringify(data?.rows?.[0] || null))
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) {
|
||||
console.error('[manager-projects.load] FAILED', e)
|
||||
rows.value = []; page.total = 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
// 操作列按钮 handlers
|
||||
function onAction(cmd, row) {
|
||||
if (cmd === 'createMeeting') doCreateMeeting(row)
|
||||
else if (cmd === 'close') openClose(row)
|
||||
else if (cmd === 'assign') doAssign(row)
|
||||
else if (cmd === 'rate') openSingleScore(row)
|
||||
else if (cmd === 'activate') openActivate(row)
|
||||
else if (cmd === 'delAnn') openDeleteAnnouncement(row)
|
||||
}
|
||||
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
|
||||
function doAssign(row) {
|
||||
resetAssignForm()
|
||||
assignForm.projectId = row.projectId
|
||||
// 先打开 dialog, 再异步加载 (loadAssigns 是 async)
|
||||
assignOpen.value = true
|
||||
onAssignProjectChange(row.projectId)
|
||||
// 预加载 sponsor 候选
|
||||
setTimeout(() => loadSupporters(''), 100)
|
||||
}
|
||||
function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) }
|
||||
|
||||
// 删除项目(按需补充,原型无但需要)
|
||||
async function doDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除项目"${row.projectName}"?\n项目编号: ${row.projectNo}\n\n删除后无法恢复!`, '删除确认', { type: 'error', confirmButtonText: '确认删除', cancelButtonText: '取消' })
|
||||
await bizDelete('project', row.projectId)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { q.value = { orgName:'', orgType:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', ratingScore:'' }; page.pageNum = 1; load() }
|
||||
|
||||
// 导出
|
||||
function exportProjects() { ElMessage.info('导出项目功能开发中') }
|
||||
function exportEval() { ElMessage.info('项目评价导出功能开发中') }
|
||||
function exportExperts() { ElMessage.info('已报名专家信息导出功能开发中') }
|
||||
|
||||
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog) ==========
|
||||
function openClose(row) {
|
||||
if (row.isFinished === '1' || row.isFinished === 1) {
|
||||
return ElMessage.warning('该项目已结题,无需重复操作')
|
||||
}
|
||||
closeTargetRow.value = row
|
||||
closeModalOpen.value = true
|
||||
}
|
||||
function confirmClose() {
|
||||
if (!closeTargetRow.value) return
|
||||
bizUpdate('project', { projectId: closeTargetRow.value.projectId, isFinished: '1' })
|
||||
.then(() => { ElMessage.success('结题成功'); closeModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('结题失败'))
|
||||
}
|
||||
|
||||
function openActivate(row) {
|
||||
activateTargetRow.value = row
|
||||
activateModalOpen.value = true
|
||||
}
|
||||
function confirmActivate() {
|
||||
if (!activateTargetRow.value) return
|
||||
bizUpdate('project', { projectId: activateTargetRow.value.projectId, isFinished: '0' })
|
||||
.then(() => { ElMessage.success('已开通'); activateModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('开通失败'))
|
||||
}
|
||||
|
||||
function openDeleteAnnouncement(row) {
|
||||
delAnnTargetRow.value = row
|
||||
delAnnModalOpen.value = true
|
||||
}
|
||||
function confirmDeleteAnn() {
|
||||
if (!delAnnTargetRow.value) return
|
||||
// 清空全部公示 URL + 取消发布状态
|
||||
// (mapper.updateByPrimaryKey 的 trim prefix="SET" 必须有字段, 不能只传空字符串)
|
||||
bizUpdate('project', {
|
||||
projectId: delAnnTargetRow.value.projectId,
|
||||
publishUrl: '',
|
||||
invitationUrl: '',
|
||||
supportLetterUrl: '',
|
||||
noticeUrl: '',
|
||||
scheduleUrl: '',
|
||||
isPublished: '0'
|
||||
})
|
||||
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('删除失败'))
|
||||
}
|
||||
|
||||
function openSingleScore(row) {
|
||||
singleScoreTargetRow.value = row
|
||||
// 反显各维度原值 (从后端 ratingQ1~Q4 字段); 无值才归零
|
||||
singleScoreForm.q1 = row.ratingQ1 != null ? Number(row.ratingQ1) : 0
|
||||
singleScoreForm.q2 = row.ratingQ2 != null ? Number(row.ratingQ2) : 0
|
||||
singleScoreForm.q3 = row.ratingQ3 != null ? Number(row.ratingQ3) : 0
|
||||
singleScoreForm.q4 = row.ratingQ4 != null ? Number(row.ratingQ4) : 0
|
||||
singleScoreModalOpen.value = true
|
||||
}
|
||||
function confirmSingleScore() {
|
||||
if (!singleScoreTargetRow.value) return
|
||||
const dims = [singleScoreForm.q1, singleScoreForm.q2, singleScoreForm.q3, singleScoreForm.q4]
|
||||
const labels = ['履约质量', '时效响应', '配合度', '合规安全']
|
||||
// 4 个维度全部必填
|
||||
for (let i = 0; i < dims.length; i++) {
|
||||
if (dims[i] === 0 || dims[i] === '' || dims[i] == null) {
|
||||
return ElMessage.error(`请填写「${labels[i]}」评分`)
|
||||
}
|
||||
const v = Number(dims[i])
|
||||
if (isNaN(v) || v < 1 || v > 5) return ElMessage.error(`${labels[i]}必须是 1~5 的数字`)
|
||||
}
|
||||
// 校验通过, 计算平均分
|
||||
const scores = dims.map(v => Number(v))
|
||||
const avg = (scores.reduce((s, v) => s + v, 0) / scores.length).toFixed(1)
|
||||
// 构造 payload: 平均分 + 4 个维度分
|
||||
const payload = {
|
||||
projectId: singleScoreTargetRow.value.projectId,
|
||||
ratingScore: Number(avg),
|
||||
ratingQ1: scores[0],
|
||||
ratingQ2: scores[1],
|
||||
ratingQ3: scores[2],
|
||||
ratingQ4: scores[3]
|
||||
}
|
||||
bizUpdate('project', payload)
|
||||
.then(() => { ElMessage.success(`评分完成,平均分:${avg}`); singleScoreModalOpen.value = false; load() })
|
||||
.catch(() => ElMessage.error('评分失败'))
|
||||
}
|
||||
function viewDetail(row) { router.push(`/manager/projects/detail/${row.projectId}`) }
|
||||
|
||||
// 金额格式化: 1234567.5 -> "1,234,567.50"
|
||||
function fmtMoney(row, col, v) {
|
||||
if (v === null || v === undefined || v === '') return ''
|
||||
const n = Number(v)
|
||||
if (isNaN(n)) return String(v)
|
||||
return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
}
|
||||
// 日期格式化: "2026-08-09" 或 "2026-08-09 10:30:00" -> "2026.08.09"
|
||||
function fmtDate(row, col, v) {
|
||||
if (!v) return ''
|
||||
const s = String(v)
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
return m ? `${m[1]}.${m[2]}.${m[3]}` : s
|
||||
}
|
||||
|
||||
const selection = ref([])
|
||||
function onSel(rs) { selection.value = rs }
|
||||
const batchOpen = ref(false)
|
||||
const batchKind = ref('')
|
||||
const batchSubmitting = ref(false)
|
||||
const batchMap = { score: '批量评分', close: '批量结题', activate: '批量开通' }
|
||||
const batchScoreRows = ref([]) // 保留兼容旧引用 (实际不再使用)
|
||||
const batchScoreForm = reactive({ q1: 5, q2: 5, q3: 5, q4: 5 }) // 批量评分: 共享一份 4 维度
|
||||
const activateDeadline = ref('')
|
||||
|
||||
// 5 个单行 modal 状态
|
||||
const closeModalOpen = ref(false)
|
||||
const closeTargetRow = ref(null)
|
||||
const activateModalOpen = ref(false)
|
||||
const activateTargetRow = ref(null)
|
||||
const delAnnModalOpen = ref(false)
|
||||
const delAnnTargetRow = ref(null)
|
||||
const singleScoreModalOpen = ref(false)
|
||||
const singleScoreTargetRow = ref(null)
|
||||
const singleScoreForm = reactive({ q1: 0, q2: 0, q3: 0, q4: 0 })
|
||||
|
||||
// 新建项目已迁到独立页面 /manager/projects/new (ManagerProjectsNew.vue)
|
||||
function openNewProject() {
|
||||
router.push('/manager/projects/new')
|
||||
}
|
||||
function openAssign() {
|
||||
if (!selection.value.length) {
|
||||
ElMessage.warning('请先勾选项目')
|
||||
return
|
||||
}
|
||||
resetAssignForm()
|
||||
if (selection.value.length >= 2) {
|
||||
assignBatchMode.value = true
|
||||
assignBatchProjects.value = selection.value.map(r => ({ projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName }))
|
||||
} else {
|
||||
assignBatchMode.value = false
|
||||
assignBatchProjects.value = []
|
||||
assignForm.projectId = selection.value[0].projectId
|
||||
onAssignProjectChange(assignForm.projectId)
|
||||
}
|
||||
assignOpen.value = true
|
||||
setTimeout(() => {
|
||||
assignForm.execRows.forEach(r => searchExecutors('', r))
|
||||
loadSupporters('')
|
||||
}, 100)
|
||||
}
|
||||
function openBatch(kind) {
|
||||
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
|
||||
batchKind.value = kind
|
||||
if (kind === 'score') {
|
||||
// 共享评分: 重置 form 为默认值 (5 分)
|
||||
Object.assign(batchScoreForm, { q1: 5, q2: 5, q3: 5, q4: 5 })
|
||||
}
|
||||
if (kind === 'activate') {
|
||||
activateDeadline.value = ''
|
||||
}
|
||||
batchOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatch() {
|
||||
if (batchKind.value === 'close') {
|
||||
// 过滤掉已结题项目, 避免无效请求
|
||||
const targets = selection.value.filter(r => r.isFinished !== '1' && r.isFinished !== 1)
|
||||
const skipCount = selection.value.length - targets.length
|
||||
try {
|
||||
batchSubmitting.value = true
|
||||
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: '1' })))
|
||||
ElMessage.success(skipCount > 0
|
||||
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
|
||||
: `已批量结题 ${targets.length} 个项目`)
|
||||
batchOpen.value = false
|
||||
selection.value = []
|
||||
load()
|
||||
} catch { ElMessage.error('批量结题失败') }
|
||||
finally { batchSubmitting.value = false }
|
||||
} else if (batchKind.value === 'activate') {
|
||||
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
|
||||
try {
|
||||
batchSubmitting.value = true
|
||||
await Promise.all(selection.value.map(r => bizUpdate('project', { projectId: r.projectId, endTime: activateDeadline.value })))
|
||||
ElMessage.success(`已批量开通 ${selection.value.length} 个项目`)
|
||||
batchOpen.value = false
|
||||
selection.value = []
|
||||
load()
|
||||
} catch { ElMessage.error('批量开通失败') }
|
||||
finally { batchSubmitting.value = false }
|
||||
} else if (batchKind.value === 'score') {
|
||||
const q1 = Number(batchScoreForm.q1 || 0)
|
||||
const q2 = Number(batchScoreForm.q2 || 0)
|
||||
const q3 = Number(batchScoreForm.q3 || 0)
|
||||
const q4 = Number(batchScoreForm.q4 || 0)
|
||||
const total = ((q1 + q2 + q3 + q4) / 4).toFixed(1)
|
||||
try {
|
||||
batchSubmitting.value = true
|
||||
let ok = 0
|
||||
// 共享评分: 所有项目用同一组 q1..q4
|
||||
for (const r of selection.value) {
|
||||
try {
|
||||
await bizUpdate('project', {
|
||||
projectId: r.projectId,
|
||||
ratingScore: Number(total),
|
||||
ratingQ1: q1,
|
||||
ratingQ2: q2,
|
||||
ratingQ3: q3,
|
||||
ratingQ4: q4
|
||||
})
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
ElMessage.success(`已批量评分 ${ok}/${selection.value.length} 个项目 (共用评分 ${total})`)
|
||||
batchOpen.value = false
|
||||
selection.value = []
|
||||
load()
|
||||
} finally { batchSubmitting.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 新建项目 ==========
|
||||
const newOpen = ref(false)
|
||||
// 新建项目独立页面: /manager/projects/new (ManagerProjectsNew.vue)
|
||||
|
||||
// ========== 项目分配 ==========
|
||||
const assignOpen = ref(false)
|
||||
const assignBatchMode = ref(false) // 项目分配批量模式开关
|
||||
const assignBatchProjects = ref([]) // 批量模式下的项目列表 [{projectId, projectNo, projectName}]
|
||||
const assignSubmitting = ref(false)
|
||||
const assignForm = reactive({
|
||||
projectId: '',
|
||||
orgId: null,
|
||||
orgName: '',
|
||||
execRows: [], // 动态加
|
||||
deadlineDays: 30
|
||||
})
|
||||
const supporterOptions = ref([])
|
||||
let _supporterTimer = null
|
||||
|
||||
function makeEmptyExecRow() {
|
||||
return { _loading: false, _timer: null, _options: [], execUserId: null, execUserName: '', execNickName: '', execOrg: '', sessions: 0, amount: 0, remark: '' }
|
||||
}
|
||||
|
||||
function resetAssignForm() {
|
||||
Object.assign(assignForm, { projectId:'', orgId:null, orgName:'', execRows:[makeEmptyExecRow()], deadlineDays:30 })
|
||||
supporterOptions.value = []
|
||||
assignBatchMode.value = false
|
||||
assignBatchProjects.value = []
|
||||
}
|
||||
|
||||
function addExecRow() {
|
||||
const row = makeEmptyExecRow()
|
||||
assignForm.execRows.push(row)
|
||||
// 新行立即加载默认列表
|
||||
searchExecutors('', row)
|
||||
}
|
||||
|
||||
function onAssignProjectChange(v) {
|
||||
const proj = rows.value.find(r => r.projectId === v)
|
||||
if (proj) {
|
||||
assignForm.orgId = proj.orgId || null
|
||||
assignForm.orgName = proj.orgName || ''
|
||||
}
|
||||
// 切换项目时重新拉已分配记录 (回显)
|
||||
loadAssigns(v)
|
||||
// 重新拉支持方候选
|
||||
loadSupporters('')
|
||||
}
|
||||
|
||||
// 加载支持方 (sponsor 角色)
|
||||
async function loadSupporters(query) {
|
||||
if (_supporterTimer) clearTimeout(_supporterTimer)
|
||||
_supporterTimer = setTimeout(async () => {
|
||||
try {
|
||||
const params = { pageNum: 1, pageSize: 50 }
|
||||
if (query) params.userName = query
|
||||
const r = await listSupporters(params)
|
||||
supporterOptions.value = r.data || []
|
||||
// 已选项不在结果里时单独拉取并加首位
|
||||
if (assignForm.orgId && !supporterOptions.value.find(u => u.userId === assignForm.orgId)) {
|
||||
const r2 = await listSupporters({ userId: assignForm.orgId })
|
||||
const sel = (r2.data || []).find(u => u.userId === assignForm.orgId)
|
||||
if (sel) supporterOptions.value = [sel, ...supporterOptions.value]
|
||||
}
|
||||
} catch (e) {
|
||||
supporterOptions.value = []
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function searchSupporters(q) { loadSupporters(q) }
|
||||
|
||||
// 选中支持方后回填名称
|
||||
function onSupporterPick(userId) {
|
||||
const u = supporterOptions.value.find(x => x.userId === userId)
|
||||
if (u) assignForm.orgName = u.nickName || u.userName
|
||||
}
|
||||
|
||||
// 拉取项目已分配的执行方
|
||||
async function loadAssigns(projectId) {
|
||||
if (!projectId) { assignForm.execRows = [makeEmptyExecRow()]; return }
|
||||
try {
|
||||
const r = await request({ url: `/business/project/${projectId}/assigns`, method: 'get' })
|
||||
const list = r.data || []
|
||||
if (list.length) {
|
||||
assignForm.execRows = list.map(a => ({
|
||||
...makeEmptyExecRow(),
|
||||
execUserId: a.execUserId,
|
||||
execUserName: a.execUserName,
|
||||
execNickName: a.execNickName,
|
||||
execOrg: a.execOrg,
|
||||
sessions: a.sessions || 0,
|
||||
amount: Number(a.amount || 0),
|
||||
remark: a.remark || ''
|
||||
}))
|
||||
} else {
|
||||
assignForm.execRows = [makeEmptyExecRow()]
|
||||
}
|
||||
// 每一行立即触发远程搜索, 让 el-select 拿到 _options 才能正确显示已选 label
|
||||
assignForm.execRows.forEach(r => searchExecutors('', r))
|
||||
} catch (e) {
|
||||
console.error('加载已分配执行方失败', e)
|
||||
assignForm.execRows = [makeEmptyExecRow()]
|
||||
assignForm.execRows.forEach(r => searchExecutors('', r))
|
||||
}
|
||||
}
|
||||
|
||||
// 加载执行方 (row 级 debounce, 每个 row 独立 timer)
|
||||
async function searchExecutors(query, row) {
|
||||
if (row._timer) clearTimeout(row._timer)
|
||||
row._loading = true
|
||||
row._timer = setTimeout(async () => {
|
||||
try {
|
||||
const params = { pageNum: 1, pageSize: 20 }
|
||||
if (query) params.userName = query
|
||||
const r = await listExecutor(params)
|
||||
row._options = r.data || []
|
||||
// 选中的项如果不在结果里, 加到首位
|
||||
if (row.execUserId && !row._options.find(u => u.userId === row.execUserId)) {
|
||||
const r2 = await listExecutor({ userId: row.execUserId })
|
||||
const sel = (r2.data || []).find(u => u.userId === row.execUserId)
|
||||
if (sel) row._options = [sel, ...row._options]
|
||||
}
|
||||
} catch (e) {
|
||||
row._options = []
|
||||
} finally {
|
||||
row._loading = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 选中执行方后回填
|
||||
function onExecUserPick(row, userId) {
|
||||
const u = (row._options || []).find(x => x.userId === userId)
|
||||
if (u) {
|
||||
row.execUserName = u.userName
|
||||
row.execNickName = u.nickName || ''
|
||||
row.execOrg = u.dept?.deptName || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAssign() {
|
||||
// 批量模式不需要选项目, 单选模式必选
|
||||
if (!assignBatchMode.value && !assignForm.projectId) return ElMessage.warning('请选择项目')
|
||||
const valid = assignForm.execRows.filter(r => r.execUserId && Number(r.sessions) > 0)
|
||||
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
|
||||
const amount = valid.reduce((s, r) => s + Number(r.amount || 0), 0)
|
||||
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
|
||||
const assignBody = valid.map(r => ({
|
||||
execUserId: r.execUserId,
|
||||
execUserName: r.execUserName,
|
||||
execNickName: r.execNickName,
|
||||
execOrg: r.execOrg,
|
||||
sessions: r.sessions,
|
||||
amount: r.amount,
|
||||
remark: r.remark
|
||||
}))
|
||||
assignSubmitting.value = true
|
||||
try {
|
||||
if (assignBatchMode.value) {
|
||||
// 批量: 每个项目单独更新 + 单独写分配 (后端先删后插策略)
|
||||
let ok = 0, errs = []
|
||||
for (const p of assignBatchProjects.value) {
|
||||
try {
|
||||
await bizUpdate('project', {
|
||||
projectId: p.projectId,
|
||||
orgId: assignForm.orgId,
|
||||
orgName: assignForm.orgName,
|
||||
totalAmount: amount,
|
||||
totalSessions: sessions,
|
||||
submitDeadlineDays: assignForm.deadlineDays,
|
||||
})
|
||||
await request({
|
||||
url: `/business/project/${p.projectId}/assigns`,
|
||||
method: 'post',
|
||||
data: assignBody
|
||||
})
|
||||
ok++
|
||||
} catch (e) {
|
||||
errs.push(`${p.projectNo}: ${e?.msg || e.message}`)
|
||||
}
|
||||
}
|
||||
if (errs.length) ElMessageBox.alert(`批量分配: 成功 ${ok}/${assignBatchProjects.value.length}\n\n失败:\n${errs.join('\n')}`, '分配结果', { type: 'warning' })
|
||||
else ElMessage.success(`批量分配成功 (${ok}/${assignBatchProjects.value.length})`)
|
||||
} else {
|
||||
// 单条模式
|
||||
await bizUpdate('project', {
|
||||
projectId: assignForm.projectId,
|
||||
orgId: assignForm.orgId,
|
||||
orgName: assignForm.orgName,
|
||||
totalAmount: amount,
|
||||
totalSessions: sessions,
|
||||
submitDeadlineDays: assignForm.deadlineDays,
|
||||
})
|
||||
await request({
|
||||
url: `/business/project/${assignForm.projectId}/assigns`,
|
||||
method: 'post',
|
||||
data: assignBody
|
||||
})
|
||||
ElMessage.success('分配成功')
|
||||
}
|
||||
assignOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error(e?.msg || '分配失败')
|
||||
}
|
||||
finally { assignSubmitting.value = false }
|
||||
}
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('附件预览')
|
||||
function openPreview(url, title) { previewUrl.value = url; previewTitle.value = title || '附件预览'; previewOpen.value = true }
|
||||
|
||||
onMounted(load)
|
||||
// 监听路由变化 (从 /manager/projects/new 跳回时强制重载)
|
||||
watch(() => route.fullPath, (newPath, oldPath) => {
|
||||
if (newPath === '/manager/projects') {
|
||||
console.info('[manager-projects] route watch reload, from=', oldPath, 'to=', newPath)
|
||||
load()
|
||||
}
|
||||
})
|
||||
// 兜底: 即便路由完全相同,从列表跳到详情再回来也触发
|
||||
watch(() => route.path + (route.query._t || ''), () => load())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 sponsor/SponsorProjects.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* 操作列下拉 */
|
||||
.op-cell { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.op-cell :deep(.el-button) { margin-left: 0 !important; padding: 0 4px !important; }
|
||||
.op-dropdown { padding-right: 0; }
|
||||
.op-caret { margin-left: 2px; font-size: 10px; }
|
||||
|
||||
/* 工具按钮 (与 sponsor 一致) */
|
||||
.toolbar :deep(.action-btn) {
|
||||
height: 32px; padding: 0 16px; font-size: 13px; border-radius: 4px;
|
||||
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
|
||||
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.toolbar :deep(.action-btn:hover) { opacity: .85; }
|
||||
.toolbar :deep(.action-btn.secondary) {
|
||||
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
|
||||
}
|
||||
.toolbar :deep(.action-btn:disabled) {
|
||||
background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; cursor: not-allowed;
|
||||
}
|
||||
.toolbar :deep(.badge) {
|
||||
margin-left: 4px; padding: 0 6px; background: var(--brand-primary); color: #fff;
|
||||
border-radius: 8px; font-size: 11px; line-height: 16px;
|
||||
}
|
||||
.toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
|
||||
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
|
||||
.page-toolbar :deep(.action-btn.is-disabled),
|
||||
.page-toolbar :deep(.action-btn:disabled) { background: #f5f7fa; color: #c0c4cc; border-color: #e4e7ed; opacity: 1; cursor: not-allowed; }
|
||||
.page-toolbar :deep(.action-btn.secondary.is-disabled),
|
||||
.page-toolbar :deep(.action-btn.secondary:disabled) { background: #fff; color: #c0c4cc; border-color: #e4e7ed; }
|
||||
.page-toolbar :deep(.action-btn .badge) {
|
||||
display: inline-block; margin-left: 6px; padding: 0 6px; min-width: 18px; height: 18px;
|
||||
line-height: 18px; font-size: 11px; text-align: center;
|
||||
background: #fff; color: var(--brand-primary); border-radius: 9px;
|
||||
}
|
||||
.page-toolbar :deep(.action-btn.secondary .badge) { background: var(--brand-primary); color: #fff; }
|
||||
.score-row { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.score-row:last-child { border-bottom: none; }
|
||||
.score-title { font-weight: 600; color: #303133; margin-bottom: 6px; }
|
||||
.score-no { color: #909399; font-weight: normal; font-size: 12px; }
|
||||
.score-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.score-item { display: flex; align-items: center; gap: 8px; }
|
||||
.score-item span { width: 70px; color: #606266; font-size: 13px; }
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 (与 sponsor 端一致: 首页 / XXX / 当前页) -->
|
||||
<div class="breadcrumb">
|
||||
首页 / 项目管理 / <span class="current">{{ isEdit ? '编辑项目' : '新建项目' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第一区块:项目信息 -->
|
||||
<el-card shadow="never" class="new-card">
|
||||
<div class="new-card-title">项目信息</div>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目名称" prop="projectName">
|
||||
<el-input v-model="form.projectName" placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目编号" prop="projectNo">
|
||||
<el-input v-model="form.projectNo" placeholder="请输入项目编号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目形式" prop="projectForm">
|
||||
<el-select v-model="form.projectForm" placeholder="请选择" style="width:100%">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总场次/总期数" prop="totalSessions">
|
||||
<el-input-number v-model="form.totalSessions" :min="1" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总金额(元)" prop="totalAmount">
|
||||
<el-input-number v-model="form.totalAmount" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="管理费(元)">
|
||||
<el-input-number v-model="form.manageFee" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束时间" prop="endTime">
|
||||
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<!-- 支持单位 -->
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="提交截止(天)">
|
||||
<el-input-number v-model="form.submitDeadlineDays" :min="0" controls-position="right" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="支持单位">
|
||||
<el-select v-model="form.orgId" placeholder="请选择赞助公司" filterable clearable style="width:100%" @change="onSupporterPick">
|
||||
<el-option v-for="u in supporterOptions" :key="u.userId"
|
||||
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`"
|
||||
:value="u.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 第二区块:角色劳务设置 -->
|
||||
<el-card shadow="never" class="new-card">
|
||||
<div class="new-card-title">角色劳务设置</div>
|
||||
<div class="hint-text">
|
||||
<p>*角色劳务设置针对该项目所有会议生效</p>
|
||||
<p>*专家劳务明细当会议劳务金额超过角色劳务设置时,该劳务金额为税后金额,则系统进行提示</p>
|
||||
</div>
|
||||
<div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
|
||||
<el-select v-model="r.role" placeholder="请选择" style="width:160px" @change="onRoleSelectChange(r)">
|
||||
<el-option label="主席" value="主席" />
|
||||
<el-option label="主持" value="主持" />
|
||||
<el-option label="讲者" value="讲者" />
|
||||
<el-option label="点评/评审" value="点评/评审" />
|
||||
<el-option label="讨论" value="讨论" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
<el-input v-if="r.role === '其他'" v-model="r.customName" placeholder="角色名" class="role-custom" />
|
||||
<el-input-number v-model="r.amount" :min="0" :precision="2" placeholder="劳务金额" class="role-amount" />
|
||||
<!-- 行尾按钮: 每行末尾 [增加角色] + [删除本行] (不靠右, 紧贴字段) -->
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="addRoleRow">增加角色</el-button>
|
||||
<el-button link type="danger" :disabled="form.roleRows.length<=1" @click="removeRoleRowAt(idx)">删除角色</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">*当选择其他时,需手动填写角色名称,显示填写框</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 第三区块:公告文件 -->
|
||||
<el-card shadow="never" class="new-card">
|
||||
<div class="new-card-title">公告文件</div>
|
||||
<p class="hint-text">*勾选公告类型后,上传对应文件;发布公告时可同时发布多个类型</p>
|
||||
<div class="notice-list">
|
||||
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
|
||||
<el-checkbox v-model="n.enabled" :label="n.key" class="notice-check">
|
||||
{{ n.label }}
|
||||
</el-checkbox>
|
||||
<el-upload
|
||||
v-if="n.enabled"
|
||||
class="notice-uploader"
|
||||
:show-file-list="true"
|
||||
:file-list="getFileList(n)"
|
||||
:before-upload="(file) => beforeUpload(file, n)"
|
||||
:http-request="(opts) => httpUpload(opts, n, idx)"
|
||||
:on-remove="(file) => onRemove(n, file)"
|
||||
:on-preview="(file) => onPreview(file)"
|
||||
accept=".pdf,.doc,.docx"
|
||||
drag
|
||||
action="#"
|
||||
>
|
||||
<i class="el-icon-upload"></i>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<div class="el-upload__tip" slot="tip">
|
||||
支持 PDF / Word,单文件 ≤ 20MB
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-else class="notice-disabled">勾选「{{ n.label }}」后可上传文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">*名称规则:公告文件类型+取项目编号的后两段+项目名称</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 底部按钮 (与 sponsor 工具栏风格一致) -->
|
||||
<div class="form-actions">
|
||||
<el-button @click="confirmCancel">取消</el-button>
|
||||
<el-button type="warning" :loading="submitting" @click="submit('publish')">保存并发布公告</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit('save')">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
|
||||
import { listExecutor, listSupporters } from '@/api/system'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isEdit = !!route.params.projectId
|
||||
const projectId = route.params.projectId || null
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
const uploadingIdx = ref(-1)
|
||||
const currentUploadN = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
|
||||
function defaultNotice(key, label) { return { key, label, url: '', enabled: false, name: '' } }
|
||||
|
||||
// 支持方候选 (sponsor 角色)
|
||||
const supporterOptions = ref([])
|
||||
|
||||
const form = reactive({
|
||||
projectName: '', projectNo: '', projectForm: '',
|
||||
totalSessions: 0, totalAmount: 0, manageFee: 0,
|
||||
startTime: '', endTime: '',
|
||||
// 赞助公司 (TODO: 下拉源应改为 biz_org,orgType='sponsor', 当前是 sys_user)
|
||||
orgId: null,
|
||||
orgName: '',
|
||||
// 提交截止(天)
|
||||
submitDeadlineDays: 30,
|
||||
roleRows: Array(6).fill(0).map(() => defaultRoleRow()),
|
||||
// 公告附件(每个 notice 含 enabled/type/label/url/name)
|
||||
// enabled: 是否启用该公告类型(对应原型 checkbox 邀请函/支持函/通知/日程)
|
||||
notices: [
|
||||
{ key: 'invitation', label: '邀请函', enabled: false, url: '', name: '' },
|
||||
{ key: 'supportLetter', label: '支持函', enabled: false, url: '', name: '' },
|
||||
{ key: 'notice', label: '通知', enabled: false, url: '', name: '' },
|
||||
{ key: 'schedule', label: '日程', enabled: false, url: '', name: '' }
|
||||
]
|
||||
})
|
||||
|
||||
const rules = {
|
||||
projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
projectNo: [{ required: true, message: '请输入项目编号', trigger: 'blur' }],
|
||||
projectForm: [{ required: true, message: '请选择项目形式', trigger: 'change' }],
|
||||
totalSessions: [{ required: true, message: '请输入总场次', trigger: 'blur' }],
|
||||
totalAmount: [{ required: true, message: '请输入总金额', trigger: 'blur' }],
|
||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function onRoleSelectChange(r) {
|
||||
if (r.role !== '其他') r.customName = ''
|
||||
}
|
||||
function addRoleRow() {
|
||||
if (form.roleRows.length >= 20) { ElMessage.warning('最多 20 行角色'); return }
|
||||
form.roleRows.push(defaultRoleRow())
|
||||
}
|
||||
function removeRoleRowAt(idx) {
|
||||
if (form.roleRows.length <= 1) {
|
||||
ElMessage.warning('至少保留 1 行角色')
|
||||
return
|
||||
}
|
||||
form.roleRows.splice(idx, 1)
|
||||
}
|
||||
function syncProjectData() {
|
||||
ElMessage.info('同步数据:从外部数据源拉取最新项目信息 (后端支持时启用)')
|
||||
}
|
||||
|
||||
// 编辑模式:加载已有项目数据并反显到表单
|
||||
async function loadProject() {
|
||||
if (!isEdit || !projectId) return
|
||||
try {
|
||||
const res = await bizGet('project', projectId)
|
||||
// res 结构: {code:200, msg, data: BizProject} (单条 success 包了一层)
|
||||
const p = res?.data?.data || res?.data || res
|
||||
if (!p) return
|
||||
form.projectName = p.projectName || ''
|
||||
form.projectNo = p.projectNo || ''
|
||||
form.projectForm = p.projectForm || ''
|
||||
form.totalSessions = p.totalSessions || 0
|
||||
form.totalAmount = p.totalAmount || 0
|
||||
form.manageFee = p.manageFee || 0
|
||||
form.startTime = p.startTime || ''
|
||||
form.endTime = p.endTime || ''
|
||||
form.orgId = p.orgId || null
|
||||
form.orgName = p.orgName || ''
|
||||
form.submitDeadlineDays = p.submitDeadlineDays || 30
|
||||
// 公告文件反显: 优先 publicityFiles JSON 数组, 否则兼容旧字段
|
||||
const files = []
|
||||
if (Array.isArray(p.publicityFiles)) files.push(...p.publicityFiles)
|
||||
else if (typeof p.publicityFiles === 'string' && p.publicityFiles) {
|
||||
try { files.push(...JSON.parse(p.publicityFiles)) } catch (e) { /* ignore */ }
|
||||
}
|
||||
if (files.length) {
|
||||
// 按 type 反显到对应 notice 行
|
||||
for (const n of form.notices) {
|
||||
const m = files.find(f => f.type === n.key)
|
||||
if (m) { n.url = m.url || ''; n.name = m.name || ''; n.enabled = !!m.url }
|
||||
}
|
||||
} else {
|
||||
// 兼容老数据
|
||||
const noticeMap = {
|
||||
invitation: p.invitationUrl,
|
||||
supportLetter: p.supportLetterUrl,
|
||||
notice: p.noticeUrl,
|
||||
schedule: p.scheduleUrl,
|
||||
publish: p.publishUrl
|
||||
}
|
||||
for (const n of form.notices) {
|
||||
n.url = noticeMap[n.key] || ''
|
||||
n.name = n.url ? (n.url.split('/').pop() || '') : ''
|
||||
n.enabled = !!n.url
|
||||
}
|
||||
}
|
||||
// 加载已有执行方分配 (暂不实现, 留 hook)
|
||||
// await loadAssigns(projectId)
|
||||
} catch (e) {
|
||||
ElMessage.error('加载项目数据失败: ' + (e?.msg || e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadSupporters(query = '') {
|
||||
try {
|
||||
const params = { roleType: 'sponsor', pageNum: 1, pageSize: 50 }
|
||||
if (query) params.userName = query
|
||||
const r = await listSupporters(params)
|
||||
supporterOptions.value = r.rows || r.data || []
|
||||
} catch (e) { supporterOptions.value = [] }
|
||||
}
|
||||
|
||||
function onSupporterPick(userId) {
|
||||
const u = supporterOptions.value.find(x => x.userId === userId)
|
||||
if (u) form.orgName = u.userName || ''
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadProject()
|
||||
loadSupporters('')
|
||||
})
|
||||
function beforeUpload(file, n) {
|
||||
// 文件大小校验: 限制 20MB
|
||||
const maxSize = 20 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
ElMessage.error('文件大小不能超过 20MB')
|
||||
return false
|
||||
}
|
||||
// 文件类型校验
|
||||
const accept = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg']
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase()
|
||||
if (!accept.includes(ext)) {
|
||||
ElMessage.error('仅支持 PDF/Word/图片格式')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function httpUpload(opts, n, idx) {
|
||||
const file = opts.file
|
||||
uploadingIdx.value = idx
|
||||
currentUploadN.value = n
|
||||
const dir = 'ry8080/project/' + n.key + '/'
|
||||
try {
|
||||
const url = await uploadToOss(file, dir)
|
||||
n.url = url
|
||||
n.name = file.name
|
||||
n.enabled = true
|
||||
ElMessage.success(`${n.label} 上传成功`)
|
||||
} catch (err) {
|
||||
ElMessage.error(`上传失败: ${err.message || err}`)
|
||||
n.url = ''
|
||||
n.name = ''
|
||||
} finally {
|
||||
uploadingIdx.value = -1
|
||||
currentUploadN.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// el-upload 内置 file-list 需要从 n.url 转换成 {name, url} 对象
|
||||
function getFileList(n) {
|
||||
if (!n.url) return []
|
||||
// 从 url 截取文件名
|
||||
const url = n.url
|
||||
const name = url.split('/').pop() || `${n.label}.pdf`
|
||||
return [{ name, url }]
|
||||
}
|
||||
|
||||
function onPreview(file) {
|
||||
if (file.url) window.open(file.url, '_blank')
|
||||
}
|
||||
|
||||
function onRemove(n, file) {
|
||||
n.url = ''
|
||||
}
|
||||
function previewFile(n) {
|
||||
if (n.url) window.open(n.url, '_blank')
|
||||
}
|
||||
function downloadFile(n) {
|
||||
if (!n.url) return
|
||||
// 触发浏览器下载
|
||||
const a = document.createElement('a')
|
||||
a.href = n.url
|
||||
a.download = n.name || (n.label + '.pdf')
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
function removeNotice(idx) {
|
||||
const n = form.notices[idx]
|
||||
if (!n) return
|
||||
ElMessageBox.confirm(`确定删除"${n.label}"附件?`, '提示', { type: 'warning' })
|
||||
.then(() => { n.url = ''; ElMessage.success('已删除') })
|
||||
.catch(() => {})
|
||||
}
|
||||
function goBack() {
|
||||
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
|
||||
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
|
||||
}
|
||||
function confirmCancel() {
|
||||
ElMessageBox.confirm(`确定取消${isEdit ? '编辑' : '新建'}项目?未保存的内容将丢失`, '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
.catch(() => {})
|
||||
}
|
||||
async function submit(mode = 'save') {
|
||||
await formRef.value.validate()
|
||||
const uploaded = form.notices.filter(n => n.url)
|
||||
const payload = {
|
||||
projectName: form.projectName,
|
||||
projectNo: form.projectNo,
|
||||
projectForm: form.projectForm,
|
||||
totalSessions: form.totalSessions,
|
||||
totalAmount: form.totalAmount,
|
||||
manageFee: form.manageFee,
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
orgId: form.orgId,
|
||||
orgName: form.orgName,
|
||||
submitDeadlineDays: form.submitDeadlineDays
|
||||
}
|
||||
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl)
|
||||
payload.publicityFiles = JSON.stringify(uploaded.map(n => ({
|
||||
type: n.key,
|
||||
label: n.label,
|
||||
url: n.url,
|
||||
name: n.name || (n.url ? n.url.split('/').pop() || '' : '')
|
||||
})))
|
||||
// 兼容旧字段: 同步写一份 (保持后端字段兼容, 新逻辑以 publicityFiles 为准)
|
||||
for (const n of uploaded) {
|
||||
if (n.key === 'invitation') payload.invitationUrl = n.url
|
||||
else if (n.key === 'supportLetter') payload.supportLetterUrl = n.url
|
||||
else if (n.key === 'notice') payload.noticeUrl = n.url
|
||||
else if (n.key === 'schedule') payload.scheduleUrl = n.url
|
||||
}
|
||||
// 发布时设置 is_published='1' + publish_time (同步后端字段)
|
||||
if (mode === 'publish') {
|
||||
payload.isPublished = '1'
|
||||
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
// announcement_type 取第一个已上传的文件类型
|
||||
const firstUploaded = uploaded[0]
|
||||
if (firstUploaded) {
|
||||
payload.announcementType = firstUploaded.key
|
||||
}
|
||||
}
|
||||
// 多执行方分配已移至"项目分配"页面单独管理
|
||||
try {
|
||||
submitting.value = true
|
||||
let savedProjectId = projectId
|
||||
if (isEdit) {
|
||||
payload.projectId = projectId
|
||||
await bizUpdate('project', payload)
|
||||
} else {
|
||||
const r = await bizAdd('project', payload)
|
||||
// bizAdd 返回 { code, msg, data: { projectId: '...' } } 或 直接 data
|
||||
savedProjectId = r?.data?.projectId || r?.projectId || r?.data
|
||||
}
|
||||
const roleText = form.roleRows.filter(r => r.role).map(r => `${r.role === '其他' ? (r.customName || '其他') : r.role}=${r.amount}`).join(', ')
|
||||
if (roleText) console.info('[new-project.roleLabor]', form.projectNo, roleText)
|
||||
const successText = isEdit
|
||||
? (mode === 'publish' ? '更新并发布公告成功' : '更新成功')
|
||||
: (mode === 'publish' ? '保存并发布公告成功' : '保存成功')
|
||||
ElMessage.success(successText)
|
||||
goBack()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || (isEdit ? '更新失败' : '新建失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 sponsor/SponsorProjects.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.breadcrumb .current { color: #262626; font-weight: 500; }
|
||||
|
||||
/* 卡片分块 (与 sponsor 一致: 左边竖线标识 + 标题) */
|
||||
.new-card { margin-bottom: 14px; border: 1px solid #f0f0f0; border-radius: 4px; }
|
||||
.new-card :deep(.el-card__body) { padding: 16px 20px; }
|
||||
.new-card-title {
|
||||
font-size: 14px; font-weight: 600; color: #262626; margin-bottom: 12px;
|
||||
padding-left: 8px; border-left: 3px solid var(--brand-primary, #1890ff); line-height: 1;
|
||||
}
|
||||
|
||||
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
|
||||
.hint-text p { margin-bottom: 4px; }
|
||||
.hint-text p:last-child { margin-bottom: 0; }
|
||||
.role-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
|
||||
.role-custom { flex: 1; max-width: 180px; }
|
||||
.role-amount { width: 180px; }
|
||||
.row-actions { display: flex; gap: 8px; }
|
||||
.notice-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.notice-row { display: flex; align-items: stretch; gap: 16px; margin-bottom: 16px; }
|
||||
.notice-label {
|
||||
width: 100px;
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
color: #606266; font-size: 14px;
|
||||
}
|
||||
.notice-uploader { flex: 1; }
|
||||
.notice-uploader :deep(.el-upload) { width: 100%; }
|
||||
.notice-uploader :deep(.el-upload-dragger) {
|
||||
padding: 12px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.notice-uploader :deep(.el-upload-dragger .el-upload__text) {
|
||||
font-size: 13px; color: #606266;
|
||||
}
|
||||
.notice-uploader :deep(.el-upload-dragger .el-upload__tip) {
|
||||
font-size: 12px; color: #909399; margin-top: 2px;
|
||||
}
|
||||
.notice-uploader :deep(.el-upload__tip) { margin-top: 0; }
|
||||
.notice-uploader :deep(.el-upload-list) { margin: 0; }
|
||||
.notice-actions { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-width: 70px; }
|
||||
.notice-actions .el-button { padding: 2px 6px; font-size: 12px; }
|
||||
.notice-disabled { flex: 1; color: #c0c4cc; font-size: 13px; padding: 8px 0; }
|
||||
|
||||
/* 底部按钮 (居中, 与 sponsor 风格一致) */
|
||||
.form-actions { display: flex; justify-content: center; gap: 16px; padding: 20px 0 4px; border-top: 1px solid #f0f0f0; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="manager-support-intent">
|
||||
<div class="page-card">
|
||||
<div class="page-title">支持意向</div>
|
||||
<!-- 筛选区严格按原型 top=169-217 的 left 顺序: 意向项目名称(276) / ZH-2026-658(371) / 姓名(541) / 工作单位(580) / 工作单位(750) / 工作单位(817) / 项目编号(996) / 提示(1113) -->
|
||||
<el-form inline :model="q">
|
||||
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="如 ZH-2026-658" clearable /></el-form-item>
|
||||
<el-form-item label="意向项目名称:"><el-input v-model="q.projectName" placeholder="输入意向项目名称" clearable /></el-form-item>
|
||||
<el-form-item label="姓名:"><el-input v-model="q.name" placeholder="输入姓名" clearable /></el-form-item>
|
||||
<el-form-item label="工作单位:"><el-input v-model="q.workUnit" placeholder="输入工作单位" clearable /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button class="action-btn" @click="load">查找</el-button>
|
||||
<el-button class="action-btn secondary" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 提示语严格按原型 top=217-243 -->
|
||||
<div class="filter-tip">*项目公示中点击支持意向后,显示在此</div>
|
||||
<div class="filter-tip">*支持意向不需要注册,但是如果已经注册账号,则显示账号状态</div>
|
||||
<div class="filter-tip">*根据手机号与系统账号进行匹配</div>
|
||||
</div>
|
||||
|
||||
<div class="page-card">
|
||||
<!-- 列头严格按原型 top=296 left 顺序: 支持意向项目编号 272 / 支持意向项目名称 437 / 姓名 610 / 工作单位名称 685 / 部门 815 / 职务 925 / 手机号 1054 / 账号状态 1164 -->
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="projectNo" label="支持意向项目编号" width="140" align="center" />
|
||||
<el-table-column prop="projectName" label="支持意向项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="name" label="姓名" width="80" align="center" />
|
||||
<el-table-column prop="workUnit" label="工作单位名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="department" label="部门" width="120" align="center" />
|
||||
<el-table-column prop="position" label="职务" width="120" align="center" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" align="center" />
|
||||
<el-table-column label="账号状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.accountStatus==='存在' ? 'success' : 'warning'">{{ row.accountStatus || '不存在' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const q = ref({ projectNo: '', projectName: '', name: '', workUnit: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('supportIntent', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []; page.total = data?.total || 0
|
||||
} catch { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function reset() { q.value = { projectNo:'', projectName:'', name:'', workUnit:'' }; page.pageNum = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-support-intent { padding: 16px; }
|
||||
.page-card { background: #fff; border-radius: 6px; padding: 16px; border: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.filter-tip { color: #909399; font-size: 12px; line-height: 1.8; }
|
||||
.page-toolbar { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.page-toolbar :deep(.action-btn) {
|
||||
height: 36px; padding: 0 20px; font-size: 14px; border-radius: 4px;
|
||||
background: var(--brand-primary); color: #fff; border: 1px solid var(--brand-primary);
|
||||
display: inline-flex; align-items: center; justify-content: center; white-space: nowrap;
|
||||
transition: opacity .15s; margin-left: 0;
|
||||
}
|
||||
.page-toolbar :deep(.action-btn:hover) { opacity: .85; }
|
||||
.page-toolbar :deep(.action-btn.secondary) {
|
||||
background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);
|
||||
}
|
||||
.page-toolbar :deep(.action-btn.secondary:hover) { background: rgba(30,58,138,.06); }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="manager-workbench">
|
||||
|
||||
<!-- 我的项目策划方案 标题 (原 top=142) -->
|
||||
<h2 class="page-title">我的项目策划方案</h2>
|
||||
|
||||
<!-- 5 个 KPI 卡 (原 top=165 已结算/已结题 + top=458 已执行/未执行) -->
|
||||
<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">
|
||||
<div class="stat-label">已结算数量</div>
|
||||
<div class="stat-value">{{ stats.settledProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/projects">
|
||||
<div class="stat-label">已结题数量</div>
|
||||
<div class="stat-value">{{ stats.completedProjects }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/meetings">
|
||||
<div class="stat-label">已执行会议</div>
|
||||
<div class="stat-value">{{ stats.executedMeetings }}</div>
|
||||
</router-link>
|
||||
<router-link class="stat-card" to="/manager/meetings">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<p class="hint-text">*点击数字,跳转到相应的列表页</p>
|
||||
|
||||
<!-- 消息通知 (新增) -->
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
|
||||
const stats = ref({
|
||||
totalProjects: 0, settledProjects: 0, completedProjects: 0,
|
||||
executedMeetings: 0, pendingMeetings: 0
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
// 项目总数量
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || ps.data?.total || 0
|
||||
// 已结题项目
|
||||
const cp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' })
|
||||
stats.value.completedProjects = cp.total || cp.data?.total || 0
|
||||
// 会议总数量
|
||||
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: '已执行' })
|
||||
stats.value.executedMeetings = em.total || em.data?.total || 0
|
||||
stats.value.pendingMeetings = meetingTotal - stats.value.executedMeetings
|
||||
// 已结算项目
|
||||
const sp = await bizList('project', { pageNum: 1, pageSize: 1, isSettled: '1' })
|
||||
stats.value.settledProjects = sp.total || sp.data?.total || 0
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
.manager-workbench { padding: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 12px; }
|
||||
.stat-card {
|
||||
background: #fff; border-radius: 4px; padding: 24px;
|
||||
border: 1px solid #ebeef5; cursor: pointer; text-decoration: none; color: inherit;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
transition: opacity .15s, border-color .15s;
|
||||
min-height: 120px;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--brand-primary); opacity: .9; }
|
||||
.stat-label { font-size: 14px; color: #606266; margin-bottom: 16px; }
|
||||
.stat-value { font-size: 32px; font-weight: 600; color: var(--brand-primary); line-height: 1.2; }
|
||||
.hint-text { font-size: 12px; color: #ff4d4f; margin: 12px 0 20px; text-align: center; }
|
||||
.section { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #ebeef5; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); display: flex; justify-content: space-between; }
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.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>
|
||||
@@ -0,0 +1,859 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<header class="top-nav" :class="topNavClass">
|
||||
<a class="logo" title="返回首页" @click.prevent="onHome">
|
||||
<div class="logo-icon">医</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">北京整合医学学会</span>
|
||||
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
|
||||
</div>
|
||||
</a>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item"><span class="nav-link active">年度项目规划</span></li>
|
||||
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
|
||||
</ul>
|
||||
<div class="top-tools">
|
||||
<template v-if="!loggedIn">
|
||||
<span class="login-btn" @click="goLogin">登录</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-dropdown trigger="click" @command="onUserCmd">
|
||||
<a class="user-link" @click.prevent>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<span>{{ userName }}</span>
|
||||
</a>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="account">我的主页</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<span class="hero-tag">2025-2030</span>
|
||||
<h1 class="hero-title">年度项目规划</h1>
|
||||
<p class="hero-subtitle">七 大 专 项</p>
|
||||
<p class="hero-desc">
|
||||
围绕健康中国战略,聚焦医学整合创新,系统推进七大专项计划,<br>
|
||||
全面构建覆盖诊疗、科研、人才、管理、公益、政学协作与组织建设的协同发展体系。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-cta">
|
||||
<span class="cta-btn" @click="onSubmit">项目提案</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section">
|
||||
<div class="section-title">年度项目规划概述</div>
|
||||
<p class="overview-text">
|
||||
依托国家战略层面《"健康中国2030"规划纲要》和健康中国行动的统领力量,切合《全民健康素养提升三年行动方案(2024-2027年)》的契机,践行学会的公益性服务的责任和使命,全方位、全领域、体系性、系统性、持续性地开展健康科普活动,通过征集全国范围300个病种的健康科普短视频、科普讲座、科普文章,进行整个医疗系统的健康科普总动员,以科普内容征集促进科普活动走近、走深、走实。
|
||||
</p>
|
||||
<div class="plan-image" v-html="planSvg"></div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section">
|
||||
<div class="section-title">参与说明</div>
|
||||
<div class="notice-card">
|
||||
<ul class="notice-list">
|
||||
<li><strong>登录要求</strong> · 点击计划详情及项目提案,需先登录系统;</li>
|
||||
<li><strong>支持方注册</strong> · 注册后默认为监察员角色,合规人员可配置支持方的管理员账号;</li>
|
||||
<li><strong>支持方管理</strong> · 注册时选择的企业为主单位,默认进入该企业的人员管理页,由企业管理员进行管理,管理员账号可新建监督员账号;</li>
|
||||
<li><strong>执行方注册</strong> · 注册跳转至供应商系统,走入库流程,注册成功后该账号默认为管理员账号,管理员账号可新建执行人员账号。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="specialty-section">
|
||||
<div class="specialty-inner">
|
||||
<div class="section-title">七大专项计划</div>
|
||||
<div class="specialty-list">
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">01</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《规范化诊疗及医疗质量提升专项计划(2026年)》</h3>
|
||||
<p class="specialty-desc">聚焦临床诊疗规范制定与质量提升,推动各级医疗机构诊疗标准化、同质化。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">02</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《科研创新专项行动计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">支持医学科研创新,推动整合医学研究成果转化与产业化应用,构建协同创新生态。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">03</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《医疗卫生人才培育专项行动计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">建立多层次医学人才培养体系,重点加强基层医疗人才与跨学科复合型人才建设。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">04</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《医院管理及高质量发展促进专项计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">推动医院管理现代化,提升运营效率与服务水平,助力公立医院高质量发展。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">05</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《社会公益与可及性提升专项计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">聚焦医疗服务的可及性与公平性,开展公益救助、基层帮扶与健康科普活动。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">06</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《政学协作综合项目专项计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">搭建政府与学术机构协同平台,推动政策研究与决策支持,促进政产学研用深度融合。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="specialty-card">
|
||||
<div class="specialty-no">07</div>
|
||||
<div class="specialty-body">
|
||||
<h3 class="specialty-title">《组织建设与内部治理专项计划(2026-2030年)》</h3>
|
||||
<p class="specialty-desc">完善学会组织架构,健全内部治理机制,提升规范化、专业化、信息化管理水平。</p>
|
||||
<div class="specialty-action">
|
||||
<span class="specialty-link">查看详情</span>
|
||||
<span class="specialty-link">项目提案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-main">
|
||||
<div class="footer-brand">
|
||||
<div class="brand-row">
|
||||
<div class="footer-logo-icon">医</div>
|
||||
<div>
|
||||
<div class="footer-brand-name">北京整合医学学会</div>
|
||||
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国。</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>快速导航</h4>
|
||||
<a href="首页.html">首页</a>
|
||||
<a href="index2.html">年度项目规划</a>
|
||||
<a href="项目公示.html">项目公示</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>学会项目</h4>
|
||||
<a>百姓巡常行</a>
|
||||
<a>专病联盟暨全国专家智库</a>
|
||||
<a>乡村幸福安康</a>
|
||||
<a>临床科研资助计划</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>联系方式</h4>
|
||||
<p>电话: 010-82089470</p>
|
||||
<p>邮箱: contactus@bahim.org.cn</p>
|
||||
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2室</p>
|
||||
<p>邮编: 100083</p>
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<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>
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 北京整合医学学会 BAHIM</span>
|
||||
<span>京ICP备2020035479号-1 京公网安备11010802034820</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isScrolled = ref(false)
|
||||
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
|
||||
const loggedIn = computed(() => !!userStore.token)
|
||||
const userName = computed(() => userStore.user?.userName || '用户')
|
||||
|
||||
const planSvg = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="2025-2030年项目规划">
|
||||
<defs>
|
||||
<linearGradient id="arcGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#c7d2fe" stop-opacity="0.3"/>
|
||||
<stop offset="50%" stop-color="#a5b4fc" stop-opacity="0.7"/>
|
||||
<stop offset="100%" stop-color="#c7d2fe" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hexGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--brand-primary-deep)"/>
|
||||
<stop offset="100%" stop-color="var(--brand-primary)"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="centerGrad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="var(--brand-primary)"/>
|
||||
<stop offset="100%" stop-color="var(--brand-primary-deep)"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bgGlow" cx="600" cy="470" r="700" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e0e7ff" stop-opacity="0.85"/>
|
||||
<stop offset="55%" stop-color="#eef2ff" stop-opacity="0.4"/>
|
||||
<stop offset="100%" stop-color="#eef2ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="hexShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="600" cy="470" r="700" fill="url(#bgGlow)"/>
|
||||
<path d="M 213 470 A 440.6 440.6 0 0 1 987 470" stroke="#c7d2fe" stroke-width="22" fill="none" opacity="0.35"/>
|
||||
<path d="M 213 470 A 440.6 440.6 0 0 1 987 470" stroke="url(#arcGrad)" stroke-width="3" fill="none"/>
|
||||
<path d="M 253 470 A 400 400 0 0 1 947 470" stroke="#c7d2fe" stroke-width="1" fill="none" stroke-dasharray="3,4"/>
|
||||
<g>
|
||||
<circle cx="213" cy="470" r="6" fill="#fff" stroke="var(--brand-primary)" stroke-width="2"/>
|
||||
<circle cx="987" cy="470" r="6" fill="#fff" stroke="var(--brand-primary)" stroke-width="2"/>
|
||||
</g>
|
||||
<g filter="url(#hexShadow)" font-family="Arial, sans-serif" font-weight="bold" fill="#fff" text-anchor="middle">
|
||||
<g transform="translate(213 470)"><polygon points="-34,0 -17,-29 17,-29 34,0 17,29 -17,29" fill="url(#hexGrad)"/><text y="7" font-size="22">01</text></g>
|
||||
<g transform="translate(294 363)"><polygon points="-30,0 -15,-26 15,-26 30,0 15,26 -15,26" fill="url(#hexGrad)"/><text y="6" font-size="19">02</text></g>
|
||||
<g transform="translate(403 286)"><polygon points="-28,0 -14,-24 14,-24 28,0 14,24 -14,24" fill="var(--brand-primary)"/><text y="6" font-size="18">03</text></g>
|
||||
<g transform="translate(532 245)"><polygon points="-28,0 -14,-24 14,-24 28,0 14,24 -14,24" fill="var(--brand-primary)"/><text y="6" font-size="18">04</text></g>
|
||||
<g transform="translate(666 245)"><polygon points="-28,0 -14,-24 14,-24 28,0 14,24 -14,24" fill="var(--brand-primary)"/><text y="6" font-size="18">05</text></g>
|
||||
<g transform="translate(794 285)"><polygon points="-28,0 -14,-24 14,-24 28,0 14,24 -14,24" fill="var(--brand-primary)"/><text y="6" font-size="18">06</text></g>
|
||||
<g transform="translate(904 362)"><polygon points="-30,0 -15,-26 15,-26 30,0 15,26 -15,26" fill="url(#hexGrad)"/><text y="6" font-size="19">07</text></g>
|
||||
<g transform="translate(986 468)"><polygon points="-34,0 -17,-29 17,-29 34,0 17,29 -17,29" fill="url(#hexGrad)"/><text y="7" font-size="22">08</text></g>
|
||||
</g>
|
||||
<g transform="translate(600 360)">
|
||||
<circle cx="0" cy="0" r="40" fill="#fff" stroke="#e0e7ff" stroke-width="2"/>
|
||||
<circle cx="0" cy="0" r="30" fill="url(#centerGrad)"/>
|
||||
<rect x="-16" y="-5" width="32" height="10" fill="#fff"/>
|
||||
<rect x="-5" y="-16" width="10" height="32" fill="#fff"/>
|
||||
</g>
|
||||
<text x="600" y="430" fill="var(--brand-primary)" text-anchor="middle" font-family="Microsoft YaHei, sans-serif" font-size="32" font-weight="bold" letter-spacing="6">2025-2030年</text>
|
||||
<line x1="540" y1="447" x2="600" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
|
||||
<line x1="600" y1="447" x2="660" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
|
||||
<g fill="#374151" font-family="Microsoft YaHei, sans-serif" font-size="14" font-weight="500" letter-spacing="0.5">
|
||||
<text x="170" y="411" text-anchor="end"><tspan x="170" dy="0">触达100万+家</tspan><tspan x="170" dy="20">医疗卫生服务机构</tspan></text>
|
||||
<text x="252" y="307" text-anchor="end"><tspan x="252" dy="0">300万+专业医护</tspan><tspan x="252" dy="20">人员广泛参与</tspan></text>
|
||||
<text x="362" y="232" text-anchor="end"><tspan x="362" dy="0">培训100万+名</tspan><tspan x="362" dy="20">健康科普创作志愿者</tspan></text>
|
||||
<text x="532" y="195" text-anchor="middle"><tspan x="532" dy="0">赋能10万+名</tspan><tspan x="532" dy="20">健康科普传播志愿者</tspan></text>
|
||||
<text x="666" y="195" text-anchor="middle"><tspan x="666" dy="0">知识普及10亿+</tspan><tspan x="666" dy="20">人次人民群众</tspan></text>
|
||||
<text x="835" y="231" text-anchor="start"><tspan x="835" dy="0">覆盖4万+个</tspan><tspan x="835" dy="20">乡镇、街道</tspan></text>
|
||||
<text x="946" y="306" text-anchor="start"><tspan x="946" dy="0">1000万个流量平台</tspan><tspan x="946" dy="20">(用户数10万+)</tspan></text>
|
||||
<text x="1029" y="409" text-anchor="start"><tspan x="1029" dy="0">建设1万个</tspan><tspan x="1029" dy="20">健康小屋</tspan></text>
|
||||
</g>
|
||||
</svg>`
|
||||
|
||||
function handleScroll() {
|
||||
isScrolled.value = window.scrollY > 10
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
|
||||
|
||||
function onHome() { isScrolled.value = false }
|
||||
function goLogin() { router.push('/login') }
|
||||
function goUser() { /* placeholder */ }
|
||||
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
|
||||
async function onUserCmd(cmd) {
|
||||
if (cmd === 'logout') return goLogout()
|
||||
if (cmd === 'account') {
|
||||
const r = (userStore.user?.role || '')
|
||||
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
|
||||
router.push(map[r] || '/leader/home')
|
||||
}
|
||||
}
|
||||
function goPublicity() { router.push('/publicity') }
|
||||
|
||||
function onSubmit() {
|
||||
if (!loggedIn.value) {
|
||||
ElMessage.warning('请先登录系统')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
router.push('/publicity')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
min-width: 1354px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ========== 顶部导航 ========== */
|
||||
.top-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: 72px;
|
||||
padding: 0 60px;
|
||||
background: var(--brand-primary);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.top-nav.is-scrolled {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: color 0.25s;
|
||||
position: relative;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
transform: translateX(-50%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-link,
|
||||
.nav-link.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-link::after,
|
||||
.nav-link.active::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.top-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
padding: 7px 20px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ========== 主体容器 ========== */
|
||||
.container {
|
||||
max-width: 1354px;
|
||||
margin: 0 auto;
|
||||
padding: 0 60px;
|
||||
}
|
||||
|
||||
/* ========== 区块标题 ========== */
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 22px;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
/* ========== Hero ========== */
|
||||
.hero {
|
||||
margin-top: 30px;
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
height: 280px;
|
||||
padding: 0 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 38px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 4px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-cta .cta-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 40px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.hero-cta .cta-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ========== 总述 ========== */
|
||||
.overview-section {
|
||||
padding: 50px 0 30px;
|
||||
}
|
||||
|
||||
.overview-text {
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
line-height: 1.9;
|
||||
background: #fff;
|
||||
padding: 24px 28px;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.plan-image {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.plan-image svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ========== 七大专项 ========== */
|
||||
.specialty-section {
|
||||
background: #f5f6f8;
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
padding: 50px 0 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.specialty-inner {
|
||||
max-width: 1354px;
|
||||
margin: 0 auto;
|
||||
padding: 0 60px;
|
||||
}
|
||||
|
||||
.specialty-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.specialty-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.specialty-card:hover {
|
||||
border-color: var(--brand-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.specialty-no {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.specialty-body {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.specialty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.specialty-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.7;
|
||||
flex: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.specialty-action {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.specialty-link {
|
||||
font-size: 13px;
|
||||
color: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.specialty-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.specialty-link.disabled {
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ========== 提示 ========== */
|
||||
.notice-section {
|
||||
padding: 30px 0 50px;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notice-list li {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.7;
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.notice-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 18px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.notice-list li strong {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== 底部 ========== */
|
||||
.footer {
|
||||
background: var(--brand-primary-darker);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
padding: 48px 0 0;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
|
||||
gap: 48px;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.footer-brand .brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.footer-logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.footer-brand-en {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.footer-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.footer-col h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer-col h4::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
.footer-col a,
|
||||
.footer-col p {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 2.1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-col a { cursor: pointer; }
|
||||
|
||||
.footer-col a:hover { color: #fff; }
|
||||
|
||||
.footer-qr {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.qr-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 18px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="portal-invitation">
|
||||
<h1 class="page-title">邀请函</h1>
|
||||
<p class="page-sub">查看项目邀请函(含模板、QR、分享链接)</p>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="项目编号:">
|
||||
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称:">
|
||||
<el-input v-model="q.title" placeholder="输入标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="page-card">
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="title" label="邀请函标题" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="publishTime" label="发布时间" width="180">
|
||||
<template #default="{ row }">{{ fmt(row.publishTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" :disabled="!row.templateUrl" @click="openPreview(row, 'template')">模板</el-button>
|
||||
<el-button link type="primary" :disabled="!row.qrCodeUrl" @click="openPreview(row, 'qr')">QR</el-button>
|
||||
<el-button link type="primary" :disabled="!row.shareUrl" @click="openPreview(row, 'share')">分享</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load"
|
||||
style="margin-top: 12px; text-align: right;" />
|
||||
</div>
|
||||
|
||||
<!-- 详情 dialog -->
|
||||
<el-dialog v-model="detailOpen" title="邀请函详情" width="640" top="6vh">
|
||||
<el-descriptions v-if="current" :column="1" border>
|
||||
<el-descriptions-item label="标题">{{ current.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ current.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ fmt(current.publishTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ current.remark || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模板链接">
|
||||
<el-link v-if="current.templateUrl" type="primary" @click="openPreview(current, 'template')">点击预览</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="QR 码">
|
||||
<el-link v-if="current.qrCodeUrl" type="primary" @click="openPreview(current, 'qr')">点击预览</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分享链接">
|
||||
<el-link v-if="current.shareUrl" type="primary" @click="openPreview(current, 'share')">点击预览</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览组件 -->
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizList } from '@/api/public'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
|
||||
const q = ref({ projectNo: '', title: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('invitation', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function onSearch() { page.pageNum = 1; load() }
|
||||
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' }
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const current = ref(null)
|
||||
function openDetail(row) { current.value = row; detailOpen.value = true }
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('预览')
|
||||
function openPreview(row, kind) {
|
||||
if (kind === 'template') { previewUrl.value = row.templateUrl; previewTitle.value = '邀请函模板' }
|
||||
else if (kind === 'qr') { previewUrl.value = row.qrCodeUrl; previewTitle.value = 'QR 码' }
|
||||
else if (kind === 'share') { previewUrl.value = row.shareUrl; previewTitle.value = '分享链接' }
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-invitation { padding: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
||||
.page-sub { color: #909399; font-size: 13px; margin: 0 0 16px; }
|
||||
.filter-bar { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.page-card { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
</style>
|
||||
@@ -0,0 +1,713 @@
|
||||
<template>
|
||||
<div class="pub-page">
|
||||
<header class="top-nav" :class="topNavClass">
|
||||
<a class="logo" title="返回首页" @click.prevent="goHome">
|
||||
<div class="logo-icon">医</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-title">北京整合医学学会</span>
|
||||
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
|
||||
</div>
|
||||
</a>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
|
||||
<li class="nav-item"><span class="nav-link active">项目公示</span></li>
|
||||
</ul>
|
||||
<div class="top-tools">
|
||||
<template v-if="!loggedIn">
|
||||
<span class="login-btn" @click="goLogin">登录</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-dropdown trigger="click" @command="onUserCmd">
|
||||
<a class="user-link" @click.prevent>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<span>{{ userName }}</span>
|
||||
</a>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="account">我的主页</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="section-title">项目公示</h1>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input v-model="currentKeyword" @keydown.enter="applyFilter" type="text" class="filter-input" placeholder="">
|
||||
<button class="filter-btn" @click="applyFilter">查询</button>
|
||||
<button class="filter-btn" style="background: #fff; color: var(--brand-primary); border: 1px solid var(--brand-primary);" @click="resetFilter">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="notice-list">
|
||||
<template v-if="pageItems.length === 0">
|
||||
<div style="padding: 40px 0; text-align: center; color: #9ca3af; font-size: 13px;">暂无数据</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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.projectName" style="color:#909399;margin-right:8px;font-size:13px">【{{ n.projectName }}】</span>
|
||||
{{ n.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<template v-if="total > 0">
|
||||
<button class="page-btn" :disabled="currentPage === 1" @click="prevPage">上一页</button>
|
||||
<template v-for="(b, idx) in pageButtons" :key="idx">
|
||||
<span v-if="b === '...'" style="color: #9ca3af; padding: 0 4px;">···</span>
|
||||
<button v-else class="page-btn" :class="b === currentPage ? 'active' : ''" @click="goPage(b)">{{ b }}</button>
|
||||
</template>
|
||||
<button class="page-btn" :disabled="currentPage === totalPages" @click="nextPage">下一页</button>
|
||||
<span class="page-info">共 {{ total }} 条 / {{ totalPages }} 页</span>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-main">
|
||||
<div class="footer-brand">
|
||||
<div class="brand-row">
|
||||
<div class="footer-logo-icon">医</div>
|
||||
<div>
|
||||
<div class="footer-brand-name">北京整合医学学会</div>
|
||||
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国。</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>快速导航</h4>
|
||||
<a @click.prevent="goHome">首页</a>
|
||||
<a @click.prevent="goHome">年度项目规划</a>
|
||||
<span style="display: block; font-size: 13px; color: rgba(255,255,255,0.6);">项目公示</span>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>学会项目</h4>
|
||||
<a>百姓巡常行</a>
|
||||
<a>专病联盟暨全国专家智库</a>
|
||||
<a>乡村幸福安康</a>
|
||||
<a>临床科研资助计划</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>联系方式</h4>
|
||||
<p>电话: 010-82089470</p>
|
||||
<p>邮箱: contactus@bahim.org.cn</p>
|
||||
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2室</p>
|
||||
<p>邮编: 100083</p>
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<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>
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© 北京整合医学学会 BAHIM</span>
|
||||
<span>京ICP备2020035479号-1 京公网安备11010802034820</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
import request from '@/utils/request'
|
||||
import { bizList } from '@/api/public'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const isScrolled = ref(false)
|
||||
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
|
||||
const loggedIn = computed(() => !!userStore.token)
|
||||
const userName = computed(() => userStore.user?.userName || '用户')
|
||||
|
||||
const ALL_NOTICES = []
|
||||
|
||||
const currentKeyword = ref('')
|
||||
const currentPage = ref(1)
|
||||
const allRows = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 项目公示 = biz_project 表中 is_published='1' 的项目
|
||||
const res = await request({ url: '/business/public/announcements', method: 'get' })
|
||||
const list = res.data?.data || res.data || []
|
||||
allRows.value = list.map(r => {
|
||||
// ann_type 来自 announcement_type 字段; URL 来自邀请函/支持函/通知/日程/公示 URL
|
||||
const type = r.announcementType || '公示'
|
||||
// 根据 announcementType 选择对应 URL
|
||||
const urlMap = {
|
||||
invitation: r.invitationUrl,
|
||||
support: r.supportLetterUrl,
|
||||
notice: r.noticeUrl,
|
||||
schedule: r.scheduleUrl,
|
||||
publish: r.publishUrl
|
||||
}
|
||||
return {
|
||||
annId: r.projectId,
|
||||
date: r.publishTime,
|
||||
type,
|
||||
title: r.projectName,
|
||||
projectName: r.projectName,
|
||||
fileUrl: urlMap[type] || r.invitationUrl || r.supportLetterUrl || r.publishUrl || ''
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[publicity] load failed', e)
|
||||
allRows.value = []
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
load()
|
||||
|
||||
function detectType(title) {
|
||||
if (title.includes('【邀请函】')) return '邀请函'
|
||||
if (title.includes('【支持函】')) return '支持函'
|
||||
if (title.includes('【通知】')) return '通知'
|
||||
if (title.includes('【日程】')) return '日程'
|
||||
if (title.includes('【会议通知】')) return '通知'
|
||||
return '其他'
|
||||
}
|
||||
|
||||
const filteredList = computed(() => {
|
||||
let list = allRows.value.map(n => ({ ...n, type: n.type || detectType(n.title) }))
|
||||
if (currentKeyword.value) {
|
||||
const kw = currentKeyword.value.toLowerCase()
|
||||
list = list.filter(n => n.title.toLowerCase().includes(kw))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const total = computed(() => filteredList.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||
const pageItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * PAGE_SIZE
|
||||
return filteredList.value.slice(start, start + PAGE_SIZE)
|
||||
})
|
||||
|
||||
const pageButtons = computed(() => {
|
||||
const buttons = []
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
if (i === 1 || i === totalPages.value || (i >= currentPage.value - 1 && i <= currentPage.value + 1)) {
|
||||
buttons.push(i)
|
||||
} else if (i === currentPage.value - 2 || i === currentPage.value + 2) {
|
||||
buttons.push('...')
|
||||
}
|
||||
}
|
||||
const result = []
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i] === '...' && result[result.length - 1] === '...') continue
|
||||
result.push(buttons[i])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function applyFilter() { currentPage.value = 1 }
|
||||
function resetFilter() { currentKeyword.value = ''; currentPage.value = 1 }
|
||||
function prevPage() { if (currentPage.value > 1) { currentPage.value--; window.scrollTo({ top: 0, behavior: 'smooth' }) } }
|
||||
function nextPage() { if (currentPage.value < totalPages.value) { currentPage.value++; window.scrollTo({ top: 0, behavior: 'smooth' }) } }
|
||||
function goPage(p) { currentPage.value = p; window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||
|
||||
function goHome() { router.push('/') }
|
||||
function goLogin() { router.push('/login') }
|
||||
function goUser() { /* placeholder */ }
|
||||
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
|
||||
async function onUserCmd(cmd) {
|
||||
if (cmd === 'logout') return goLogout()
|
||||
if (cmd === 'account') {
|
||||
const r = (userStore.user?.role || '')
|
||||
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
|
||||
router.push(map[r] || '/leader/home')
|
||||
}
|
||||
}
|
||||
function goDetail(n) {
|
||||
if (!n || !n.annId) return
|
||||
router.push({ name: 'publicity-detail', params: { projectId: n.annId } })
|
||||
}
|
||||
function fmtDate(d) {
|
||||
if (!d) return ''
|
||||
// 只显示年月日, 不显示时分秒
|
||||
const date = new Date(d)
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
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))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
min-width: 1354px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ========== 顶部导航 ========== */
|
||||
.top-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: 72px;
|
||||
padding: 0 60px;
|
||||
background: var(--brand-primary);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.top-nav.is-scrolled {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 2px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: color 0.25s;
|
||||
position: relative;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
transform: translateX(-50%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-link,
|
||||
.nav-link.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-link::after,
|
||||
.nav-link.active::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
padding: 7px 20px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ========== 主体容器 ========== */
|
||||
.container {
|
||||
max-width: 1354px;
|
||||
margin: 0 auto;
|
||||
padding: 0 60px;
|
||||
}
|
||||
|
||||
/* ========== 页面标题 ========== */
|
||||
.page-header {
|
||||
padding: 40px 0 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 22px;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
/* ========== 筛选条 ========== */
|
||||
.filter-bar {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
width: 220px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
height: 30px;
|
||||
padding: 0 18px;
|
||||
background: var(--brand-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
letter-spacing: 1px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--brand-primary-deep);
|
||||
}
|
||||
|
||||
/* ========== 公示列表 ========== */
|
||||
.notice-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.notice-item:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.notice-item:hover .notice-title {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.notice-date {
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
line-height: 1.7;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.notice-item-no {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-family: ui-monospace, "Courier New", monospace;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* ========== 分页 ========== */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 30px 0 60px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-btn:hover {
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.page-btn:disabled:hover {
|
||||
border-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* ========== 底部 ========== */
|
||||
.footer {
|
||||
background: var(--brand-primary-darker);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
padding: 48px 0 0;
|
||||
}
|
||||
|
||||
.footer-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
|
||||
gap: 48px;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.footer-brand .brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.footer-logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
color: var(--brand-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.footer-brand-en {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.footer-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.footer-col h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer-col h4::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
.footer-col a,
|
||||
.footer-col p {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 2.1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-col a { cursor: pointer; }
|
||||
|
||||
.footer-col a:hover { color: #fff; }
|
||||
|
||||
.footer-qr {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.qr-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 18px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="portal-support-letter">
|
||||
<h1 class="page-title">支持函</h1>
|
||||
<p class="page-sub">查看项目支持函(含 PDF 文件下载/预览)</p>
|
||||
|
||||
<el-form inline :model="q" class="filter-bar">
|
||||
<el-form-item label="项目编号:">
|
||||
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="标题:">
|
||||
<el-input v-model="q.title" placeholder="输入标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="page-card">
|
||||
<el-table :data="rows" v-loading="loading" stripe border>
|
||||
<el-table-column prop="title" label="支持函标题" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="projectNo" label="项目编号" width="160" />
|
||||
<el-table-column prop="publishTime" label="发布时间" width="180">
|
||||
<template #default="{ row }">{{ fmt(row.publishTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="supportCount" label="支持单位数" width="120" align="center" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" :disabled="!row.fileUrl" @click="openPreview(row)">预览</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load" @size-change="load"
|
||||
style="margin-top: 12px; text-align: right;" />
|
||||
</div>
|
||||
|
||||
<!-- 详情 dialog -->
|
||||
<el-dialog v-model="detailOpen" title="支持函详情" width="640" top="6vh">
|
||||
<el-descriptions v-if="current" :column="1" border>
|
||||
<el-descriptions-item label="标题">{{ current.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ current.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ fmt(current.publishTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支持单位数">{{ current.supportCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="文件">
|
||||
<el-link v-if="current.fileUrl" type="primary" @click="openPreview(current)">点击预览</el-link>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 预览组件 -->
|
||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { bizList } from '@/api/public'
|
||||
import Preview from '@/components/Preview.vue'
|
||||
|
||||
const q = ref({ projectNo: '', title: '' })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bizList('supportLetter', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = data?.rows || []
|
||||
page.total = data?.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function onSearch() { page.pageNum = 1; load() }
|
||||
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' }
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const current = ref(null)
|
||||
function openDetail(row) { current.value = row; detailOpen.value = true }
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('支持函预览')
|
||||
function openPreview(row) {
|
||||
previewUrl.value = row.fileUrl
|
||||
previewTitle.value = `支持函预览 - ${row.title || ''}`
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-support-letter { padding: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
||||
.page-sub { color: #909399; font-size: 13px; margin: 0 0 16px; }
|
||||
.filter-bar { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.page-card { background: #fff; padding: 16px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
</style>
|
||||
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<!--
|
||||
1:1 抄 doctor/Account.vue 容器 + 结构
|
||||
容器: <div class="page-card"> + 面包屑 + page-title
|
||||
Tab: 个人信息 + 修改密码
|
||||
个人信息 tab 包含: 头像块 + 基本资料 (form-grid 2 列)
|
||||
修改密码 tab: 原密码 / 新密码 / 确认密码
|
||||
修改手机号: 走独立 dialog + 短信验证
|
||||
-->
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
<span @click="goHome" class="link">首页</span>
|
||||
<span class="sep">/</span>
|
||||
<span>账号信息</span>
|
||||
</div>
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<h1 class="page-title">账号信息</h1>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<el-tabs v-model="tab" class="account-tabs">
|
||||
<!-- 个人信息 -->
|
||||
<el-tab-pane label="个人信息" name="profile">
|
||||
<div class="card">
|
||||
<!-- 头像 + 用户信息 -->
|
||||
<div class="avatar-block">
|
||||
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
|
||||
<template v-else>{{ avatarChar }}</template>
|
||||
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
|
||||
</div>
|
||||
<div class="avatar-meta">
|
||||
<div class="name">{{ store.user?.nickName || store.user?.userName || '支持方账号' }}</div>
|
||||
<div class="role">{{ roleLabel }}</div>
|
||||
<div class="phone-row">
|
||||
<span class="phone-label">联系电话:</span>
|
||||
<span class="phone-value">{{ form.phone || '未设置' }}</span>
|
||||
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
|
||||
<el-icon><EditPen /></el-icon> 修改
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本资料 (2 列) -->
|
||||
<div class="section-title">基本资料</div>
|
||||
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
|
||||
<el-form-item label="账号">
|
||||
<el-input :model-value="store.user?.userName || '-'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" :required="true">
|
||||
<el-input v-model="form.name" placeholder="请填写姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input :model-value="store.user?.email || '-'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属公司">
|
||||
<el-input
|
||||
v-if="myPersonId"
|
||||
v-model="form.orgName"
|
||||
placeholder="请填写所属公司 (TODO: 改为 biz_org 下拉)"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
model-value="主账号无需设置 (请联系子账号在 [人员管理] 中维护)"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="form-error">{{ saveError }}</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button type="default" @click="onCancel">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<el-tab-pane label="修改密码" name="password">
|
||||
<div class="card">
|
||||
<el-form :model="pwd" label-position="right" label-width="120px" class="password-form">
|
||||
<el-form-item label="原密码" :required="true">
|
||||
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" :required="true">
|
||||
<el-input v-model="pwd.new" type="password" show-password placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" :required="true">
|
||||
<el-input v-model="pwd.confirm" type="password" show-password placeholder="请再次输入新密码" />
|
||||
</el-form-item>
|
||||
<div class="form-error">{{ pwdError }}</div>
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" @click="onChangePwd">保存</el-button>
|
||||
<el-button type="default" @click="onCancelPwd">取消</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 隐藏的 avatar 上传 input -->
|
||||
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
|
||||
|
||||
<!-- 修改手机号 dialog -->
|
||||
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="phoneForm" label-width="100px">
|
||||
<el-form-item label="新手机号" :required="true">
|
||||
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信验证码" :required="true">
|
||||
<div class="sms-row">
|
||||
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
|
||||
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
|
||||
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="form-error">{{ phoneError }}</div>
|
||||
<template #footer>
|
||||
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
|
||||
</template>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import request from '@/utils/request'
|
||||
import { uploadToOss } from '@/utils/oss'
|
||||
|
||||
const store = useUserStore()
|
||||
const tab = ref('profile')
|
||||
|
||||
// 角色 label
|
||||
const roleLabel = computed(() => {
|
||||
const r = store.user?.role
|
||||
if (r === 'sponsor') return '支持方'
|
||||
if (r === 'leader') return '项目主管'
|
||||
if (r === 'expert') return '专家'
|
||||
if (r === 'executor') return '执行方'
|
||||
if (r === 'admin') return '系统管理员'
|
||||
return r || '-'
|
||||
})
|
||||
|
||||
// 短信验证码状态 (修改手机号 dialog 用)
|
||||
const phoneDialogVisible = ref(false)
|
||||
const phoneSmsCountdown = ref(0)
|
||||
let phoneSmsTimer = null
|
||||
const phoneSmsUuid = ref('')
|
||||
const phoneForm = reactive({ phone: '', smsCode: '' })
|
||||
const phoneError = ref('')
|
||||
const phoneSaving = ref(false)
|
||||
|
||||
// 个人信息 form (sponsor 字段比 doctor 少, 没有科室/职称/银行等)
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
orgName: '',
|
||||
licenseCertUrl: '',
|
||||
idCardFrontUrl: '',
|
||||
idCardBackUrl: ''
|
||||
})
|
||||
|
||||
const pwd = reactive({ old: '', new: '', confirm: '' })
|
||||
const pwdError = ref('')
|
||||
const saveError = ref('')
|
||||
const saving = ref(false)
|
||||
// 当前登录人绑定的 biz_person.personId (主账号为 null, 子账号有值)
|
||||
const myPersonId = ref(null)
|
||||
|
||||
const avatarUrl = ref(store.user?.avatar || '')
|
||||
const avatarChar = computed(() => (form.name || store.user?.userName || '支').slice(0, 1))
|
||||
const avatarUploading = ref(false)
|
||||
const avatarInput = ref(null)
|
||||
|
||||
let snapshot = ref(null)
|
||||
|
||||
function onAvatarClick() {
|
||||
if (avatarUploading.value) return
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAvatarFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return ElMessage.warning('请选择图片文件')
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return ElMessage.warning('图片大小不能超过 5MB')
|
||||
}
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const url = await uploadToOss(file, 'ry8080/sponsor/avatar/')
|
||||
await request({
|
||||
url: '/system/user/profile/avatarUrl',
|
||||
method: 'put',
|
||||
data: { avatarUrl: url }
|
||||
})
|
||||
avatarUrl.value = url
|
||||
if (store.user) {
|
||||
store.user.avatar = url
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
ElMessage.success('头像更新成功')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.msg || '头像上传失败')
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||
window.parent.goHome?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserProfile() {
|
||||
try {
|
||||
const res = await request({ url: '/system/user/profile', method: 'get' })
|
||||
const u = res.data
|
||||
if (!u) return
|
||||
if (store.user) {
|
||||
store.user.avatar = u.avatar || store.user.avatar
|
||||
store.user.userName = u.userName || store.user.userName
|
||||
store.user.nickName = u.nickName || store.user.nickName
|
||||
store.user.phonenumber = u.phonenumber || store.user.phonenumber
|
||||
store.user.email = u.email || store.user.email
|
||||
store.user.createTime = u.createTime || store.user.createTime
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
avatarUrl.value = u.avatar || avatarUrl.value
|
||||
form.name = u.nickName || store.user?.userName || ''
|
||||
form.phone = u.phonenumber || ''
|
||||
form.licenseCertUrl = u.licenseCertUrl || ''
|
||||
form.idCardFrontUrl = u.idCardFrontUrl || ''
|
||||
form.idCardBackUrl = u.idCardBackUrl || ''
|
||||
// orgName 不存在 sys_user 表, 改从 biz_person 读 (只有 SUB 子账号有 person 记录, 后端已 LEFT JOIN biz_org)
|
||||
try {
|
||||
const myPerson = await request({
|
||||
url: '/business/person/list',
|
||||
method: 'get',
|
||||
params: { userId: u.userId, unitType: 'sponsor', pageNum: 1, pageSize: 1 }
|
||||
})
|
||||
const rows = (myPerson.data && myPerson.data.rows) || myPerson.rows || []
|
||||
const me = rows[0]
|
||||
myPersonId.value = me?.personId || null
|
||||
form.orgName = me?.orgName || ''
|
||||
} catch {
|
||||
myPersonId.value = null
|
||||
form.orgName = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('loadUserProfile:', e?.msg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUserProfile()
|
||||
snapshot = ref(JSON.parse(JSON.stringify(form)))
|
||||
})
|
||||
|
||||
async function onSave() {
|
||||
saveError.value = ''
|
||||
if (!(form.name || '').toString().trim()) {
|
||||
saveError.value = '请填写姓名'
|
||||
return ElMessage.warning(saveError.value)
|
||||
}
|
||||
// 主账号 (无 personId) 只保存 sys_user 字段; 子账号额外保存 biz_person.orgId
|
||||
// TODO: orgName → orgId 需后端根据 orgName 查 biz_org 取 org_id
|
||||
const sysPayload = {
|
||||
nickName: form.name,
|
||||
licenseCertUrl: form.licenseCertUrl,
|
||||
idCardFrontUrl: form.idCardFrontUrl,
|
||||
idCardBackUrl: form.idCardBackUrl
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await request({ url: '/system/user/profile', method: 'put', data: sysPayload })
|
||||
if (myPersonId.value) {
|
||||
await request({
|
||||
url: '/business/person',
|
||||
method: 'put',
|
||||
data: { personId: myPersonId.value, orgName: form.orgName }
|
||||
})
|
||||
}
|
||||
if (store.user) {
|
||||
store.user.nickName = form.name
|
||||
localStorage.setItem('ry_user', JSON.stringify(store.user))
|
||||
}
|
||||
snapshot.value = JSON.parse(JSON.stringify(form))
|
||||
ElMessage.success('个人信息已保存')
|
||||
} catch (e) {
|
||||
saveError.value = e?.msg || '保存失败'
|
||||
ElMessage.error(saveError.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (snapshot.value) {
|
||||
Object.assign(form, JSON.parse(JSON.stringify(snapshot.value)))
|
||||
}
|
||||
ElMessage.info('已恢复修改前内容')
|
||||
}
|
||||
|
||||
function onChangePwd() {
|
||||
pwdError.value = ''
|
||||
if (!pwd.old) return (pwdError.value = '请先填写原密码')
|
||||
if (pwd.new.length < 6) return (pwdError.value = '新密码长度不能少于 6 位')
|
||||
if (pwd.new !== pwd.confirm) return (pwdError.value = '两次输入的新密码不一致')
|
||||
request({
|
||||
url: '/system/user/profile/updatePwd',
|
||||
method: 'put',
|
||||
data: { oldPassword: pwd.old, newPassword: pwd.new }
|
||||
}).then(() => {
|
||||
ElMessage.success('密码修改成功')
|
||||
pwd.old = pwd.new = pwd.confirm = ''
|
||||
}).catch(e => {
|
||||
pwdError.value = e?.msg || '密码修改失败'
|
||||
ElMessage.error(pwdError.value)
|
||||
})
|
||||
}
|
||||
|
||||
function onCancelPwd() {
|
||||
pwd.old = pwd.new = pwd.confirm = ''
|
||||
pwdError.value = ''
|
||||
}
|
||||
|
||||
// 手机号 dialog
|
||||
function openPhoneDialog() {
|
||||
phoneForm.phone = ''
|
||||
phoneForm.smsCode = ''
|
||||
phoneError.value = ''
|
||||
phoneDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function sendSms() {
|
||||
const phone = (phoneForm.phone || '').trim()
|
||||
if (!/^(1[0-9])\d{9}$/.test(phone)) {
|
||||
return ElMessage.warning('请输入正确的手机号')
|
||||
}
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/system/user/profile/sendSmsCode',
|
||||
method: 'post',
|
||||
params: { phone }
|
||||
})
|
||||
phoneSmsUuid.value = res.uuid || ''
|
||||
ElMessage.success('验证码已发送')
|
||||
phoneSmsCountdown.value = 60
|
||||
phoneSmsTimer = setInterval(() => {
|
||||
phoneSmsCountdown.value--
|
||||
if (phoneSmsCountdown.value <= 0) {
|
||||
clearInterval(phoneSmsTimer)
|
||||
phoneSmsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangePhone() {
|
||||
phoneError.value = ''
|
||||
if (!phoneForm.phone || !phoneForm.smsCode || !phoneSmsUuid.value) {
|
||||
return (phoneError.value = '请填写完整信息')
|
||||
}
|
||||
phoneSaving.value = true
|
||||
try {
|
||||
await request({
|
||||
url: '/system/user/profile/changePhone',
|
||||
method: 'post',
|
||||
data: {
|
||||
phone: phoneForm.phone,
|
||||
smsCode: phoneForm.smsCode,
|
||||
uuid: phoneSmsUuid.value
|
||||
}
|
||||
})
|
||||
form.phone = phoneForm.phone
|
||||
if (store.user) store.user.phonenumber = phoneForm.phone
|
||||
ElMessage.success('手机号修改成功')
|
||||
phoneDialogVisible.value = false
|
||||
} catch (e) {
|
||||
phoneError.value = e?.msg || '修改失败'
|
||||
} finally {
|
||||
phoneSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (phoneSmsTimer) clearInterval(phoneSmsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/Account.vue */
|
||||
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; }
|
||||
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.breadcrumb .link { color: var(--brand-primary); cursor: pointer; }
|
||||
.breadcrumb .link:hover { color: var(--brand-primary-deep, var(--brand-primary)); text-decoration: underline; }
|
||||
.breadcrumb .sep { margin: 0 6px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: #262626; margin: 0 0 16px; }
|
||||
|
||||
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||
.account-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
|
||||
.account-tabs :deep(.el-tabs__active-bar) { background-color: var(--brand-primary); }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
.form-grid-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 头像块 */
|
||||
.avatar-block { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.avatar-circle {
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 600;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.avatar-circle:hover { transform: scale(1.04); }
|
||||
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
|
||||
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.avatar-tip {
|
||||
font-size: 11px; opacity: 0.85; margin-top: 4px;
|
||||
position: absolute; bottom: 4px; left: 0; right: 0;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
|
||||
.avatar-meta .role { font-size: 13px; color: #909399; }
|
||||
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
|
||||
.phone-label { color: #909399; }
|
||||
.phone-value { color: #262626; font-weight: 500; }
|
||||
.btn-edit-phone { padding: 0 4px; }
|
||||
|
||||
/* 错误提示 + 按钮区 */
|
||||
.form-error { color: #f56c6c; font-size: 13px; margin: 4px 0 12px; min-height: 18px; }
|
||||
.form-actions { margin-top: 20px; display: flex; gap: 12px; }
|
||||
|
||||
/* 密码 form 单列 */
|
||||
.password-form { max-width: 480px; }
|
||||
.password-form :deep(.el-form-item) { margin-bottom: 16px; }
|
||||
|
||||
/* 手机号 dialog 验证码 row */
|
||||
.sms-row { display: flex; gap: 8px; align-items: center; width: 100%; }
|
||||
.sms-row .el-input { flex: 1; }
|
||||
.btn-sms { flex-shrink: 0; }
|
||||
|
||||
/* 主色统一 (跟项目 brand-primary 一致, 深海军蓝) */
|
||||
:deep(.el-button--primary:not(.is-link)) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; color: #fff; }
|
||||
:deep(.el-button--primary:not(.is-link):hover),
|
||||
:deep(.el-button--primary:not(.is-link):focus) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); color: #fff; }
|
||||
/* link 文字按钮 + 链接色都用主色, 跟 button 主色保持一致 (不撞色) */
|
||||
:deep(.el-button--primary.is-link),
|
||||
:deep(.el-button.is-link),
|
||||
:deep(.el-link.is-link),
|
||||
:deep(.el-tabs__item.is-active) { color: var(--brand-primary); }
|
||||
:deep(.el-button--primary.is-link:hover),
|
||||
:deep(.el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-button) { border-radius: 4px; }
|
||||
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<!--
|
||||
原型: 工作台.html
|
||||
SIDEBAR: ['项目管理', '项目策划方案管理', '会议管理', '专家审核']
|
||||
筛选区 (top 165-205, 按 left): 项目策划方案管理 / 项目总数量 / 已结算的会议 / 已结题的项目
|
||||
列头 (top 285-335, 按 left): 工作台 / 会议管理
|
||||
-->
|
||||
<div class="sponsor-home">
|
||||
|
||||
<!-- KPI: 5 个数字面板, 1 行展示 -->
|
||||
<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=1">
|
||||
<div class="stat-label">已结算的会议</div>
|
||||
<div class="stat-value">{{ stats.settledMeetings }}</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=已执行">
|
||||
<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=未执行">
|
||||
<div class="stat-label">未执行会议</div>
|
||||
<div class="stat-value">{{ stats.pendingMeetings }}</div>
|
||||
<div class="stat-desc">查看未执行会议</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { bizList, listMyMessages } from '@/api/public'
|
||||
|
||||
const stats = ref({
|
||||
totalProjects: 0,
|
||||
settledMeetings: 0,
|
||||
completedProjects: 0,
|
||||
executedMeetings: 0,
|
||||
pendingMeetings: 0
|
||||
})
|
||||
const notices = ref([])
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.totalProjects = ps.total || 0
|
||||
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
|
||||
stats.value.executedMeetings = ms.total || 0
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
.sponsor-home { padding: 16px; }
|
||||
|
||||
.stats-grid { display: grid; gap: 16px; margin-bottom: 16px; }
|
||||
.stats-grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.stats-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.stats-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
.stat-label { font-size: 14px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: var(--brand-primary); line-height: 1; margin-bottom: 8px; }
|
||||
.stat-desc { font-size: 12px; color: #bfbfbf; }
|
||||
|
||||
.hint-text { font-size: 12px; color: #ff4d4f; margin: 12px 0; text-align: center; }
|
||||
|
||||
.section { background: #fff; border-radius: 4px; padding: 20px 24px; border: 1px solid #f0f0f0; }
|
||||
.section-title {
|
||||
font-size: 15px; font-weight: 600; color: #1a1a1a;
|
||||
margin-bottom: 16px; padding-left: 10px;
|
||||
border-left: 3px solid var(--brand-primary);
|
||||
display: flex; justify-content: space-between;
|
||||
}
|
||||
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; }
|
||||
.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); }
|
||||
.stats-grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 会议列表</div>
|
||||
|
||||
<!-- ========== 筛选区 (按 sidebar=项目管理/... 的支持方原型 会议管理.html) ========== -->
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" placeholder="项目编号" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议ID">
|
||||
<el-input v-model="q.meetingId" placeholder="会议ID" clearable style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议名称">
|
||||
<el-input v-model="q.meetingName" placeholder="会议名称" clearable style="width:180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="第">
|
||||
<el-input v-model="q.periodNo" placeholder="期数" clearable style="width:80px" />
|
||||
<span style="margin:0 4px">期</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="会议时间起">
|
||||
<el-date-picker v-model="q.startTimeRange" type="datetimerange" range-separator="至"
|
||||
start-placeholder="开始时间" end-placeholder="结束时间" style="width:340px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="q.projectForm" clearable placeholder="全部" style="width:120px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前阶段">
|
||||
<el-select v-model="q.currentStage" clearable placeholder="全部" style="width:120px">
|
||||
<el-option label="已执行" value="已执行" />
|
||||
<el-option label="未执行" value="未执行" />
|
||||
<el-option label="执行中" value="执行中" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="q.remark" placeholder="备注" clearable style="width:160px" />
|
||||
</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>
|
||||
|
||||
<!-- ========== 批量按钮区 (top=232, 3 个按钮) ========== -->
|
||||
<div class="batch-bar">
|
||||
<el-button :disabled="!selected.length" @click="onBatch('labor')">批量下载劳务材料</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onBatch('meeting')">批量下载会务材料</el-button>
|
||||
<el-button :disabled="!selected.length" type="warning" @click="onBatchAudit">批量审核</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ========== 表格区 (按原型列头) ========== -->
|
||||
<el-table :data="list" border stripe v-loading="loading" @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column prop="projectNo" label="项目编号" min-width="130" />
|
||||
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="总期数" prop="totalPeriod" width="80" />
|
||||
<el-table-column label="期数" prop="periodNo" width="70" />
|
||||
<el-table-column label="当前阶段" prop="currentStage" width="100" />
|
||||
<el-table-column label="会议开始时间" prop="startTime" min-width="160" />
|
||||
<el-table-column label="会议结束时间" prop="endTime" min-width="160" />
|
||||
<el-table-column label="提交剩余时间" width="140">
|
||||
<template #default="{ row }">{{ calcRemain(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目形式" prop="projectForm" width="100" />
|
||||
<el-table-column label="操作" width="420" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="onView(row)">查看</el-button>
|
||||
<el-button size="small" link type="primary" @click="onEdit(row)">修改</el-button>
|
||||
<el-button size="small" link type="warning" :disabled="!canSettle(row)" @click="onSettle(row)">结算</el-button>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize"
|
||||
:total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadList" @size-change="loadList" />
|
||||
</div>
|
||||
|
||||
<!-- ========== Dialog: 详情 ========== -->
|
||||
<el-dialog v-model="viewOpen" title="会议详情" width="600px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="项目编号">{{ view.projectNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目形式">{{ view.projectForm }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议名称" :span="2">{{ view.meetingName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议ID">{{ view.businessId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段">{{ view.currentStage }}</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.remark }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="viewOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 修改 ========== -->
|
||||
<el-dialog v-model="editOpen" title="修改会议" width="560px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="会议名称"><el-input v-model="editForm.meetingName" /></el-form-item>
|
||||
<el-form-item label="会议开始时间"><el-date-picker v-model="editForm.startTime" type="datetime" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="会议结束时间"><el-date-picker v-model="editForm.endTime" type="datetime" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="editForm.projectForm" style="width:100%">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="submitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 审核 (原型 u3136: 通过/退回 + 意见) ========== -->
|
||||
<el-dialog v-model="auditOpen" title="审核" width="520px">
|
||||
<el-form :model="auditForm" label-width="100px">
|
||||
<el-form-item label="会议名称">
|
||||
<span>{{ auditRow?.meetingName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.action">
|
||||
<el-radio value="pass">通过</el-radio>
|
||||
<el-radio value="reject">退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见">
|
||||
<p style="margin:0 0 6px;font-size:12px;color:#909399">*意见为非必填项,未填写则默认显示无</p>
|
||||
<el-input v-model="auditForm.opinion" type="textarea" rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="auditSubmitting" @click="submitAudit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ========== Dialog: 解冻确认 ========== -->
|
||||
<el-dialog v-model="unfreezeOpen" title="解冻确认" width="420px">
|
||||
<p>您确定要解冻吗?</p>
|
||||
<p style="margin-top:8px;color:#606266">会议: {{ unfreezeRow?.meetingName }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="unfreezeOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="unfreezeSubmitting" @click="submitUnfreeze">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bizList, bizUpdate } from '@/api/public'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref([])
|
||||
const q = reactive({
|
||||
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
|
||||
startTimeRange: null, projectForm: '', currentStage: '', remark: ''
|
||||
})
|
||||
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const view = ref({})
|
||||
const viewOpen = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editForm = reactive({ meetingId: null, meetingName: '', startTime: null, endTime: null, projectForm: '', remark: '' })
|
||||
const auditOpen = ref(false)
|
||||
const auditRow = ref(null)
|
||||
const auditSubmitting = ref(false)
|
||||
const auditForm = reactive({ action: 'pass', opinion: '' })
|
||||
const unfreezeOpen = ref(false)
|
||||
const unfreezeRow = ref(null)
|
||||
const unfreezeSubmitting = ref(false)
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
pageNum: page.pageNum, pageSize: page.pageSize,
|
||||
...q,
|
||||
startTime: q.startTimeRange?.[0] || '',
|
||||
endTime: q.startTimeRange?.[1] || ''
|
||||
}
|
||||
delete params.startTimeRange
|
||||
const r = await bizList('meeting', params)
|
||||
list.value = r.rows || []
|
||||
page.total = r.total || 0
|
||||
} catch (e) { list.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', meetingId: '', meetingName: '', periodNo: '', startTimeRange: null, projectForm: '', currentStage: '', remark: '' })
|
||||
page.pageNum = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
function onSelectionChange(rows) { selected.value = rows }
|
||||
|
||||
function onView(row) { view.value = row; viewOpen.value = true }
|
||||
|
||||
function onEdit(row) {
|
||||
Object.assign(editForm, row)
|
||||
editForm.startTime = row.startTime || null
|
||||
editForm.endTime = row.endTime || null
|
||||
editOpen.value = true
|
||||
}
|
||||
async function submitEdit() {
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await bizUpdate('meeting', editForm)
|
||||
ElMessage.success('修改成功')
|
||||
editOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '修改失败') }
|
||||
finally { editSubmitting.value = false }
|
||||
}
|
||||
|
||||
function onAudit(row) {
|
||||
auditRow.value = row
|
||||
auditForm.action = 'pass'
|
||||
auditForm.opinion = ''
|
||||
auditOpen.value = true
|
||||
}
|
||||
async function submitAudit() {
|
||||
if (!auditRow.value) return
|
||||
auditSubmitting.value = true
|
||||
try {
|
||||
const text = auditForm.opinion || '无'
|
||||
await bizUpdate('meeting', {
|
||||
meetingId: auditRow.value.meetingId,
|
||||
auditStatus: auditForm.action === 'pass' ? '通过' : '退回',
|
||||
auditOpinion: text
|
||||
})
|
||||
ElMessage.success('审核成功')
|
||||
auditOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '审核失败') }
|
||||
finally { auditSubmitting.value = false }
|
||||
}
|
||||
|
||||
function canSettle(row) {
|
||||
return row && ['未执行', '执行中'].includes(row.currentStage)
|
||||
}
|
||||
function onSettle(row) {
|
||||
ElMessageBox.confirm(`确定发起「${row.meetingName}」结算吗?`, '结算', { type: 'warning' })
|
||||
.then(async () => {
|
||||
await bizUpdate('meeting', { meetingId: row.meetingId, currentStage: '已完结' })
|
||||
ElMessage.success('已发起结算')
|
||||
loadList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onDownload(row, type) {
|
||||
const url = type === 'labor' ? row.laborUrl : row.meetingUrl
|
||||
if (!url) { ElMessage.warning('暂无文件'); return }
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function onUnfreeze(row) {
|
||||
unfreezeRow.value = row
|
||||
unfreezeOpen.value = true
|
||||
}
|
||||
async function submitUnfreeze() {
|
||||
if (!unfreezeRow.value) return
|
||||
unfreezeSubmitting.value = true
|
||||
try {
|
||||
await bizUpdate('meeting', { meetingId: unfreezeRow.value.meetingId, currentStage: '执行中' })
|
||||
ElMessage.success('解冻成功')
|
||||
unfreezeOpen.value = false
|
||||
loadList()
|
||||
} catch (e) { ElMessage.error(e?.msg || '解冻失败') }
|
||||
finally { unfreezeSubmitting.value = false }
|
||||
}
|
||||
|
||||
function onBatch(type) {
|
||||
if (!selected.value.length) return
|
||||
selected.value.forEach(r => onDownload(r, type))
|
||||
}
|
||||
function onBatchAudit() {
|
||||
if (!selected.value.length) return
|
||||
auditRow.value = selected.value[0]
|
||||
auditOpen.value = true
|
||||
}
|
||||
|
||||
function calcRemain(row) {
|
||||
if (!row.endTime || !row.submitDeadline) return '-'
|
||||
const diff = new Date(row.submitDeadline) - new Date(row.endTime)
|
||||
if (diff <= 0) return '冻结中'
|
||||
const h = Math.floor(diff / 3600000)
|
||||
return h > 24 ? `${Math.floor(h/24)}天${h%24}小时` : `${h}小时`
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/Submissions.vue + SponsorPeople.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.batch-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<!--
|
||||
1:1 抄 doctor/SubmissionNew.vue
|
||||
表单布局: el-card shadow="never" form-card + el-row :gutter="12" el-col :span="12" 两列
|
||||
编辑/新建复用: 通过 route.params.id 区分
|
||||
-->
|
||||
<div class="page-card">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">人员管理</a> > {{ isEdit ? '编辑人员' : '新建人员' }}
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-row v-if="!isEdit" :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="账号" prop="loginUsername">
|
||||
<el-input v-model="form.loginUsername" placeholder="请输入子账号登录账号" maxlength="30" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="初始密码" prop="loginPassword">
|
||||
<el-input v-model="form.loginPassword" placeholder="请输入初始密码 (≥6 位)" type="password" show-password maxlength="30" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="所属公司" prop="orgName">
|
||||
<el-input v-model="form.orgName" placeholder="请输入所属公司 (TODO: 改 biz_org 下拉)" maxlength="200" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入部门" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="职务" prop="position">
|
||||
<el-input v-model="form.position" placeholder="请输入职务" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="form.role" placeholder="请选择" style="width:100%">
|
||||
<el-option label="监察员" value="监察员" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio value="0">正常</el-radio>
|
||||
<el-radio value="1">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<el-button @click="confirmCancel">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const isEdit = !!route.params.id
|
||||
const personId = route.params.id || null
|
||||
|
||||
const form = reactive({
|
||||
personId: null,
|
||||
name: '',
|
||||
phone: '',
|
||||
orgName: '',
|
||||
department: '',
|
||||
position: '',
|
||||
role: '监察员',
|
||||
unitType: 'sponsor',
|
||||
status: '0',
|
||||
// 子账号登录信息 (后端会创建 sys_user SUB,parent_user_id=主账号)
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
})
|
||||
|
||||
// 7 字段全必填 + 手机号正则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
|
||||
],
|
||||
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }],
|
||||
department:[{ required: true, message: '请输入部门', trigger: 'blur' }],
|
||||
position: [{ required: true, message: '请输入职务', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
loginUsername: [
|
||||
{ required: true, message: '请输入子账号登录账号', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]{2,29}$/, message: '账号以字母开头,3-30 位字母数字下划线', trigger: 'blur' },
|
||||
],
|
||||
loginPassword: [
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ min: 6, max: 30, message: '密码长度 6-30 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ path: '/sponsor/people', query: { _t: Date.now() } })
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!personId) return
|
||||
loadingDetail.value = true
|
||||
try {
|
||||
const res = await bizGet('person', personId)
|
||||
if (res?.code === 200 && res.data) {
|
||||
const d = res.data
|
||||
Object.assign(form, {
|
||||
personId: d.personId,
|
||||
name: d.name || '',
|
||||
phone: d.phone || '',
|
||||
orgName: d.orgName || '',
|
||||
department: d.department || '',
|
||||
position: d.position || '',
|
||||
role: d.role || '',
|
||||
unitType: d.unitType || 'sponsor',
|
||||
status: d.status != null ? String(d.status) : '0',
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
goBack()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
goBack()
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
// 检查表单是否有内容 (新建时只要 form.name 即可)
|
||||
const hasContent = form.name || form.phone || form.orgName || form.department || form.position || form.role
|
||||
if (!isEdit && !hasContent) { goBack(); return }
|
||||
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
|
||||
.then(() => goBack())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 业务"重复/已存在"提示用 warning 黄 (memory 规则)
|
||||
function isDuplicateError(msg) {
|
||||
return /已存在|重复|duplicate|uk_person_phone/i.test(msg || '')
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
let payload = { ...form }
|
||||
let res
|
||||
if (isEdit) {
|
||||
delete payload.loginUsername // 编辑时不改登录账号
|
||||
delete payload.loginPassword // 编辑时不改密码 (password 走独立接口)
|
||||
res = await bizUpdate('person', payload)
|
||||
} else {
|
||||
payload.unitType = 'sponsor'
|
||||
res = await bizAdd('person', payload)
|
||||
}
|
||||
if (res?.code === 200) {
|
||||
if (!isEdit && res.data) {
|
||||
// 新建成功: 提示主账号刚创建的子账号信息
|
||||
const u = res.data
|
||||
ElMessageBox.alert(
|
||||
`子账号已创建\n账号: ${u.userName || form.loginUsername}\n初始密码: ${u.password || form.loginPassword}\n请记录后告知使用人 (密码仅本次显示)`,
|
||||
'创建成功',
|
||||
{ type: 'success', confirmButtonText: '我已记录, 返回' }
|
||||
).then(() => goBack())
|
||||
.catch(() => goBack())
|
||||
} else {
|
||||
ElMessage.success(isEdit ? '修改成功' : '新建成功')
|
||||
goBack()
|
||||
}
|
||||
} else {
|
||||
const msg = res?.msg || '保存失败'
|
||||
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
|
||||
else ElMessage.error(msg)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.msg || e?.message || '保存失败'
|
||||
if (isDuplicateError(msg)) ElMessage.warning('该手机号已存在,请更换')
|
||||
else ElMessage.error(msg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit) loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/SubmissionNew.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
|
||||
.form-card.nested { background: transparent; border: none; box-shadow: none; }
|
||||
:deep(.form-card .el-card__body) { padding: 24px 28px; }
|
||||
|
||||
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||
.form-hint { font-size: 12px; color: #ff4d4f; line-height: 1.6; margin: 4px 0; }
|
||||
|
||||
: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, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-button) { border-radius: 4px; }
|
||||
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<!--
|
||||
1:1 抄 doctor/SubmissionDetail.vue
|
||||
只读表单: <el-input :model-value="..." readonly /> + readonly-cell 自定义块
|
||||
状态用 <el-tag>
|
||||
-->
|
||||
<div class="sponsor-people-detail">
|
||||
<div class="breadcrumb">
|
||||
<a @click="goBack">人员管理</a> > 人员详情
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" shadow="never" class="detail-card">
|
||||
<el-form label-width="120px" class="readonly-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名">
|
||||
<el-input :model-value="display.name" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号">
|
||||
<el-input :model-value="display.phone" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工作单位">
|
||||
<el-input :model-value="display.orgName" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="部门">
|
||||
<el-input :model-value="display.department" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="职务">
|
||||
<el-input :model-value="display.position" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色">
|
||||
<el-input :model-value="display.role" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-tag v-if="detail.status !== null && detail.status !== undefined && detail.status !== ''"
|
||||
:type="detail.status === '0' ? 'success' : 'danger'" disable-transitions>
|
||||
{{ detail.status === '0' ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="创建时间">
|
||||
<el-input :model-value="display.createTime" readonly />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="detail-actions">
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bizGet } from '@/api/public'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref({})
|
||||
|
||||
function fmt(v) {
|
||||
if (v === null || v === undefined || v === '') return '-'
|
||||
return v
|
||||
}
|
||||
function fmtTime(t) {
|
||||
if (!t) return '-'
|
||||
try { return new Date(t).toLocaleString('zh-CN', { hour12: false }) } catch { return t }
|
||||
}
|
||||
|
||||
const display = computed(() => ({
|
||||
name: fmt(detail.value.name),
|
||||
phone: fmt(detail.value.phone),
|
||||
orgName: fmt(detail.value.orgName),
|
||||
department: fmt(detail.value.department),
|
||||
position: fmt(detail.value.position),
|
||||
role: fmt(detail.value.role),
|
||||
createTime: fmtTime(detail.value.createTime),
|
||||
}))
|
||||
|
||||
function goBack() {
|
||||
router.push({ path: '/sponsor/people', query: { _t: Date.now() } })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const personId = route.params.id
|
||||
if (!personId) { ElMessage.warning('参数缺失'); goBack(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await bizGet('person', personId)
|
||||
if (res?.code === 200) {
|
||||
detail.value = res.data || {}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/SubmissionDetail.vue */
|
||||
.sponsor-people-detail { max-width: 1000px; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
|
||||
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
|
||||
.detail-card { border-radius: 6px; }
|
||||
.detail-card :deep(.el-card__body) { padding: 24px 28px; }
|
||||
|
||||
.readonly-form :deep(.el-form-item) { margin-bottom: 22px; }
|
||||
.readonly-form :deep(.el-form-item__label) { color: #606266; font-weight: normal; }
|
||||
|
||||
.detail-actions { margin-top: 16px; }
|
||||
:deep(.el-button) { border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<!--
|
||||
1:1 抄 doctor/Projects.vue + doctor/Submissions.vue 容器
|
||||
SIDEBAR: 项目管理
|
||||
列头 (按原型 sponsor-项目管理.html left 顺序):
|
||||
复选框 / 项目编号 / 项目名称 / 总场次 / 已执行 / 未执行 /
|
||||
总金额 / 可用金额 / 已支付劳务费 / 已支付会务费 /
|
||||
执行单位得分 / 执行单位评价 / 支持单位 / 服务机构 / 项目形式 / 是否结题 /
|
||||
开始时间 / 结束时间 / 操作
|
||||
操作: 建会 / 分配 / 结题 / 查看 / 编辑 / 开通 / 删除公告 / 支持单位 / 执行单位评分
|
||||
业务保留: 批量评分/批量结题/批量开通/项目分配 逻辑
|
||||
-->
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 项目管理</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<el-input v-model="q.projectName" placeholder="输入项目名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="q.projectForm" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题">
|
||||
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已结题" value="1" />
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查找</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建项目</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onAssignSelected">项目分配</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onBatchRate">批量评分</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onBatchFinish">批量结题</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onBatchOpen">批量开通</el-button>
|
||||
<span class="hint">已选 {{ selected.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelect">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="projectNo" label="项目编号" min-width="140" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.projectNo }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="totalSessions" label="总场次/总期数" width="120" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column label="总金额" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.totalAmount) }}</template></el-table-column>
|
||||
<el-table-column label="可用金额" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.availableAmount) }}</template></el-table-column>
|
||||
<el-table-column label="已支付劳务费" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.paidLaborAmount) }}</template></el-table-column>
|
||||
<el-table-column label="已支付会务费" width="120" align="right"><template #default="{ row }">¥{{ formatMoney(row.paidMeetingAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="complianceScore" label="执行单位得分(合规)" width="150" align="center" />
|
||||
<el-table-column prop="ratingScore" label="执行单位评价(支持方)" width="160" align="center" />
|
||||
<el-table-column prop="orgName" label="赞助公司" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
|
||||
<el-table-column label="是否结题" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isFinished === '1' ? 'success' : 'info'" size="small" disable-transitions>
|
||||
{{ row.isFinished === '1' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="项目开始时间" min-width="160" />
|
||||
<el-table-column prop="endTime" label="项目结束时间" min-width="160" />
|
||||
<el-table-column label="操作" width="480" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="onCreateMeeting(row)">建会</el-button>
|
||||
<el-button link type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button link type="primary" @click="onFinish(row)">结题</el-button>
|
||||
<el-button link type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="onOpen(row)">开通</el-button>
|
||||
<el-button link type="danger" @click="onDeleteNotice(row)">删除公告</el-button>
|
||||
<el-button link type="primary" @click="onSupportUnit(row)">支持单位</el-button>
|
||||
<el-button link type="primary" @click="openRate(row)">执行单位评分</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<!-- 新建/编辑项目 dialog (跟 doctor/Projects.vue 风格统一) -->
|
||||
<el-dialog v-model="formOpen" :title="formMode === 'create' ? '新建项目' : '编辑项目'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="项目编号"><el-input v-model="form.projectNo" /></el-form-item>
|
||||
<el-form-item label="项目名称"><el-input v-model="form.projectName" /></el-form-item>
|
||||
<el-form-item label="项目形式">
|
||||
<el-select v-model="form.projectForm" placeholder="请选择" clearable>
|
||||
<el-option label="线上" value="线上" />
|
||||
<el-option label="线下" value="线下" />
|
||||
<el-option label="线上+线下" value="线上+线下" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="总场次"><el-input-number v-model="form.totalSessions" :min="0" :max="9999" /></el-form-item>
|
||||
<el-form-item label="管理费"><el-input-number v-model="form.manageFee" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="开始时间"><el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
|
||||
<el-form-item label="结束时间"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 评分 dialog -->
|
||||
<el-dialog v-model="rateOpen" title="执行单位评分" width="520px">
|
||||
<el-form :model="rateForm" label-width="100px">
|
||||
<el-form-item label="项目编号 / 名称">
|
||||
<span>{{ rateForm.projectNo }} / {{ rateForm.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价维度">
|
||||
<div class="score-row"><span>履约质量:</span><el-rate v-model="rateForm.q1" :max="5" /></div>
|
||||
<div class="score-row"><span>时效响应:</span><el-rate v-model="rateForm.q2" :max="5" /></div>
|
||||
<div class="score-row"><span>配合度:</span><el-rate v-model="rateForm.q3" :max="5" /></div>
|
||||
<div class="score-row"><span>合规安全:</span><el-rate v-model="rateForm.q4" :max="5" /></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价备注"><el-input v-model="rateForm.remark" type="textarea" rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rateOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRate">提交评分</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配 dialog -->
|
||||
<el-dialog v-model="assignOpen" title="项目分配" width="520px">
|
||||
<el-form :model="assignForm" label-width="100px">
|
||||
<el-form-item label="项目编号 / 名称">
|
||||
<span>{{ assignForm.projectNo }} / {{ assignForm.projectName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配给监察员">
|
||||
<el-select v-model="assignForm.supervisor" filterable placeholder="请选择监察员" style="width:100%">
|
||||
<el-option v-for="p in personList" :key="p.personId" :label="p.name" :value="p.personId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="监察要点"><el-input v-model="assignForm.supervisionPoint" type="textarea" rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAssign">确认分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情 dialog (跟 doctor/Projects.vue el-descriptions 同款) -->
|
||||
<el-dialog v-model="detailOpen" title="项目详情" width="640px">
|
||||
<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.orgName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公司类型" :span="2">
|
||||
<el-tag :type="detail.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
|
||||
{{ detail.orgType === 'sponsor' ? '赞助方' : '执行方' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已执行/未执行">{{ detail.doneSessions || 0 }} / {{ detail.todoSessions || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总金额">¥{{ formatMoney(detail.totalAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可用金额">¥{{ formatMoney(detail.availableAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付劳务费">¥{{ formatMoney(detail.paidLaborAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已支付会务费">¥{{ formatMoney(detail.paidMeetingAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行单位得分">{{ detail.ratingScore || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否结题">
|
||||
<el-tag :type="detail.isFinished === '1' ? 'success' : 'info'" disable-transitions>
|
||||
{{ detail.isFinished === '1' ? '已结题' : '未结题' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间" :span="2">{{ detail.startTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间" :span="2">{{ detail.endTime || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
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 page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
|
||||
const selected = ref([])
|
||||
|
||||
const formOpen = ref(false)
|
||||
const formMode = ref('create')
|
||||
const form = reactive({
|
||||
projectId: null, projectNo: '', projectName: '', projectForm: '',
|
||||
totalSessions: 0, manageFee: 0, startTime: '', endTime: ''
|
||||
})
|
||||
|
||||
const rateOpen = ref(false)
|
||||
const rateForm = reactive({ projectId: null, projectNo: '', projectName: '', q1: 0, q2: 0, q3: 0, q4: 0, remark: '' })
|
||||
|
||||
const assignOpen = ref(false)
|
||||
const assignForm = reactive({ projectId: '', projectNo: '', projectName: '', supervisor: '', supervisionPoint: '' })
|
||||
const personList = ref([])
|
||||
|
||||
const detail = ref({})
|
||||
const detailOpen = ref(false)
|
||||
|
||||
function formatMoney(v) { return Number(v || 0).toFixed(2) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
// sponsor 专属: GET /business/project/sponsorList (后端按 mainUid 团队可见性过滤)
|
||||
const { data } = await request.get('/business/project/sponsorList', {
|
||||
params: { ...q, pageNum: page.pageNum, pageSize: page.pageSize }
|
||||
})
|
||||
const payload = data?.data || data
|
||||
rows.value = payload?.rows || []
|
||||
page.total = payload?.total || 0
|
||||
} catch (e) { rows.value = []; page.total = 0 }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadPersons() {
|
||||
try {
|
||||
// sponsor 团队下"监察员"角色 + 已绑 sys_user 的子账号
|
||||
const r = await bizList('person', { pageNum: 1, pageSize: 100, unitType: 'sponsor', role: '监察员' })
|
||||
personList.value = (r.rows || []).filter(p => p.userId)
|
||||
} catch (e) { personList.value = [] }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(q, { projectNo: '', projectName: '', projectForm: '', isFinished: '' })
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelect(arr) { selected.value = arr }
|
||||
|
||||
function openDetail(row) { detail.value = row; detailOpen.value = true }
|
||||
|
||||
function openCreate() {
|
||||
formMode.value = 'create'
|
||||
Object.assign(form, { projectId: null, projectNo: '', projectName: '', projectForm: '', totalSessions: 0, manageFee: 0, startTime: '', endTime: '' })
|
||||
formOpen.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
formMode.value = 'edit'
|
||||
Object.assign(form, {
|
||||
projectId: row.projectId, projectNo: row.projectNo, projectName: row.projectName,
|
||||
projectForm: row.projectForm, totalSessions: row.totalSessions, manageFee: row.manageFee,
|
||||
startTime: row.startTime || '', endTime: row.endTime || ''
|
||||
})
|
||||
formOpen.value = true
|
||||
}
|
||||
function onCreateMeeting(row) {
|
||||
ElMessage.info(`已为项目「${row.projectName}」启动建会流程`)
|
||||
}
|
||||
|
||||
function onSupportUnit(row) {
|
||||
if (!row.orgId) { ElMessage.warning('该项目未关联赞助公司'); return }
|
||||
ElMessage.info(`查看赞助公司: ${row.orgName || row.orgId}`)
|
||||
}
|
||||
async function onDeleteNotice(row) {
|
||||
try { await ElMessageBox.confirm(`确定删除「${row.projectName}」的所有公告吗?删除后无法恢复`, '删除公告', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
await bizUpdate('project', { projectId: row.projectId, noticeDeleted: '1' })
|
||||
ElMessage.success('公告已删除')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
|
||||
}
|
||||
async function submitForm() {
|
||||
if (!form.projectNo || !form.projectName) { ElMessage.warning('请填写项目编号与项目名称'); return }
|
||||
try {
|
||||
if (formMode.value === 'create') await bizAdd('project', { ...form })
|
||||
else await bizUpdate('project', form)
|
||||
ElMessage.success('保存成功')
|
||||
formOpen.value = false
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
|
||||
}
|
||||
|
||||
function openRate(row) {
|
||||
Object.assign(rateForm, { projectId: row.projectId, projectNo: row.projectNo, projectName: row.projectName, q1: 0, q2: 0, q3: 0, q4: 0, remark: '' })
|
||||
rateOpen.value = true
|
||||
}
|
||||
async function submitRate() {
|
||||
if (!rateForm.q1 && !rateForm.q2 && !rateForm.q3 && !rateForm.q4) {
|
||||
ElMessage.warning('请至少完成一项评分'); return
|
||||
}
|
||||
try {
|
||||
// sponsor 专属: POST /business/project/rate (BizProjectSponsor 字段)
|
||||
const data = {
|
||||
projectId: rateForm.projectId,
|
||||
projectNo: rateForm.projectNo,
|
||||
projectName: rateForm.projectName,
|
||||
sponsorQ1: rateForm.q1,
|
||||
sponsorQ2: rateForm.q2,
|
||||
sponsorQ3: rateForm.q3,
|
||||
sponsorQ4: rateForm.q4,
|
||||
sponsorRemark: rateForm.remark
|
||||
}
|
||||
await request.post('/business/project/rate', data)
|
||||
ElMessage.success('评分已提交')
|
||||
rateOpen.value = false
|
||||
} catch (e) { ElMessage.error(e?.msg || '提交失败') }
|
||||
}
|
||||
|
||||
async function onBatchRate() {
|
||||
if (!selected.value.length) return
|
||||
let ok = 0
|
||||
for (const r of selected.value) {
|
||||
try {
|
||||
await bizAdd('projectRating', { projectId: r.projectId, projectNo: r.projectNo, projectName: r.projectName, q1: 3, q2: 3, q3: 3, q4: 3, remark: '批量评分' })
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
ElMessage.success(`已批量评分 ${ok} 项`)
|
||||
}
|
||||
|
||||
async function onBatchFinish() {
|
||||
if (!selected.value.length) return
|
||||
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量结题吗?`, '批量结题', { type: 'warning' }) } catch { return }
|
||||
let ok = 0
|
||||
for (const r of selected.value) {
|
||||
try { await bizUpdate('project', { projectId: r.projectId, isFinished: '1' }); ok++ } catch {}
|
||||
}
|
||||
ElMessage.success(`已批量结题 ${ok} 项`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function onBatchOpen() {
|
||||
if (!selected.value.length) return
|
||||
try { await ElMessageBox.confirm(`确定对选中的 ${selected.value.length} 个项目批量开通吗?`, '批量开通', { type: 'warning' }) } catch { return }
|
||||
let ok = 0
|
||||
for (const r of selected.value) {
|
||||
try { await bizUpdate('project', { projectId: r.projectId, isPublished: '1' }); ok++ } catch {}
|
||||
}
|
||||
ElMessage.success(`已批量开通 ${ok} 项`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function onFinish(row) {
|
||||
try { await ElMessageBox.confirm(`确定将「${row.projectName}」结题吗?`, '结题', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
await bizUpdate('project', { projectId: row.projectId, isFinished: '1' })
|
||||
ElMessage.success('已结题')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
async function onOpen(row) {
|
||||
try { await ElMessageBox.confirm(`确定开通「${row.projectName}」吗?`, '开通', { type: 'warning' }) } catch { return }
|
||||
try {
|
||||
await bizUpdate('project', { projectId: row.projectId, isPublished: '1' })
|
||||
ElMessage.success('已开通')
|
||||
load()
|
||||
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
function onAssignSelected() {
|
||||
if (!selected.value.length) { ElMessage.warning('请先勾选项目'); return }
|
||||
ElMessage.info(`已为 ${selected.value.length} 个项目启动批量分配流程,请逐个确认`)
|
||||
}
|
||||
|
||||
function openAssign(row) {
|
||||
assignForm.projectId = row.projectId
|
||||
assignForm.projectNo = row.projectNo
|
||||
assignForm.projectName = row.projectName
|
||||
assignForm.supervisor = ''
|
||||
assignForm.supervisionPoint = ''
|
||||
assignOpen.value = true
|
||||
}
|
||||
async function submitAssign() {
|
||||
if (!assignForm.supervisor) { ElMessage.warning('请选择监察员'); return }
|
||||
try {
|
||||
const person = personList.value.find(p => String(p.personId) === String(assignForm.supervisor))
|
||||
if (!person || !person.userId) { ElMessage.error('未找到监察员账号'); return }
|
||||
// sponsor 专属: POST /business/project/sponsorAssign (BizProjectSponsorAssign 字段)
|
||||
await request.post('/business/project/sponsorAssign', {
|
||||
projectId: assignForm.projectId,
|
||||
projectNo: assignForm.projectNo,
|
||||
projectName: assignForm.projectName,
|
||||
monitorUserId: person.userId,
|
||||
assignPoints: assignForm.supervisionPoint || '',
|
||||
assignDesc: ''
|
||||
})
|
||||
ElMessage.success('分配成功')
|
||||
assignOpen.value = false
|
||||
} catch (e) { ElMessage.error(e?.msg || '分配失败') }
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadPersons() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/Projects.vue + doctor/Submissions.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.hint { color: #909399; font-size: 12px; margin-left: 8px; }
|
||||
.score-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
|
||||
: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, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<!--
|
||||
1:1 抄 doctor/Submissions.vue
|
||||
容器: <div class="page-card">
|
||||
面包屑: <div class="breadcrumb">首页 / 人员管理</div>
|
||||
筛选栏: <el-form inline :model="q" class="filter-form">
|
||||
工具栏: <div class="toolbar">
|
||||
列表列 (按原型 sponsor-people.html left 排序):
|
||||
复选框 / 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色 / 账号类型 / 状态 / 操作
|
||||
-->
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 人员管理</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="q.name" placeholder="输入姓名" clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="q.phone" placeholder="输入手机号" clearable maxlength="11" style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作单位">
|
||||
<el-input v-model="q.orgName" placeholder="输入所属公司" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-input v-model="q.department" placeholder="输入部门" clearable style="width: 140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="q.status" placeholder="全部状态" clearable style="width: 140px">
|
||||
<el-option label="正常" value="0" />
|
||||
<el-option label="禁用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="onCreate">新建人员</el-button>
|
||||
<el-button @click="onImport">批量导入</el-button>
|
||||
<el-button :disabled="!selected.length" @click="onBatchRemove">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<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 />
|
||||
<el-table-column prop="department" label="部门" width="120" />
|
||||
<el-table-column prop="position" label="职务" width="120" />
|
||||
<el-table-column prop="role" label="角色" width="100" />
|
||||
<el-table-column label="账号类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.accountType" :type="row.accountType === 'MAIN' ? 'success' : 'warning'" disable-transitions>
|
||||
{{ row.accountType === 'MAIN' ? '主账号' : '子账号' }}
|
||||
</el-tag>
|
||||
<span v-else style="color:#909399">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '0' ? 'success' : 'danger'" disable-transitions>
|
||||
{{ row.status === '0' ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<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" @click="onEdit(row)">编辑</el-button>
|
||||
<el-button link :type="row.status === '0' ? 'warning' : 'success'" @click="onToggleStatus(row)">
|
||||
{{ row.status === '0' ? '禁用' : '恢复' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="onRemoveOne(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
style="margin-top: 12px; text-align: right;"
|
||||
/>
|
||||
|
||||
<!-- 批量导入 dialog (后端 ExcelUtil 自动生成中文列头 .xlsx) -->
|
||||
<el-dialog v-model="importVisible" title="批量导入 (Excel)" width="520px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="选择文件">
|
||||
<div style="display:flex; gap:8px; align-items:center">
|
||||
<el-input :model-value="importFileName" placeholder="未选择文件" readonly style="flex:1" />
|
||||
<input ref="fileInputEl" type="file" accept=".xls,.xlsx" style="display:none" @change="onFileChange" />
|
||||
<el-button @click="fileInputEl?.click()">浏览</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="uploadFile">上传</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<span class="hint-link" @click="downloadTemplate">批量导入模板下载 (.xlsx)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 导入结果 dialog (用 table 展示每行成功/失败明细) -->
|
||||
<el-dialog v-model="resultVisible" :title="`导入结果 (成功 ${resultOk}/${resultTotal})`" width="780px">
|
||||
<el-table :data="resultRows" stripe border max-height="480">
|
||||
<el-table-column prop="rowNo" label="行号" width="80" align="center" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.ok ? 'success' : 'danger'" disable-transitions size="small">
|
||||
{{ row.ok ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="错误信息" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="resultVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bizUpdate, bizDelete } from '@/api/public'
|
||||
import { listSponsorPerson } from '@/api/business/person'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// sponsor 端只看自己团队 (主账号+子账号) 创建的人, 走专属接口 /business/person/sponsorList (后端强制隔离)
|
||||
const q = reactive({
|
||||
name: '', phone: '', orgName: '', department: '', status: ''
|
||||
})
|
||||
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const selected = ref([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listSponsorPerson({ ...q, pageNum: page.pageNum, pageSize: page.pageSize })
|
||||
rows.value = res.rows || []
|
||||
page.total = res.total || 0
|
||||
} catch (e) {
|
||||
rows.value = []; page.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.name = ''
|
||||
q.phone = ''
|
||||
q.orgName = ''
|
||||
q.department = ''
|
||||
q.status = ''
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelectionChange(arr) { selected.value = arr }
|
||||
|
||||
function onCreate() {
|
||||
router.push({ path: '/sponsor/people/new' })
|
||||
}
|
||||
function onView(row) {
|
||||
router.push({ path: `/sponsor/people/detail/${row.personId}` })
|
||||
}
|
||||
function onEdit(row) {
|
||||
router.push({ path: `/sponsor/people/edit/${row.personId}` })
|
||||
}
|
||||
|
||||
async function onToggleStatus(row) {
|
||||
const newStatus = row.status === '0' ? '1' : '0'
|
||||
const label = newStatus === '0' ? '恢复' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要${label}「${row.name}」吗?`, '确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await bizUpdate('person', { personId: row.personId, status: newStatus })
|
||||
ElMessage.success('已' + label)
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveOne(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.name}」? 该操作不可恢复`, '删除确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await bizDelete('person', row.personId)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchRemove() {
|
||||
if (!selected.value.length) { ElMessage.warning('请先勾选要删除的人员'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 条? 该操作不可恢复`, '批量删除', { type: 'warning' })
|
||||
} catch { return }
|
||||
let ok = 0
|
||||
for (const r of selected.value) {
|
||||
try { await bizDelete('person', r.personId); ok++ } catch {}
|
||||
}
|
||||
ElMessage.success(`已删除 ${ok} 条`)
|
||||
selected.value = []
|
||||
load()
|
||||
}
|
||||
|
||||
// 批量导入 (Excel)
|
||||
const importVisible = ref(false)
|
||||
const importFileName = ref('')
|
||||
const fileInputEl = ref(null)
|
||||
const importing = ref(false)
|
||||
// 导入结果 (dialog + table)
|
||||
const resultVisible = ref(false)
|
||||
const resultRows = ref([])
|
||||
const resultOk = ref(0)
|
||||
const resultTotal = ref(0)
|
||||
function onImport() {
|
||||
importFileName.value = ''
|
||||
importVisible.value = true
|
||||
}
|
||||
function onFileChange(e) {
|
||||
importFileName.value = e.target.files?.[0]?.name || ''
|
||||
}
|
||||
async function downloadTemplate() {
|
||||
// 后端 ExcelUtil<BizPersonImportVO>.importTemplateExcel 自动生成中文列头 .xlsx
|
||||
const res = await request.get('/business/person/sponsorImportTemplate', { responseType: 'blob' })
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
// 优先用后端 Content-Disposition 的 filename* (utf-8) 字段
|
||||
const dispo = res.headers?.['content-disposition'] || res.headers?.['Content-Disposition'] || ''
|
||||
const m = /filename\*=UTF-8''([^;]+)/i.exec(dispo)
|
||||
a.download = m ? decodeURIComponent(m[1]) : 'sponsor人员批量导入模板.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('模板下载完成')
|
||||
}
|
||||
async function uploadFile() {
|
||||
const file = fileInputEl.value?.files?.[0]
|
||||
if (!file) { ElMessage.warning('请先选择文件'); return }
|
||||
if (!/\.(xlsx|xls)$/i.test(file.name)) { ElMessage.warning('只支持 .xlsx / .xls 文件'); return }
|
||||
importing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await request.post('/business/person/sponsorImport', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
const data = res.data || res
|
||||
resultOk.value = data.ok || 0
|
||||
resultTotal.value = data.total || 0
|
||||
resultRows.value = data.results || []
|
||||
resultVisible.value = true
|
||||
importVisible.value = false
|
||||
fileInputEl.value.value = ''
|
||||
importFileName.value = ''
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 1:1 抄 doctor/Submissions.vue */
|
||||
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
.hint-link { color: var(--brand-primary); cursor: pointer; font-size: 13px; }
|
||||
.hint-link:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
|
||||
: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, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
|
||||
:deep(.el-table .el-button) { border-radius: 4px; }
|
||||
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
|
||||
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); background: transparent; }
|
||||
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<!--
|
||||
我的项目 (sponsor 专属, 团队模式: 主账号+子账号关联的项目; 仅 sponsor 角色可见, 由 router meta.role='sponsor' + beforeEach 守卫拦截)
|
||||
容器: <div class="page-card"> (与 People.vue / NewPerson.vue 同款)
|
||||
面包屑: <div class="breadcrumb">首页 / 我的项目</div>
|
||||
1:1 抄原型: proto/html/components/sponsor-projects.html
|
||||
15 列: checkbox + 项目编号 + 项目名称 + 总场次/已执行/未执行 + 4 金额 + 开始/结束时间 + 是否结题 + 执行单位评价 + 操作
|
||||
Modal 1: 项目评价 (4 维度星级 1-5 + 备注) -> POST /business/project/rate [hmr fix]
|
||||
Modal 2: 项目分配 (监察员 chip + 说明 + 要点) -> POST /business/project/sponsorAssign
|
||||
-->
|
||||
<div class="page-card">
|
||||
<div class="breadcrumb">首页 / 我的项目</div>
|
||||
|
||||
<el-form inline :model="q" class="filter-form">
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<el-input v-model="q.projectName" placeholder="输入项目名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否结题">
|
||||
<el-select v-model="q.isFinished" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已结题" value="1" />
|
||||
<el-option label="未结题" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="onAssignBatch">项目分配</el-button>
|
||||
<el-button @click="onEvalBatch">批量评价</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="40" />
|
||||
<el-table-column label="项目编号" width="160">
|
||||
<template #default="{ row }">
|
||||
<a class="link">{{ row.projectNo }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目名称" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<a class="link-bold">{{ row.projectName }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalSessions" label="总场次" width="80" align="center" />
|
||||
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
|
||||
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
|
||||
<el-table-column prop="totalAmount" label="总金额" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="availableAmount" label="可用金额" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidLaborAmount" label="劳务费" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column prop="paidMeetingAmount" label="会务费" width="120" align="right" :formatter="fmtMoney" />
|
||||
<el-table-column label="项目开始时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ fmtDate(row.startTime) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目结束时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="mono">{{ fmtDate(row.endTime) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否结题" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.isFinished === '1' ? '已结题' : '未结题' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行单位评价" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<a v-if="row.ratingScore" class="link">{{ row.ratingScore }}</a>
|
||||
<span v-else style="color:#909399">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEval(row)">项目评价</el-button>
|
||||
<el-button link type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button link type="primary" @click="onView(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="page.pageNum"
|
||||
v-model:page-size="page.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="page.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<!-- ============ Modal 1: 项目评价 ============ -->
|
||||
<el-dialog v-model="evalVisible" title="执行单位评分" width="600px" append-to-body destroy-on-close>
|
||||
<div class="modal-project-info">
|
||||
<div><span class="lbl">项目编号</span><span>{{ evalForm.projectNo }}</span></div>
|
||||
<div><span class="lbl">项目名称</span><span>{{ evalForm.projectName }}</span></div>
|
||||
</div>
|
||||
<el-form :model="evalForm" label-width="120px" class="modal-form">
|
||||
<el-form-item label="履约质量">
|
||||
<el-rate v-model="evalForm.q1" :max="5" :colors="['#99A9BF','#F7BA2A','#FF9900']" show-text :texts="['1', '2', '3', '4', '5']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时效响应">
|
||||
<el-rate v-model="evalForm.q2" :max="5" :colors="['#99A9BF','#F7BA2A','#FF9900']" show-text :texts="['1', '2', '3', '4', '5']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配合度">
|
||||
<el-rate v-model="evalForm.q3" :max="5" :colors="['#99A9BF','#F7BA2A','#FF9900']" show-text :texts="['1', '2', '3', '4', '5']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="合规安全">
|
||||
<el-rate v-model="evalForm.q4" :max="5" :colors="['#99A9BF','#F7BA2A','#FF9900']" show-text :texts="['1', '2', '3', '4', '5']" />
|
||||
</el-form-item>
|
||||
<el-form-item label="评价备注">
|
||||
<el-input v-model="evalForm.remark" type="textarea" :rows="4" placeholder="请输入评价备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="evalVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEval">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ Modal 2: 项目分配 (单选 / 批量复用) ============ -->
|
||||
<el-dialog :title="assignBatchMode ? `批量项目分配 (${assignBatchRows.length} 个项目)` : '项目分配'" width="600px" append-to-body destroy-on-close>
|
||||
<!-- 单选模式: 显示项目编号 / 名称 -->
|
||||
<div v-if="!assignBatchMode" class="modal-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="modal-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="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-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="assignSubmitting" @click="saveAssign">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listSponsorProjects, rateProject, sponsorAssignProject, sponsorAssignBatch } from '@/api/business/project'
|
||||
import { listBizPerson } from '@/api/business/person'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const selected = ref([])
|
||||
const dateRange = ref([])
|
||||
|
||||
const q = reactive({
|
||||
projectNo: '',
|
||||
projectName: '',
|
||||
isFinished: ''
|
||||
})
|
||||
|
||||
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
|
||||
|
||||
function fmtMoney(_row, _col, val) {
|
||||
if (val === null || val === undefined || val === '') return '—'
|
||||
return Number(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function fmtDate(val) {
|
||||
if (!val) return '—'
|
||||
return String(val).substring(0, 10)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
pageNum: page.pageNum,
|
||||
pageSize: page.pageSize,
|
||||
projectNo: q.projectNo || undefined,
|
||||
projectName: q.projectName || undefined,
|
||||
isFinished: q.isFinished || undefined,
|
||||
startTime: dateRange.value?.[0] ? dateRange.value[0] + ' 00:00:00' : undefined,
|
||||
endTime: dateRange.value?.[1] ? dateRange.value[1] + ' 23:59:59' : undefined
|
||||
}
|
||||
const res = await listSponsorProjects(params)
|
||||
rows.value = res.rows || []
|
||||
page.total = res.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
q.projectNo = ''
|
||||
q.projectName = ''
|
||||
q.isFinished = ''
|
||||
dateRange.value = []
|
||||
page.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onSelectionChange(val) { selected.value = val }
|
||||
|
||||
function onView(row) {
|
||||
ElMessageBox.alert(`项目编号: ${row.projectNo}\n项目名称: ${row.projectName}`, '项目详情', { confirmButtonText: '确定' })
|
||||
}
|
||||
|
||||
// ========== Modal 1: 项目评价 ==========
|
||||
const evalVisible = ref(false)
|
||||
const evalForm = reactive({ projectId: null, projectNo: '', projectName: '', q1: 5, q2: 5, q3: 5, q4: 5, remark: '' })
|
||||
function openEval(row) {
|
||||
evalForm.projectId = row.projectId
|
||||
evalForm.projectNo = row.projectNo
|
||||
evalForm.projectName = row.projectName
|
||||
evalForm.q1 = row.sponsorQ1 || 5
|
||||
evalForm.q2 = row.sponsorQ2 || 5
|
||||
evalForm.q3 = row.sponsorQ3 || 5
|
||||
evalForm.q4 = row.sponsorQ4 || 5
|
||||
evalForm.remark = row.sponsorRemark || ''
|
||||
evalVisible.value = true
|
||||
}
|
||||
async function saveEval() {
|
||||
const avg = ((evalForm.q1 + evalForm.q2 + evalForm.q3 + evalForm.q4) / 4).toFixed(2)
|
||||
await rateProject({
|
||||
projectId: evalForm.projectId,
|
||||
sponsorRating: avg,
|
||||
sponsorQ1: evalForm.q1,
|
||||
sponsorQ2: evalForm.q2,
|
||||
sponsorQ3: evalForm.q3,
|
||||
sponsorQ4: evalForm.q4,
|
||||
sponsorRemark: evalForm.remark
|
||||
})
|
||||
ElMessage.success('评价已保存')
|
||||
evalVisible.value = false
|
||||
load()
|
||||
}
|
||||
function onEvalBatch() {
|
||||
if (!selected.value.length) return ElMessage.warning('请先选择项目')
|
||||
if (selected.value.length > 1) return ElMessage.warning('批量评价一次仅可评价一个项目')
|
||||
openEval(selected.value[0])
|
||||
}
|
||||
|
||||
// ========== Modal 2: 项目分配 (单选 / 批量) ==========
|
||||
const assignVisible = ref(false)
|
||||
const assignBatchMode = ref(false) // 批量模式开关
|
||||
const assignBatchRows = ref([]) // 批量模式下被分配的项目列表
|
||||
const assignSubmitting = ref(false)
|
||||
const assignForm = reactive({
|
||||
projectId: null, projectNo: '', projectName: '',
|
||||
monitorInput: '', monitorUserId: null, monitorUserName: '',
|
||||
assignDesc: '', assignPoints: ''
|
||||
})
|
||||
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: '监察员', 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
|
||||
}
|
||||
function openAssign(row) {
|
||||
assignBatchMode.value = false
|
||||
assignBatchRows.value = []
|
||||
assignForm.projectId = row.projectId
|
||||
assignForm.projectNo = row.projectNo
|
||||
assignForm.projectName = row.projectName
|
||||
assignForm.monitorInput = ''
|
||||
assignForm.monitorUserId = null
|
||||
assignForm.monitorUserName = ''
|
||||
assignForm.assignDesc = ''
|
||||
assignForm.assignPoints = ''
|
||||
assignVisible.value = true
|
||||
loadMonitors()
|
||||
}
|
||||
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 = ''
|
||||
assignVisible.value = true
|
||||
loadMonitors()
|
||||
}
|
||||
async function saveAssign() {
|
||||
if (!assignForm.monitorUserId) return ElMessage.warning('请选择监察员')
|
||||
if (!assignForm.assignDesc) return ElMessage.warning('请填写分配说明')
|
||||
assignSubmitting.value = true
|
||||
try {
|
||||
if (assignBatchMode.value) {
|
||||
// 批量模式: 循环逐条 POST (后端先删后插, 每个项目都会被覆盖)
|
||||
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
|
||||
})
|
||||
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 sponsorAssignProject({
|
||||
projectId: assignForm.projectId,
|
||||
monitorUserId: assignForm.monitorUserId,
|
||||
assignDesc: assignForm.assignDesc,
|
||||
assignPoints: assignForm.assignPoints
|
||||
})
|
||||
ElMessage.success('分配成功')
|
||||
}
|
||||
assignVisible.value = false
|
||||
load()
|
||||
} finally {
|
||||
assignSubmitting.value = false
|
||||
}
|
||||
}
|
||||
function onAssignBatch() {
|
||||
if (!selected.value.length) return ElMessage.warning('请先选择项目')
|
||||
// 单选走 openAssign, 多选走批量分配
|
||||
if (selected.value.length === 1) openAssign(selected.value[0])
|
||||
else openAssignBatch(selected.value)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-card { background: #fff; border: 1px solid #f0f0f0; border-radius: 6px; padding: 16px 20px; }
|
||||
.breadcrumb { color: #909399; font-size: 13px; margin-bottom: 12px; }
|
||||
.filter-form { margin-bottom: 12px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.link { color: var(--brand-primary, #1E3A8A); }
|
||||
.link-bold { color: #303133; font-weight: 600; }
|
||||
.mono { font-family: monospace; color: #606266; font-size: 12px; }
|
||||
.pagination { margin-top: 12px; justify-content: flex-end; display: flex; }
|
||||
|
||||
.modal-project-info { background: #f5f7fa; padding: 12px 16px; border-radius: 4px; margin-bottom: 12px; }
|
||||
.modal-project-info > div { display: flex; line-height: 22px; }
|
||||
.modal-project-info .lbl { color: #909399; width: 80px; }
|
||||
.modal-form .el-form-item { margin-bottom: 16px; }
|
||||
.hint-inline { color: #909399; font-size: 12px; margin-top: 4px; }
|
||||
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({ resolvers: [ElementPlusResolver()] }),
|
||||
Components({ resolvers: [ElementPlusResolver()] })
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, 'src') }
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/dev-api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user