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:
郭庆泰
2026-08-15 12:41:10 +08:00
commit cf5790229a
694 changed files with 102090 additions and 0 deletions
+12
View File
@@ -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>
+3068
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+21
View File
@@ -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' })
}
+19
View File
@@ -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
}
}
+32
View File
@@ -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 })
}
+54
View File
@@ -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' })
}
+93
View File
@@ -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)
// 业务 CRUDgeneric
// 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}`)
+22
View File
@@ -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 })
+7
View File
@@ -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; }
+116
View File
@@ -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()
}
+39
View File
@@ -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);
}
+152
View File
@@ -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>
+92
View File
@@ -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>
+140
View File
@@ -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>
+120
View File
@@ -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>
+72
View File
@@ -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" />
<!-- PDFiframe 预览浏览器内置 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>
+133
View File
@@ -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>
+13
View File
@@ -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>
+19
View File
@@ -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')
+115
View File
@@ -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
+38
View File
@@ -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
+72
View File
@@ -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
}
+46
View File
@@ -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
+65
View File
@@ -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,
}
+41
View File
@@ -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
}))
+113
View File
@@ -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>
+172
View File
@@ -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>
+460
View File
@@ -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>
+90
View File
@@ -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>
+262
View File
@@ -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>
+104
View File
@@ -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>
+90
View File
@@ -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>
+172
View File
@@ -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>
+182
View File
@@ -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>
+112
View File
@@ -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>
+689
View File
@@ -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 &nbsp;&nbsp; 京公网安备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>
+207
View File
@@ -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>
+199
View File
@@ -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>
+549
View File
@@ -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>
+177
View File
@@ -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>
+241
View File
@@ -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>
+106
View File
@@ -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>
+150
View File
@@ -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> &gt; 投稿详情
</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>
+280
View File
@@ -0,0 +1,280 @@
<template>
<div class="submission-new">
<!-- 面包屑 -->
<div class="breadcrumb">
<a @click="goBack">我的项目设计投稿</a> &gt; {{ 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>
+177
View File
@@ -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>
+76
View File
@@ -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>
+215
View File
@@ -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>
+197
View File
@@ -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>
+84
View File
@@ -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>
+198
View File
@@ -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>
+275
View File
@@ -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>
+80
View File
@@ -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>
+154
View File
@@ -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>
+287
View File
@@ -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>
+351
View File
@@ -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>
+100
View File
@@ -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>
+69
View File
@@ -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>
+446
View File
@@ -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>
+112
View File
@@ -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>
+319
View File
@@ -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>
+148
View File
@@ -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">公司管理 &gt; 公司查看 (赞助方 + 执行方)</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>
+312
View File
@@ -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>
+834
View File
@@ -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>
<!-- ============= 单行执行单位评分 modal4 维度) ============= -->
<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>
+504
View File
@@ -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>
+131
View File
@@ -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>
+859
View File
@@ -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 &nbsp;&nbsp; 京公网安备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>
+118
View File
@@ -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>
+713
View File
@@ -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 &nbsp;&nbsp; 京公网安备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
+106
View File
@@ -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>
+502
View File
@@ -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>
+156
View File
@@ -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>
+317
View File
@@ -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>
+261
View File
@@ -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> &gt; {{ 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>
+149
View File
@@ -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> &gt; 人员详情
</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>
+433
View File
@@ -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>
+308
View File
@@ -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>
+28
View File
@@ -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/, '')
}
}
}
})