first commit

This commit is contained in:
2026-02-28 15:02:08 +08:00
commit f657de1c0d
55 changed files with 15806 additions and 0 deletions

16
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,16 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
#app {
width: 100%;
height: 100vh;
}
</style>

View File

@@ -0,0 +1,49 @@
import request from './request'
// 获取考核列表
export function getAssessments(params) {
return request.get('/assessments', { params })
}
// 获取考核详情
export function getAssessment(id) {
return request.get(`/assessments/${id}`)
}
// 创建考核
export function createAssessment(data) {
return request.post('/assessments', data)
}
// 更新考核
export function updateAssessment(id, data) {
return request.put(`/assessments/${id}`, data)
}
// 提交考核
export function submitAssessment(id) {
return request.post(`/assessments/${id}/submit`)
}
// 审核考核
export function reviewAssessment(id, data) {
return request.post(`/assessments/${id}/review`, null, { params: data })
}
// 确认考核
export function finalizeAssessment(id) {
return request.post(`/assessments/${id}/finalize`)
}
// 批量创建考核
export function batchCreateAssessments(data) {
// FastAPI expects repeated query params: indicators=1&indicators=2&indicators=3
const params = new URLSearchParams()
params.append('department_id', data.department_id)
params.append('period_year', data.period_year)
params.append('period_month', data.period_month)
// Ensure indicators are numbers
data.indicators.forEach(id => params.append('indicators', Number(id)))
return request.post('/assessments/batch-create', null, { params })
}

21
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,21 @@
import request from './request'
// 登录
export function login(data) {
const params = new URLSearchParams()
params.append('username', data.username)
params.append('password', data.password)
return request.post('/auth/login', params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
// 获取当前用户
export function getCurrentUser() {
return request.get('/auth/me')
}
// 注册
export function register(data) {
return request.post('/auth/register', data)
}

View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取科室列表
export function getDepartments(params) {
return request.get('/departments', { params })
}
// 获取科室树
export function getDepartmentTree(params) {
return request.get('/departments/tree', { params })
}
// 获取科室详情
export function getDepartment(id) {
return request.get(`/departments/${id}`)
}
// 创建科室
export function createDepartment(data) {
return request.post('/departments', data)
}
// 更新科室
export function updateDepartment(id, data) {
return request.put(`/departments/${id}`, data)
}
// 删除科室
export function deleteDepartment(id) {
return request.delete(`/departments/${id}`)
}

View File

@@ -0,0 +1,51 @@
import request from './request'
// 获取科室收入
export function getRevenue(params) {
return request.get('/finance/revenue', { params })
}
// 获取科室支出
export function getExpense(params) {
return request.get('/finance/expense', { params })
}
// 获取收支结余
export function getBalance(params) {
return request.get('/finance/balance', { params })
}
// 按类别统计收入
export function getRevenueByCategory(params) {
return request.get('/finance/revenue/by-category', { params })
}
// 按类别统计支出
export function getExpenseByCategory(params) {
return request.get('/finance/expense/by-category', { params })
}
// 获取科室财务汇总
export function getDepartmentSummary(params) {
return request.get('/finance/summary', { params })
}
// 获取财务类别
export function getCategories() {
return request.get('/finance/categories')
}
// 创建财务记录
export function createFinanceRecord(data) {
return request.post('/finance', data)
}
// 更新财务记录
export function updateFinanceRecord(id, data) {
return request.put(`/finance/${id}`, data)
}
// 删除财务记录
export function deleteFinanceRecord(id) {
return request.delete(`/finance/${id}`)
}

View File

@@ -0,0 +1,8 @@
export * from './auth'
export * from './department'
export * from './staff'
export * from './indicator'
export * from './assessment'
export * from './salary'
export * from './stats'
export * from './template'

View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取指标列表
export function getIndicators(params) {
return request.get('/indicators', { params })
}
// 获取启用的指标
export function getActiveIndicators() {
return request.get('/indicators/active')
}
// 获取指标详情
export function getIndicator(id) {
return request.get(`/indicators/${id}`)
}
// 创建指标
export function createIndicator(data) {
return request.post('/indicators', data)
}
// 更新指标
export function updateIndicator(id, data) {
return request.put(`/indicators/${id}`, data)
}
// 删除指标
export function deleteIndicator(id) {
return request.delete(`/indicators/${id}`)
}

36
frontend/src/api/menu.js Normal file
View File

@@ -0,0 +1,36 @@
import request from './request'
// 获取菜单树
export function getMenuTree(params) {
return request.get('/menus/tree', { params })
}
// 获取菜单列表
export function getMenus(params) {
return request.get('/menus', { params })
}
// 获取菜单详情
export function getMenu(id) {
return request.get(`/menus/${id}`)
}
// 创建菜单
export function createMenu(data) {
return request.post('/menus', data)
}
// 更新菜单
export function updateMenu(id, data) {
return request.put(`/menus/${id}`, data)
}
// 删除菜单
export function deleteMenu(id) {
return request.delete(`/menus/${id}`)
}
// 初始化默认菜单
export function initDefaultMenus() {
return request.post('/menus/init')
}

View File

@@ -0,0 +1,66 @@
import request from './request'
// 获取绩效计划列表
export function getPerformancePlans(params) {
return request.get('/plans', { params })
}
// 获取绩效计划树
export function getPerformancePlanTree(params) {
return request.get('/plans/tree', { params })
}
// 获取绩效计划统计
export function getPerformancePlanStats(params) {
return request.get('/plans/stats', { params })
}
// 获取绩效计划详情
export function getPerformancePlan(id) {
return request.get(`/plans/${id}`)
}
// 创建绩效计划
export function createPerformancePlan(data) {
return request.post('/plans', data)
}
// 更新绩效计划
export function updatePerformancePlan(id, data) {
return request.put(`/plans/${id}`, data)
}
// 提交绩效计划
export function submitPerformancePlan(id) {
return request.post(`/plans/${id}/submit`)
}
// 审批绩效计划
export function approvePerformancePlan(id, params) {
return request.post(`/plans/${id}/approve`, null, { params })
}
// 激活绩效计划
export function activatePerformancePlan(id) {
return request.post(`/plans/${id}/activate`)
}
// 删除绩效计划
export function deletePerformancePlan(id) {
return request.delete(`/plans/${id}`)
}
// 添加计划指标关联
export function addKpiRelation(planId, data) {
return request.post(`/plans/${planId}/kpi-relations`, data)
}
// 更新计划指标关联
export function updateKpiRelation(relationId, data) {
return request.put(`/plans/kpi-relations/${relationId}`, data)
}
// 删除计划指标关联
export function deleteKpiRelation(relationId) {
return request.delete(`/plans/kpi-relations/${relationId}`)
}

View File

@@ -0,0 +1,65 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
// 创建axios实例
const request = axios.create({
baseURL: '/api/v1',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
response => {
const res = response.data
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
router.push('/login')
break
case 403:
ElMessage.error('没有权限访问')
break
case 404:
ElMessage.error('请求资源不存在')
break
case 500:
ElMessage.error('服务器错误')
break
default:
ElMessage.error(error.response.data?.detail || '请求失败')
}
} else {
ElMessage.error('网络错误,请检查网络连接')
}
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,41 @@
import request from './request'
// 获取工资列表
export function getSalaryRecords(params) {
return request.get('/salary', { params })
}
// 获取工资详情
export function getSalaryRecord(id) {
return request.get(`/salary/${id}`)
}
// 创建工资记录
export function createSalaryRecord(data) {
return request.post('/salary', data)
}
// 更新工资记录
export function updateSalaryRecord(id, data) {
return request.put(`/salary/${id}`, data)
}
// 根据考核生成工资
export function generateSalary(params) {
return request.post('/salary/generate', null, { params })
}
// 批量生成工资
export function batchGenerateSalary(params) {
return request.post('/salary/batch-generate', null, { params })
}
// 确认工资
export function confirmSalary(id) {
return request.post(`/salary/${id}/confirm`)
}
// 批量确认工资
export function batchConfirmSalary(params) {
return request.post('/salary/batch-confirm', null, { params })
}

31
frontend/src/api/staff.js Normal file
View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取员工列表
export function getStaffList(params) {
return request.get('/staff', { params })
}
// 获取员工详情
export function getStaff(id) {
return request.get(`/staff/${id}`)
}
// 创建员工
export function createStaff(data) {
return request.post('/staff', data)
}
// 更新员工
export function updateStaff(id, data) {
return request.put(`/staff/${id}`, data)
}
// 删除员工
export function deleteStaff(id) {
return request.delete(`/staff/${id}`)
}
// 获取科室员工
export function getDepartmentStaff(departmentId) {
return request.get(`/staff/department/${departmentId}`)
}

42
frontend/src/api/stats.js Normal file
View File

@@ -0,0 +1,42 @@
import request from './request'
// 获取科室统计
export function getDepartmentStats(params) {
return request.get('/stats/department', { params })
}
// 获取周期统计
export function getPeriodStats(params) {
return request.get('/stats/period', { params })
}
// 获取趋势数据
export function getTrendData(params) {
return request.get('/stats/trend', { params })
}
// 获取员工排名
export function getStaffRanking(params) {
return request.get('/stats/ranking', { params })
}
// 获取科室绩效排名
export function getDepartmentRanking(params) {
return request.get('/stats/department-ranking', { params })
}
// 获取收支趋势
export function getFinanceTrend(params) {
return request.get('/stats/finance-trend', { params })
}
// 获取关键指标仪表盘
export function getKpiGauges(params) {
return request.get('/stats/kpi-gauges', { params })
}
// 获取预警数据
export function getAlerts(params) {
return request.get('/stats/alerts', { params })
}

View File

@@ -0,0 +1,61 @@
import request from './request'
// 获取模板列表
export function getTemplates(params) {
return request.get('/templates', { params })
}
// 获取模板类型列表
export function getTemplateTypes() {
return request.get('/templates/types')
}
// 获取BSC维度列表
export function getDimensions() {
return request.get('/templates/dimensions')
}
// 获取模板详情
export function getTemplate(id) {
return request.get(`/templates/${id}`)
}
// 创建模板
export function createTemplate(data) {
return request.post('/templates', data)
}
// 更新模板
export function updateTemplate(id, data) {
return request.put(`/templates/${id}`, data)
}
// 删除模板
export function deleteTemplate(id) {
return request.delete(`/templates/${id}`)
}
// 获取模板指标列表
export function getTemplateIndicators(templateId) {
return request.get(`/templates/${templateId}/indicators`)
}
// 添加模板指标
export function addTemplateIndicator(templateId, data) {
return request.post(`/templates/${templateId}/indicators`, data)
}
// 更新模板指标
export function updateTemplateIndicator(templateId, indicatorId, data) {
return request.put(`/templates/${templateId}/indicators/${indicatorId}`, data)
}
// 移除模板指标
export function removeTemplateIndicator(templateId, indicatorId) {
return request.delete(`/templates/${templateId}/indicators/${indicatorId}`)
}
// 批量添加模板指标
export function batchAddTemplateIndicators(templateId, data) {
return request.post(`/templates/${templateId}/indicators/batch`, data)
}

View File

@@ -0,0 +1,185 @@
// 全局样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
}
// 主题变量
:root {
--primary-color: #409eff;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
--info-color: #909399;
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--border-color: #dcdfe6;
--bg-color: #f5f7fa;
}
// 布局相关
.app-container {
display: flex;
height: 100vh;
}
.app-aside {
width: 220px;
background: linear-gradient(180deg, #1d3557 0%, #457b9d 100%);
color: #fff;
transition: width 0.3s;
&.collapsed {
width: 64px;
}
}
.app-header {
height: 60px;
background: #fff;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.app-main {
flex: 1;
overflow: auto;
background: var(--bg-color);
padding: 20px;
}
// 卡片样式
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
// 表格样式优化
.el-table {
border-radius: 8px;
th {
background: #f5f7fa !important;
color: var(--text-primary);
font-weight: 600;
}
}
// 搜索栏样式
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 20px;
.el-input, .el-select {
width: 200px;
}
}
// 统计卡片
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px;
color: #fff;
&.success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
&.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-value {
font-size: 32px;
font-weight: 700;
margin: 8px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
}
// 状态标签
.status-tag {
&.draft {
background: #f4f4f5;
color: #909399;
}
&.submitted {
background: #ecf5ff;
color: #409eff;
}
&.reviewed {
background: #f0f9eb;
color: #67c23a;
}
&.finalized {
background: #fef0f0;
color: #f56c6c;
}
&.rejected {
background: #fdf6ec;
color: #e6a23c;
}
}
// 分数等级
.score-level {
padding: 4px 12px;
border-radius: 4px;
font-weight: 500;
&.excellent {
background: #f0f9eb;
color: #67c23a;
}
&.good {
background: #ecf5ff;
color: #409eff;
}
&.average {
background: #fdf6ec;
color: #e6a23c;
}
&.poor {
background: #fef0f0;
color: #f56c6c;
}
}
// 图表容器
.chart-container {
width: 100%;
height: 350px;
}

23
frontend/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
import './assets/main.scss'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,115 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '工作台', icon: 'HomeFilled' }
},
{
path: 'departments',
name: 'Departments',
component: () => import('@/views/basic/Departments.vue'),
meta: { title: '科室管理', icon: 'OfficeBuilding' }
},
{
path: 'staff',
name: 'Staff',
component: () => import('@/views/basic/Staff.vue'),
meta: { title: '员工管理', icon: 'User' }
},
{
path: 'indicators',
name: 'Indicators',
component: () => import('@/views/basic/Indicators.vue'),
meta: { title: '考核指标', icon: 'DataAnalysis' }
},
{
path: 'templates',
name: 'Templates',
component: () => import('@/views/basic/Templates.vue'),
meta: { title: '指标模板', icon: 'DocumentCopy' }
},
{
path: 'assessments',
name: 'Assessments',
component: () => import('@/views/assessment/Assessments.vue'),
meta: { title: '考核管理', icon: 'Document' }
},
{
path: 'assessments/:id',
name: 'AssessmentDetail',
component: () => import('@/views/assessment/AssessmentDetail.vue'),
meta: { title: '考核详情', hidden: true }
},
{
path: 'salary',
name: 'Salary',
component: () => import('@/views/salary/Salary.vue'),
meta: { title: '工资核算', icon: 'Money' }
},
{
path: 'reports',
name: 'Reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { title: '统计报表', icon: 'TrendCharts' }
},
{
path: 'finance',
name: 'Finance',
component: () => import('@/views/finance/Finance.vue'),
meta: { title: '经济核算', icon: 'Coin' }
},
{
path: 'plans',
name: 'Plans',
component: () => import('@/views/plan/Plans.vue'),
meta: { title: '绩效计划', icon: 'Setting' }
},
{
path: 'system',
name: 'System',
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
path: 'menus',
name: 'Menus',
component: () => import('@/views/system/Menus.vue'),
meta: { title: '菜单管理', icon: 'Menu' }
}
]
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
document.title = `${to.meta.title || '首页'} - 医院绩效考核系统`
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDepartmentTree } from '@/api/department'
export const useAppStore = defineStore('app', () => {
const collapsed = ref(false)
const departmentTree = ref([])
// 切换侧边栏
function toggleSidebar() {
collapsed.value = !collapsed.value
}
// 加载科室树
async function loadDepartmentTree() {
try {
const res = await getDepartmentTree()
departmentTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
return {
collapsed,
departmentTree,
toggleSidebar,
loadDepartmentTree
}
})

View File

@@ -0,0 +1,2 @@
export { useUserStore } from './user'
export { useAppStore } from './app'

View File

@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, getCurrentUser } from '@/api/auth'
import router from '@/router'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(null)
// 登录
async function login(username, password) {
try {
const res = await loginApi({ username, password })
token.value = res.access_token
localStorage.setItem('token', res.access_token)
return true
} catch (error) {
return false
}
}
// 获取用户信息
async function getUserInfo() {
try {
const res = await getCurrentUser()
userInfo.value = res.data
return res.data
} catch (error) {
return null
}
}
// 登出
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
router.push('/login')
}
return {
token,
userInfo,
login,
getUserInfo,
logout
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
<template>
<div class="app-container">
<!-- 侧边栏 -->
<aside class="app-aside" :class="{ collapsed: appStore.collapsed }">
<div class="logo">
<el-icon size="24"><FirstAidKit /></el-icon>
<span v-show="!appStore.collapsed">绩效考核系统</span>
</div>
<el-menu
:default-active="route.path"
:collapse="appStore.collapsed"
background-color="transparent"
text-color="#fff"
active-text-color="#a8dadc"
router
>
<template v-for="item in menuItems" :key="item.path">
<el-menu-item :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
</aside>
<!-- 主内容区 -->
<div class="app-wrapper">
<!-- 顶部栏 -->
<header class="app-header">
<div class="header-left">
<el-icon
class="collapse-btn"
size="20"
@click="appStore.toggleSidebar"
>
<component :is="appStore.collapsed ? 'Expand' : 'Fold'" />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown>
<span class="user-info">
<el-avatar :size="32" icon="User" />
<span class="username">管理员</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore, useUserStore } from '@/stores'
import { getMenuTree } from '@/api/menu'
const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()
const menuItems = ref([])
// 加载菜单
async function loadMenus() {
try {
const res = await getMenuTree()
// 将后端返回的菜单树转换为前端格式
const menus = res.data || []
menuItems.value = menus.map(menu => ({
path: menu.path,
title: menu.menu_name,
icon: menu.menu_icon || 'Document',
children: menu.children && menu.children.length > 0 ? menu.children.map(child => ({
path: child.path,
title: child.menu_name,
icon: child.menu_icon || 'Document'
})) : undefined
}))
} catch (error) {
console.error('加载菜单失败', error)
// 使用默认菜单作为后备
menuItems.value = [
{ path: '/dashboard', title: '工作台', icon: 'HomeFilled' },
{ path: '/departments', title: '科室管理', icon: 'OfficeBuilding' },
{ path: '/staff', title: '员工管理', icon: 'User' },
{ path: '/indicators', title: '考核指标', icon: 'DataAnalysis' },
{ path: '/assessments', title: '考核管理', icon: 'Document' },
{ path: '/plans', title: '绩效计划', icon: 'Setting' },
{ path: '/salary', title: '工资核算', icon: 'Money' },
{ path: '/finance', title: '经济核算', icon: 'Coin' },
{ path: '/reports', title: '统计报表', icon: 'TrendCharts' }
]
}
}
function handleLogout() {
userStore.logout()
}
onMounted(() => {
loadMenus()
})
</script>
<style scoped lang="scss">
.app-container {
display: flex;
height: 100vh;
}
.app-aside {
width: 220px;
background: linear-gradient(180deg, #1d3557 0%, #457b9d 100%);
color: #fff;
transition: width 0.3s;
display: flex;
flex-direction: column;
&.collapsed {
width: 64px;
.logo span {
display: none;
}
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.el-menu {
border: none;
flex: 1;
.el-menu-item {
margin: 4px 8px;
border-radius: 8px;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
&.is-active {
background: rgba(255, 255, 255, 0.2);
}
}
}
}
.app-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-header {
height: 60px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.collapse-btn {
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
}
}
.header-right {
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.username {
color: #606266;
}
}
}
}
.app-main {
flex: 1;
overflow: auto;
background: #f5f7fa;
padding: 20px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h1>医院绩效考核管理系统</h1>
<p>某县中医院</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="login-form">
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>提示默认账号 admin / admin123</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({
username: 'admin',
password: 'admin123'
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleLogin() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const success = await userStore.login(form.username, form.password)
if (success) {
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error('用户名或密码错误')
}
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-container {
width: 100%;
height: 100vh;
background: linear-gradient(135deg, #1d3557 0%, #457b9d 50%, #a8dadc 100%);
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 24px;
color: #1d3557;
margin-bottom: 8px;
}
p {
color: #666;
font-size: 14px;
}
}
.login-form {
.el-input {
--el-input-border-radius: 8px;
}
}
.login-btn {
width: 100%;
border-radius: 8px;
font-size: 16px;
height: 48px;
background: linear-gradient(135deg, #1d3557 0%, #457b9d 100%);
border: none;
&:hover {
opacity: 0.9;
}
}
.login-footer {
text-align: center;
margin-top: 20px;
p {
color: #999;
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="assessment-detail">
<el-page-header @back="goBack" title="返回">
<template #content>
<span class="title">考核详情</span>
</template>
</el-page-header>
<div class="content" v-loading="loading">
<!-- 基本信息 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<span>基本信息</span>
<el-tag :type="getStatusType(assessment.status)">{{ getStatusLabel(assessment.status) }}</el-tag>
</div>
</template>
<el-descriptions :column="4" border>
<el-descriptions-item label="姓名">{{ assessment.staff_name }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ assessment.department_name }}</el-descriptions-item>
<el-descriptions-item label="考核周期">{{ assessment.period_year }}{{ assessment.period_month }}</el-descriptions-item>
<el-descriptions-item label="总分">
<span class="score">{{ assessment.weighted_score?.toFixed(1) }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 考核明细 -->
<el-card class="detail-card">
<template #header>
<div class="card-header">
<span>考核明细</span>
<el-button v-if="assessment.status === 'draft'" type="primary" size="small" @click="handleSave">保存</el-button>
</div>
</template>
<el-table :data="assessment.details" border>
<el-table-column prop="indicator_name" label="指标名称" />
<el-table-column label="指标类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ getTypeLabel(row.indicator_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="weight" label="权重" width="80" align="center" />
<el-table-column prop="max_score" label="最高分" width="80" align="center" />
<el-table-column label="实际值" width="120">
<template #default="{ row }">
<el-input-number
v-if="assessment.status === 'draft'"
v-model="row.actual_value"
size="small"
:precision="2"
controls-position="right"
/>
<span v-else>{{ row.actual_value }}</span>
</template>
</el-table-column>
<el-table-column label="得分" width="120">
<template #default="{ row }">
<el-input-number
v-if="assessment.status === 'draft'"
v-model="row.score"
size="small"
:min="0"
:max="row.max_score"
:precision="1"
controls-position="right"
/>
<span v-else :class="getScoreClass(row.score)">{{ row.score }}</span>
</template>
</el-table-column>
<el-table-column label="佐证材料" min-width="200">
<template #default="{ row }">
<el-input
v-if="assessment.status === 'draft'"
v-model="row.evidence"
size="small"
placeholder="请输入佐证材料"
/>
<span v-else>{{ row.evidence || '-' }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 操作按钮 -->
<div class="actions" v-if="assessment.status !== 'finalized'">
<el-button v-if="assessment.status === 'draft'" type="primary" @click="handleSubmit">提交审核</el-button>
<template v-if="assessment.status === 'submitted'">
<el-button type="success" @click="handleReview(true)">审核通过</el-button>
<el-button type="danger" @click="handleReview(false)">驳回</el-button>
</template>
<el-button v-if="assessment.status === 'reviewed'" type="success" @click="handleFinalize">确认考核</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAssessment, updateAssessment, submitAssessment, reviewAssessment, finalizeAssessment } from '@/api/assessment'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const assessment = ref({
details: []
})
const statusMap = {
draft: { label: '草稿', type: 'info' },
submitted: { label: '已提交', type: 'warning' },
reviewed: { label: '已审核', type: 'success' },
finalized: { label: '已确认', type: 'primary' },
rejected: { label: '已驳回', type: 'danger' }
}
const typeMap = {
quality: '质量指标',
quantity: '数量指标',
efficiency: '效率指标',
service: '服务指标',
cost: '成本指标'
}
function getStatusLabel(status) {
return statusMap[status]?.label || status
}
function getStatusType(status) {
return statusMap[status]?.type || ''
}
function getTypeLabel(type) {
return typeMap[type] || type
}
function getScoreClass(score) {
if (score >= 90) return 'score-level excellent'
if (score >= 80) return 'score-level good'
if (score >= 60) return 'score-level average'
return 'score-level poor'
}
function goBack() {
router.back()
}
async function loadData() {
loading.value = true
try {
const res = await getAssessment(route.params.id)
assessment.value = res.data || { details: [] }
} finally {
loading.value = false
}
}
async function handleSave() {
try {
await updateAssessment(assessment.value.id, {
details: assessment.value.details.map(d => ({
indicator_id: d.indicator_id,
actual_value: d.actual_value,
score: d.score,
evidence: d.evidence
}))
})
ElMessage.success('保存成功')
loadData()
} catch (error) {
console.error('保存失败', error)
}
}
async function handleSubmit() {
try {
await updateAssessment(assessment.value.id, {
details: assessment.value.details.map(d => ({
indicator_id: d.indicator_id,
actual_value: d.actual_value,
score: d.score,
evidence: d.evidence
}))
})
await submitAssessment(assessment.value.id)
ElMessage.success('提交成功')
loadData()
} catch (error) {
console.error('提交失败', error)
}
}
async function handleReview(approved) {
try {
await reviewAssessment(assessment.value.id, { approved, remark: '' })
ElMessage.success(approved ? '审核通过' : '已驳回')
loadData()
} catch (error) {
console.error('审核失败', error)
}
}
async function handleFinalize() {
try {
await finalizeAssessment(assessment.value.id)
ElMessage.success('确认成功')
loadData()
} catch (error) {
console.error('确认失败', error)
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.assessment-detail {
.title {
font-size: 18px;
font-weight: 600;
}
.content {
margin-top: 20px;
}
.info-card, .detail-card {
margin-bottom: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.score {
font-size: 24px;
font-weight: 700;
color: #409eff;
}
.actions {
display: flex;
justify-content: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: 8px;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-tree-select
v-model="searchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="所属科室"
clearable
check-strictly
/>
<el-date-picker
v-model="searchForm.period"
type="month"
placeholder="考核周期"
format="YYYY年MM月"
value-format="YYYY-MM"
/>
<el-select v-model="searchForm.status" placeholder="状态" clearable>
<el-option label="草稿" value="draft" />
<el-option label="已提交" value="submitted" />
<el-option label="已审核" value="reviewed" />
<el-option label="已确认" value="finalized" />
<el-option label="已驳回" value="rejected" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleBatchCreate">批量创建</el-button>
</div>
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="staff_name" label="姓名" width="100" />
<el-table-column prop="department_name" label="科室" />
<el-table-column label="考核周期" width="120">
<template #default="{ row }">
{{ row.period_year }}{{ row.period_month }}
</template>
</el-table-column>
<el-table-column prop="total_score" label="总分" width="100" align="center" />
<el-table-column prop="weighted_score" label="加权得分" width="100" align="center">
<template #default="{ row }">
<span :class="getScoreClass(row.weighted_score)">{{ row.weighted_score?.toFixed(1) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">详情</el-button>
<el-button v-if="row.status === 'draft'" type="warning" link @click="handleSubmit(row)">提交</el-button>
<el-button v-if="row.status === 'submitted'" type="success" link @click="handleReview(row, true)">通过</el-button>
<el-button v-if="row.status === 'submitted'" type="danger" link @click="handleReview(row, false)">驳回</el-button>
<el-button v-if="row.status === 'reviewed'" type="success" link @click="handleFinalize(row)">确认</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 批量创建弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量创建考核" width="500px">
<el-form :model="batchForm" label-width="100px">
<el-form-item label="科室">
<el-tree-select
v-model="batchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
/>
</el-form-item>
<el-form-item label="考核周期">
<el-date-picker
v-model="batchForm.period"
type="month"
placeholder="选择考核周期"
format="YYYY年MM月"
value-format="YYYY-MM"
/>
</el-form-item>
<el-form-item label="考核指标">
<el-select v-model="batchForm.indicators" multiple placeholder="请选择指标" style="width: 100%">
<el-option
v-for="item in activeIndicators"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" @click="doBatchCreate" :loading="batchLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAssessments, submitAssessment, reviewAssessment, finalizeAssessment, batchCreateAssessments } from '@/api/assessment'
import { getDepartmentTree } from '@/api/department'
import { getActiveIndicators } from '@/api/indicator'
const router = useRouter()
const loading = ref(false)
const batchLoading = ref(false)
const tableData = ref([])
const deptTree = ref([])
const activeIndicators = ref([])
const batchDialogVisible = ref(false)
const searchForm = reactive({
department_id: null,
period: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const batchForm = reactive({
department_id: null,
period: '',
indicators: []
})
const statusMap = {
draft: { label: '草稿', type: 'info' },
submitted: { label: '已提交', type: 'warning' },
reviewed: { label: '已审核', type: 'success' },
finalized: { label: '已确认', type: 'primary' },
rejected: { label: '已驳回', type: 'danger' }
}
function getStatusLabel(status) {
return statusMap[status]?.label || status
}
function getStatusType(status) {
return statusMap[status]?.type || ''
}
function getScoreClass(score) {
if (score >= 90) return 'score-level excellent'
if (score >= 80) return 'score-level good'
if (score >= 60) return 'score-level average'
return 'score-level poor'
}
function formatDate(date) {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
async function loadData() {
loading.value = true
try {
const [year, month] = searchForm.period ? searchForm.period.split('-') : ['', '']
const res = await getAssessments({
department_id: searchForm.department_id,
period_year: year ? parseInt(year) : undefined,
period_month: month ? parseInt(month) : undefined,
status: searchForm.status,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
async function loadActiveIndicators() {
try {
const res = await getActiveIndicators()
activeIndicators.value = res.data || []
} catch (error) {
console.error('加载指标失败', error)
}
}
function resetSearch() {
searchForm.department_id = null
searchForm.period = ''
searchForm.status = ''
pagination.page = 1
loadData()
}
function handleView(row) {
router.push(`/assessments/${row.id}`)
}
async function handleSubmit(row) {
try {
await submitAssessment(row.id)
ElMessage.success('提交成功')
loadData()
} catch (error) {
console.error('提交失败', error)
}
}
async function handleReview(row, approved) {
try {
await reviewAssessment(row.id, { approved, remark: '' })
ElMessage.success(approved ? '审核通过' : '已驳回')
loadData()
} catch (error) {
console.error('审核失败', error)
}
}
async function handleFinalize(row) {
try {
await finalizeAssessment(row.id)
ElMessage.success('确认成功')
loadData()
} catch (error) {
console.error('确认失败', error)
}
}
function handleBatchCreate() {
batchForm.department_id = null
batchForm.period = ''
batchForm.indicators = []
batchDialogVisible.value = true
}
async function doBatchCreate() {
if (!batchForm.department_id || !batchForm.period || batchForm.indicators.length === 0) {
ElMessage.warning('请填写完整信息')
return
}
const [year, month] = batchForm.period.split('-')
batchLoading.value = true
try {
await batchCreateAssessments({
department_id: batchForm.department_id,
period_year: parseInt(year),
period_month: parseInt(month),
indicators: batchForm.indicators
})
ElMessage.success('批量创建成功')
batchDialogVisible.value = false
loadData()
} finally {
batchLoading.value = false
}
}
onMounted(() => {
loadData()
loadDeptTree()
loadActiveIndicators()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select, .el-tree-select, .el-date-picker {
width: 160px;
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="科室名称/编码" clearable />
<el-select v-model="searchForm.dept_type" placeholder="科室类型" clearable>
<el-option label="临床科室" value="clinical" />
<el-option label="医技科室" value="medical_tech" />
<el-option label="医辅科室" value="medical_auxiliary" />
<el-option label="行政科室" value="admin" />
<el-option label="后勤科室" value="logistics" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAdd">新增科室</el-button>
</div>
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="code" label="科室编码" width="120" />
<el-table-column prop="name" label="科室名称" />
<el-table-column prop="dept_type" label="科室类型" width="120">
<template #default="{ row }">
<el-tag :type="getDeptTypeTag(row.dept_type)">
{{ getDeptTypeLabel(row.dept_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="level" label="层级" width="80" align="center" />
<el-table-column prop="is_active" label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.is_active" @change="handleStatusChange(row)" />
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="科室编码" prop="code">
<el-input v-model="form.code" placeholder="请输入科室编码" />
</el-form-item>
<el-form-item label="科室名称" prop="name">
<el-input v-model="form.name" placeholder="请输入科室名称" />
</el-form-item>
<el-form-item label="科室类型" prop="dept_type">
<el-select v-model="form.dept_type" placeholder="请选择科室类型">
<el-option label="手术临床科室" value="clinical_surgical" />
<el-option label="非手术有病房科室" value="clinical_nonsurgical_ward" />
<el-option label="非手术无病房科室" value="clinical_nonsurgical_noward" />
<el-option label="医技科室" value="medical_tech" />
<el-option label="医辅科室" value="medical_auxiliary" />
<el-option label="护理单元" value="nursing" />
<el-option label="行政科室" value="admin" />
<el-option label="财务科室" value="finance" />
<el-option label="后勤保障科室" value="logistics" />
</el-select>
</el-form-item>
<el-form-item label="上级科室" prop="parent_id">
<el-tree-select
v-model="form.parent_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择上级科室"
clearable
check-strictly
/>
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="3" placeholder="请输入描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDepartments, createDepartment, updateDepartment, deleteDepartment, getDepartmentTree } from '@/api/department'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const deptTree = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增科室')
const formRef = ref()
const searchForm = reactive({
keyword: '',
dept_type: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const form = reactive({
id: null,
code: '',
name: '',
dept_type: '',
parent_id: null,
sort_order: 0,
description: ''
})
const rules = {
code: [{ required: true, message: '请输入科室编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }],
dept_type: [{ required: true, message: '请选择科室类型', trigger: 'change' }]
}
const deptTypeMap = {
clinical_surgical: { label: '手术临床科室', type: 'primary' },
clinical_nonsurgical_ward: { label: '非手术有病房科室', type: 'primary' },
clinical_nonsurgical_noward: { label: '非手术无病房科室', type: 'primary' },
medical_tech: { label: '医技科室', type: 'success' },
medical_auxiliary: { label: '医辅科室', type: 'info' },
nursing: { label: '护理单元', type: 'success' },
admin: { label: '行政科室', type: 'warning' },
finance: { label: '财务科室', type: 'warning' },
logistics: { label: '后勤保障科室', type: 'danger' }
}
function getDeptTypeLabel(type) {
return deptTypeMap[type]?.label || type
}
function getDeptTypeTag(type) {
return deptTypeMap[type]?.type || ''
}
function formatDate(date) {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
async function loadData() {
loading.value = true
try {
const res = await getDepartments({
...searchForm,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
function resetSearch() {
searchForm.keyword = ''
searchForm.dept_type = ''
pagination.page = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新增科室'
Object.assign(form, {
id: null,
code: '',
name: '',
dept_type: '',
parent_id: null,
sort_order: 0,
description: ''
})
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑科室'
Object.assign(form, {
id: row.id,
code: row.code,
name: row.name,
dept_type: row.dept_type,
parent_id: row.parent_id,
sort_order: row.sort_order,
description: row.description
})
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定要删除该科室吗?', '提示', { type: 'warning' })
try {
await deleteDepartment(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败', error)
}
}
async function handleStatusChange(row) {
try {
await updateDepartment(row.id, { is_active: row.is_active })
ElMessage.success('状态更新成功')
} catch (error) {
row.is_active = !row.is_active
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (form.id) {
await updateDepartment(form.id, form)
ElMessage.success('更新成功')
} else {
await createDepartment(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
loadDeptTree()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadData()
loadDeptTree()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select {
width: 180px;
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="指标名称/编码" clearable />
<el-select v-model="searchForm.indicator_type" placeholder="指标类型" clearable>
<el-option label="质量指标" value="quality" />
<el-option label="数量指标" value="quantity" />
<el-option label="效率指标" value="efficiency" />
<el-option label="服务指标" value="service" />
<el-option label="成本指标" value="cost" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAdd">新增指标</el-button>
</div>
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="code" label="指标编码" width="120" />
<el-table-column prop="name" label="指标名称" />
<el-table-column prop="indicator_type" label="指标类型" width="120">
<template #default="{ row }">
<el-tag :type="getTypeTag(row.indicator_type)">
{{ getTypeLabel(row.indicator_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="weight" label="权重" width="100" align="center" />
<el-table-column prop="max_score" label="最高分值" width="100" align="center" />
<el-table-column prop="target_value" label="目标值" width="100" align="center" />
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="is_active" label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.is_active" @change="handleStatusChange(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指标编码" prop="code">
<el-input v-model="form.code" placeholder="请输入指标编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="指标名称" prop="name">
<el-input v-model="form.name" placeholder="请输入指标名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指标类型" prop="indicator_type">
<el-select v-model="form.indicator_type" placeholder="请选择指标类型">
<el-option label="质量指标" value="quality" />
<el-option label="数量指标" value="quantity" />
<el-option label="效率指标" value="efficiency" />
<el-option label="服务指标" value="service" />
<el-option label="成本指标" value="cost" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="权重" prop="weight">
<el-input-number v-model="form.weight" :min="0.1" :max="10" :precision="1" :step="0.1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="最高分值" prop="max_score">
<el-input-number v-model="form.max_score" :min="0" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标值" prop="target_value">
<el-input-number v-model="form.target_value" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder="如:人次、%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="计算方法" prop="calculation_method">
<el-input v-model="form.calculation_method" type="textarea" rows="3" placeholder="请输入计算方法" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="2" placeholder="请输入描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getIndicators, createIndicator, updateIndicator, deleteIndicator } from '@/api/indicator'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增指标')
const formRef = ref()
const searchForm = reactive({
keyword: '',
indicator_type: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const form = reactive({
id: null,
code: '',
name: '',
indicator_type: '',
weight: 1.0,
max_score: 100,
target_value: null,
unit: '',
calculation_method: '',
description: ''
})
const rules = {
code: [{ required: true, message: '请输入指标编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入指标名称', trigger: 'blur' }],
indicator_type: [{ required: true, message: '请选择指标类型', trigger: 'change' }]
}
const typeMap = {
quality: { label: '质量指标', type: 'primary' },
quantity: { label: '数量指标', type: 'success' },
efficiency: { label: '效率指标', type: 'warning' },
service: { label: '服务指标', type: 'info' },
cost: { label: '成本指标', type: 'danger' }
}
function getTypeLabel(type) {
return typeMap[type]?.label || type
}
function getTypeTag(type) {
return typeMap[type]?.type || ''
}
async function loadData() {
loading.value = true
try {
const res = await getIndicators({
...searchForm,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
function resetSearch() {
searchForm.keyword = ''
searchForm.indicator_type = ''
pagination.page = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新增指标'
Object.assign(form, {
id: null,
code: '',
name: '',
indicator_type: '',
weight: 1.0,
max_score: 100,
target_value: null,
unit: '',
calculation_method: '',
description: ''
})
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑指标'
Object.assign(form, {
id: row.id,
code: row.code,
name: row.name,
indicator_type: row.indicator_type,
weight: row.weight,
max_score: row.max_score,
target_value: row.target_value,
unit: row.unit,
calculation_method: row.calculation_method,
description: row.description
})
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定要删除该指标吗?', '提示', { type: 'warning' })
try {
await deleteIndicator(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败', error)
}
}
async function handleStatusChange(row) {
try {
await updateIndicator(row.id, { is_active: row.is_active })
ElMessage.success('状态更新成功')
} catch (error) {
row.is_active = !row.is_active
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (form.id) {
await updateIndicator(form.id, form)
ElMessage.success('更新成功')
} else {
await createIndicator(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select {
width: 180px;
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="姓名/工号" clearable />
<el-tree-select
v-model="searchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="所属科室"
clearable
check-strictly
/>
<el-select v-model="searchForm.status" placeholder="状态" clearable>
<el-option label="在职" value="active" />
<el-option label="休假" value="leave" />
<el-option label="离职" value="resigned" />
<el-option label="退休" value="retired" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAdd">新增员工</el-button>
</div>
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="employee_id" label="工号" width="120" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="department_name" label="所属科室" />
<el-table-column prop="position" label="职位" width="120" />
<el-table-column prop="title" label="职称" width="120" />
<el-table-column prop="base_salary" label="基本工资" width="120" align="right">
<template #default="{ row }">
¥{{ row.base_salary?.toLocaleString() || 0 }}
</template>
</el-table-column>
<el-table-column prop="performance_ratio" label="绩效系数" width="100" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="工号" prop="employee_id">
<el-input v-model="form.employee_id" placeholder="请输入工号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属科室" prop="department_id">
<el-tree-select
v-model="form.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职位" prop="position">
<el-input v-model="form.position" placeholder="请输入职位" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="职称" prop="title">
<el-input v-model="form.title" placeholder="请输入职称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="基本工资" prop="base_salary">
<el-input-number v-model="form.base_salary" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="绩效系数" prop="performance_ratio">
<el-input-number v-model="form.performance_ratio" :min="0" :max="5" :precision="2" :step="0.1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio label="active">在职</el-radio>
<el-radio label="leave">休假</el-radio>
<el-radio label="resigned">离职</el-radio>
<el-radio label="retired">退休</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getStaffList, createStaff, updateStaff, deleteStaff } from '@/api/staff'
import { getDepartmentTree } from '@/api/department'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const deptTree = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增员工')
const formRef = ref()
const searchForm = reactive({
keyword: '',
department_id: null,
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const form = reactive({
id: null,
employee_id: '',
name: '',
department_id: null,
position: '',
title: '',
phone: '',
base_salary: 0,
performance_ratio: 1.0,
status: 'active'
})
const rules = {
employee_id: [{ required: true, message: '请输入工号', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
department_id: [{ required: true, message: '请选择科室', trigger: 'change' }],
position: [{ required: true, message: '请输入职位', trigger: 'blur' }]
}
const statusMap = {
active: { label: '在职', type: 'success' },
leave: { label: '休假', type: 'warning' },
resigned: { label: '离职', type: 'danger' },
retired: { label: '退休', type: 'info' }
}
function getStatusLabel(status) {
return statusMap[status]?.label || status
}
function getStatusType(status) {
return statusMap[status]?.type || ''
}
async function loadData() {
loading.value = true
try {
const res = await getStaffList({
...searchForm,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
function resetSearch() {
searchForm.keyword = ''
searchForm.department_id = null
searchForm.status = ''
pagination.page = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新增员工'
Object.assign(form, {
id: null,
employee_id: '',
name: '',
department_id: null,
position: '',
title: '',
phone: '',
base_salary: 0,
performance_ratio: 1.0,
status: 'active'
})
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑员工'
Object.assign(form, {
id: row.id,
employee_id: row.employee_id,
name: row.name,
department_id: row.department_id,
position: row.position,
title: row.title,
phone: row.phone,
base_salary: row.base_salary,
performance_ratio: row.performance_ratio,
status: row.status
})
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定要删除该员工吗?', '提示', { type: 'warning' })
try {
await deleteStaff(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败', error)
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (form.id) {
await updateStaff(form.id, form)
ElMessage.success('更新成功')
} else {
await createStaff(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadData()
loadDeptTree()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select, .el-tree-select {
width: 160px;
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,637 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="模板名称/编码" clearable style="width: 200px" />
<el-select v-model="searchForm.template_type" placeholder="模板类型" clearable style="width: 180px">
<el-option v-for="t in templateTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAdd">新增模板</el-button>
</div>
<el-row :gutter="20">
<!-- 模板列表 -->
<el-col :span="10">
<el-table :data="tableData" stripe v-loading="loading" highlight-current-row @current-change="handleSelectTemplate">
<el-table-column prop="template_code" label="编码" width="100" />
<el-table-column prop="template_name" label="模板名称" />
<el-table-column prop="template_type" label="类型" width="120">
<template #default="{ row }">
<el-tag>{{ getTypeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="indicator_count" label="指标数" width="80" align="center" />
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch v-model="row.is_active" size="small" @change="handleStatusChange(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@size-change="loadData"
@current-change="loadData"
/>
</el-col>
<!-- 模板详情/指标列表 -->
<el-col :span="14">
<el-card v-if="currentTemplate" class="detail-card">
<template #header>
<div class="card-header">
<span>{{ currentTemplate.template_name }}</span>
<div>
<el-button type="primary" size="small" @click="handleEditTemplate">编辑</el-button>
<el-button type="success" size="small" @click="handleAddIndicator">添加指标</el-button>
</div>
</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="模板编码">{{ currentTemplate.template_code }}</el-descriptions-item>
<el-descriptions-item label="模板类型">{{ getTypeLabel(currentTemplate.template_type) }}</el-descriptions-item>
<el-descriptions-item label="考核周期">{{ currentTemplate.assessment_cycle === 'monthly' ? '月度' : '年度' }}</el-descriptions-item>
<el-descriptions-item label="指标数量">{{ currentTemplate.indicators?.length || 0 }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ currentTemplate.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 维度权重展示 -->
<div v-if="dimensionWeights" class="dimension-weights">
<h4>维度权重</h4>
<el-row :gutter="10">
<el-col :span="6" v-for="(weight, dim) in dimensionWeights" :key="dim">
<div class="weight-item">
<span class="label">{{ getDimensionLabel(dim) }}</span>
<el-progress :percentage="weight" :stroke-width="10" />
</div>
</el-col>
</el-row>
</div>
<!-- 指标列表 -->
<div class="indicator-section">
<h4>指标列表</h4>
<el-table :data="currentTemplate.indicators" stripe size="small" max-height="400">
<el-table-column prop="indicator_code" label="编码" width="80" />
<el-table-column prop="indicator_name" label="指标名称" />
<el-table-column prop="category" label="分类" width="100" />
<el-table-column prop="bs_dimension" label="维度" width="100">
<template #default="{ row }">
<el-tag size="small" :type="getDimensionTag(row.bs_dimension)">
{{ getDimensionLabel(row.bs_dimension) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_value" label="目标值" width="80" align="center">
<template #default="{ row }">
{{ row.target_value }}{{ row.target_unit }}
</template>
</el-table-column>
<el-table-column prop="weight" label="权重" width="60" align="center" />
<el-table-column prop="scoring_method" label="评分方法" width="100">
<template #default="{ row }">
{{ getScoringMethodLabel(row.scoring_method) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEditIndicator(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleRemoveIndicator(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-empty v-else description="请选择模板查看详情" />
</el-col>
</el-row>
<!-- 新增/编辑模板弹窗 -->
<el-dialog v-model="templateDialogVisible" :title="templateDialogTitle" width="600px">
<el-form ref="templateFormRef" :model="templateForm" :rules="templateRules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="模板编码" prop="template_code">
<el-input v-model="templateForm.template_code" placeholder="请输入模板编码" :disabled="!!templateForm.id" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模板名称" prop="template_name">
<el-input v-model="templateForm.template_name" placeholder="请输入模板名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="模板类型" prop="template_type">
<el-select v-model="templateForm.template_type" placeholder="请选择模板类型" style="width: 100%">
<el-option v-for="t in templateTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="考核周期" prop="assessment_cycle">
<el-select v-model="templateForm.assessment_cycle" style="width: 100%">
<el-option label="月度" value="monthly" />
<el-option label="季度" value="quarterly" />
<el-option label="年度" value="annual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="维度权重">
<el-row :gutter="10" style="width: 100%">
<el-col :span="6" v-for="dim in dimensions" :key="dim.value">
<div class="weight-input">
<span>{{ dim.label }}</span>
<el-input-number v-model="templateForm.dimensionWeightObj[dim.value]" :min="0" :max="100" size="small" />
</div>
</el-col>
</el-row>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="templateForm.description" type="textarea" rows="3" placeholder="请输入模板描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitTemplate" :loading="submitting">确定</el-button>
</template>
</el-dialog>
<!-- 添加/编辑指标弹窗 -->
<el-dialog v-model="indicatorDialogVisible" :title="indicatorDialogTitle" width="700px">
<el-form ref="indicatorFormRef" :model="indicatorForm" :rules="indicatorRules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="选择指标" prop="indicator_id">
<el-select v-model="indicatorForm.indicator_id" placeholder="请选择指标" filterable style="width: 100%" :disabled="!!indicatorForm.id">
<el-option v-for="ind in availableIndicators" :key="ind.id" :label="`${ind.code} - ${ind.name}`" :value="ind.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="指标分类" prop="category">
<el-input v-model="indicatorForm.category" placeholder="如:收支管理、患者满意度" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值" prop="target_value">
<el-input-number v-model="indicatorForm.target_value" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标单位" prop="target_unit">
<el-input v-model="indicatorForm.target_unit" placeholder="如:%、人次、天" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权重" prop="weight">
<el-input-number v-model="indicatorForm.weight" :min="0" :max="100" :precision="1" :step="0.5" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="评分方法" prop="scoring_method">
<el-select v-model="indicatorForm.scoring_method" placeholder="请选择评分方法" style="width: 100%">
<el-option label="区间法" value="range" />
<el-option label="目标参照法" value="target" />
<el-option label="扣分法" value="deduction" />
<el-option label="加分法" value="bonus" />
<el-option label="一票否决" value="veto" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="评分参数" prop="scoring_params">
<el-input v-model="indicatorForm.scoring_params" type="textarea" rows="2" placeholder="JSON 格式的评分参数" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="indicatorForm.remark" type="textarea" rows="2" placeholder="备注信息" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="indicatorDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitIndicator" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate,
getTemplateTypes, getDimensions, getTemplateIndicators,
addTemplateIndicator, updateTemplateIndicator, removeTemplateIndicator
} from '@/api/template'
import { getActiveIndicators } from '@/api/indicator'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const templateTypes = ref([])
const dimensions = ref([])
const currentTemplate = ref(null)
const allIndicators = ref([])
const templateDialogVisible = ref(false)
const templateDialogTitle = ref('新增模板')
const templateFormRef = ref()
const indicatorDialogVisible = ref(false)
const indicatorDialogTitle = ref('添加指标')
const indicatorFormRef = ref()
const searchForm = reactive({
keyword: '',
template_type: ''
})
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const templateForm = reactive({
id: null,
template_code: '',
template_name: '',
template_type: '',
description: '',
assessment_cycle: 'monthly',
dimensionWeightObj: {
financial: 35,
customer: 30,
internal_process: 25,
learning_growth: 10
}
})
const templateRules = {
template_code: [{ required: true, message: '请输入模板编码', trigger: 'blur' }],
template_name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
template_type: [{ required: true, message: '请选择模板类型', trigger: 'change' }]
}
const indicatorForm = reactive({
id: null,
indicator_id: null,
category: '',
target_value: null,
target_unit: '',
weight: 1,
scoring_method: '',
scoring_params: '',
remark: ''
})
const indicatorRules = {
indicator_id: [{ required: true, message: '请选择指标', trigger: 'change' }]
}
const dimensionWeights = computed(() => {
if (!currentTemplate.value?.dimension_weights) return null
try {
return JSON.parse(currentTemplate.value.dimension_weights)
} catch {
return null
}
})
const availableIndicators = computed(() => {
if (!currentTemplate.value?.indicators) return allIndicators.value
const existingIds = currentTemplate.value.indicators.map(i => i.indicator_id)
return allIndicators.value.filter(i => !existingIds.includes(i.id))
})
function getTypeLabel(type) {
const t = templateTypes.value.find(t => t.value === type)
return t?.label || type
}
function getDimensionLabel(dim) {
const d = dimensions.value.find(d => d.value === dim)
return d?.label || dim
}
function getDimensionTag(dim) {
const tagMap = {
financial: 'warning',
customer: 'success',
internal_process: 'primary',
learning_growth: 'info'
}
return tagMap[dim] || ''
}
function getScoringMethodLabel(method) {
const methodMap = {
range: '区间法',
target: '目标参照法',
deduction: '扣分法',
bonus: '加分法',
veto: '一票否决'
}
return methodMap[method] || method || '-'
}
async function loadData() {
loading.value = true
try {
const res = await getTemplates({
...searchForm,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadTemplateTypes() {
const res = await getTemplateTypes()
templateTypes.value = res.data || []
}
async function loadDimensions() {
const res = await getDimensions()
dimensions.value = res.data || []
}
async function loadAllIndicators() {
const res = await getActiveIndicators()
allIndicators.value = res.data || []
}
function resetSearch() {
searchForm.keyword = ''
searchForm.template_type = ''
pagination.page = 1
loadData()
}
async function handleSelectTemplate(row) {
if (!row) {
currentTemplate.value = null
return
}
const res = await getTemplate(row.id)
currentTemplate.value = res.data
}
function handleAdd() {
templateDialogTitle.value = '新增模板'
Object.assign(templateForm, {
id: null,
template_code: '',
template_name: '',
template_type: '',
description: '',
assessment_cycle: 'monthly',
dimensionWeightObj: {
financial: 35,
customer: 30,
internal_process: 25,
learning_growth: 10
}
})
templateDialogVisible.value = true
}
function handleEditTemplate() {
templateDialogTitle.value = '编辑模板'
let dw = { financial: 35, customer: 30, internal_process: 25, learning_growth: 10 }
if (currentTemplate.value.dimension_weights) {
try {
dw = JSON.parse(currentTemplate.value.dimension_weights)
} catch {}
}
Object.assign(templateForm, {
id: currentTemplate.value.id,
template_code: currentTemplate.value.template_code,
template_name: currentTemplate.value.template_name,
template_type: currentTemplate.value.template_type,
description: currentTemplate.value.description,
assessment_cycle: currentTemplate.value.assessment_cycle,
dimensionWeightObj: dw
})
templateDialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm('确定要删除该模板吗?', '提示', { type: 'warning' })
try {
await deleteTemplate(row.id)
ElMessage.success('删除成功')
if (currentTemplate.value?.id === row.id) {
currentTemplate.value = null
}
loadData()
} catch (error) {
console.error('删除失败', error)
}
}
async function handleStatusChange(row) {
try {
await updateTemplate(row.id, { is_active: row.is_active })
ElMessage.success('状态更新成功')
} catch (error) {
row.is_active = !row.is_active
}
}
async function handleSubmitTemplate() {
const valid = await templateFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const data = {
template_name: templateForm.template_name,
template_code: templateForm.template_code,
template_type: templateForm.template_type,
description: templateForm.description,
assessment_cycle: templateForm.assessment_cycle,
dimension_weights: JSON.stringify(templateForm.dimensionWeightObj)
}
if (templateForm.id) {
await updateTemplate(templateForm.id, data)
ElMessage.success('更新成功')
} else {
await createTemplate(data)
ElMessage.success('创建成功')
}
templateDialogVisible.value = false
loadData()
} finally {
submitting.value = false
}
}
function handleAddIndicator() {
indicatorDialogTitle.value = '添加指标'
Object.assign(indicatorForm, {
id: null,
indicator_id: null,
category: '',
target_value: null,
target_unit: '',
weight: 1,
scoring_method: '',
scoring_params: '',
remark: ''
})
indicatorDialogVisible.value = true
}
function handleEditIndicator(row) {
indicatorDialogTitle.value = '编辑指标'
Object.assign(indicatorForm, {
id: row.id,
indicator_id: row.indicator_id,
category: row.category,
target_value: row.target_value,
target_unit: row.target_unit,
weight: row.weight,
scoring_method: row.scoring_method,
scoring_params: row.scoring_params,
remark: row.remark
})
indicatorDialogVisible.value = true
}
async function handleRemoveIndicator(row) {
await ElMessageBox.confirm('确定要移除该指标吗?', '提示', { type: 'warning' })
try {
await removeTemplateIndicator(currentTemplate.value.id, row.indicator_id)
ElMessage.success('移除成功')
handleSelectTemplate({ id: currentTemplate.value.id })
} catch (error) {
console.error('移除失败', error)
}
}
async function handleSubmitIndicator() {
const valid = await indicatorFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const data = {
indicator_id: indicatorForm.indicator_id,
category: indicatorForm.category,
target_value: indicatorForm.target_value,
target_unit: indicatorForm.target_unit,
weight: indicatorForm.weight,
scoring_method: indicatorForm.scoring_method,
scoring_params: indicatorForm.scoring_params,
remark: indicatorForm.remark
}
if (indicatorForm.id) {
await updateTemplateIndicator(currentTemplate.value.id, indicatorForm.indicator_id, data)
ElMessage.success('更新成功')
} else {
await addTemplateIndicator(currentTemplate.value.id, data)
ElMessage.success('添加成功')
}
indicatorDialogVisible.value = false
handleSelectTemplate({ id: currentTemplate.value.id })
} finally {
submitting.value = false
}
}
onMounted(() => {
loadData()
loadTemplateTypes()
loadDimensions()
loadAllIndicators()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.detail-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.dimension-weights {
margin: 20px 0;
h4 {
margin-bottom: 10px;
font-size: 14px;
color: #606266;
}
.weight-item {
text-align: center;
.label {
display: block;
font-size: 12px;
color: #909399;
margin-bottom: 5px;
}
}
}
.indicator-section {
margin-top: 20px;
h4 {
margin-bottom: 10px;
font-size: 14px;
color: #606266;
}
}
.weight-input {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
span {
font-size: 12px;
color: #606266;
}
}
.el-pagination {
margin-top: 15px;
justify-content: flex-start;
}
</style>

View File

@@ -0,0 +1,613 @@
<template>
<div class="page-card">
<!-- 搜索栏 -->
<div class="search-bar">
<el-tree-select
v-model="searchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="选择科室"
clearable
check-strictly
/>
<el-date-picker
v-model="periodDate"
type="month"
placeholder="选择月份"
format="YYYY-MM"
value-format="YYYY-MM"
@change="handlePeriodChange"
/>
<el-button type="primary" @click="loadAllData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAddRevenue">新增收入</el-button>
<el-button type="warning" @click="handleAddExpense">新增支出</el-button>
</div>
<!-- 收支结余汇总卡片 -->
<div class="summary-cards">
<el-card class="summary-card revenue-card">
<div class="card-content">
<div class="card-icon">
<el-icon size="32"><TrendCharts /></el-icon>
</div>
<div class="card-info">
<div class="card-label">总收入</div>
<div class="card-value">¥{{ formatMoney(balance.total_revenue) }}</div>
</div>
</div>
</el-card>
<el-card class="summary-card expense-card">
<div class="card-content">
<div class="card-icon">
<el-icon size="32"><Minus /></el-icon>
</div>
<div class="card-info">
<div class="card-label">总支出</div>
<div class="card-value">¥{{ formatMoney(balance.total_expense) }}</div>
</div>
</div>
</el-card>
<el-card class="summary-card" :class="balance.balance >= 0 ? 'positive-card' : 'negative-card'">
<div class="card-content">
<div class="card-icon">
<el-icon size="32"><Wallet /></el-icon>
</div>
<div class="card-info">
<div class="card-label">收支结余</div>
<div class="card-value">¥{{ formatMoney(balance.balance) }}</div>
</div>
</div>
</el-card>
</div>
<!-- 数据表格区域 -->
<el-tabs v-model="activeTab" class="data-tabs">
<!-- 收入统计 -->
<el-tab-pane label="收入统计" name="revenue">
<el-table :data="revenueData" stripe v-loading="revenueLoading">
<el-table-column prop="department_name" label="科室" width="150" />
<el-table-column prop="category_label" label="收入类别" width="120" />
<el-table-column prop="amount" label="金额" width="150" align="right">
<template #default="{ row }">
¥{{ formatMoney(row.amount) }}
</template>
</el-table-column>
<el-table-column prop="source" label="数据来源" width="150" />
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditRevenue(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 支出统计 -->
<el-tab-pane label="支出统计" name="expense">
<el-table :data="expenseData" stripe v-loading="expenseLoading">
<el-table-column prop="department_name" label="科室" width="150" />
<el-table-column prop="category_label" label="支出类别" width="120" />
<el-table-column prop="amount" label="金额" width="150" align="right">
<template #default="{ row }">
¥{{ formatMoney(row.amount) }}
</template>
</el-table-column>
<el-table-column prop="source" label="数据来源" width="150" />
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditExpense(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 科室汇总 -->
<el-tab-pane label="科室汇总" name="summary">
<el-table :data="summaryData" stripe v-loading="summaryLoading">
<el-table-column prop="department_name" label="科室" />
<el-table-column prop="total_revenue" label="总收入" align="right">
<template #default="{ row }">
¥{{ formatMoney(row.total_revenue) }}
</template>
</el-table-column>
<el-table-column prop="total_expense" label="总支出" align="right">
<template #default="{ row }">
¥{{ formatMoney(row.total_expense) }}
</template>
</el-table-column>
<el-table-column prop="balance" label="结余" align="right">
<template #default="{ row }">
<span :class="row.balance >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(row.balance) }}
</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 新增/编辑收入弹窗 -->
<el-dialog v-model="revenueDialogVisible" :title="revenueDialogTitle" width="500px">
<el-form ref="revenueFormRef" :model="revenueForm" :rules="revenueRules" label-width="100px">
<el-form-item label="科室" prop="department_id">
<el-tree-select
v-model="revenueForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
/>
</el-form-item>
<el-form-item label="收入类别" prop="category">
<el-select v-model="revenueForm.category" placeholder="请选择类别">
<el-option
v-for="cat in revenueCategories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
/>
</el-select>
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="revenueForm.amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="数据来源" prop="source">
<el-input v-model="revenueForm.source" placeholder="请输入数据来源" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="revenueForm.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="revenueDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitRevenue" :loading="submitting">确定</el-button>
</template>
</el-dialog>
<!-- 新增/编辑支出弹窗 -->
<el-dialog v-model="expenseDialogVisible" :title="expenseDialogTitle" width="500px">
<el-form ref="expenseFormRef" :model="expenseForm" :rules="expenseRules" label-width="100px">
<el-form-item label="科室" prop="department_id">
<el-tree-select
v-model="expenseForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
/>
</el-form-item>
<el-form-item label="支出类别" prop="category">
<el-select v-model="expenseForm.category" placeholder="请选择类别">
<el-option
v-for="cat in expenseCategories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
/>
</el-select>
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="expenseForm.amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="数据来源" prop="source">
<el-input v-model="expenseForm.source" placeholder="请输入数据来源" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="expenseForm.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="expenseDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitExpense" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getRevenue, getExpense, getBalance, getDepartmentSummary,
getCategories, createFinanceRecord, updateFinanceRecord, deleteFinanceRecord
} from '@/api/finance'
import { getDepartmentTree } from '@/api/department'
// 状态
const activeTab = ref('revenue')
const loading = ref(false)
const revenueLoading = ref(false)
const expenseLoading = ref(false)
const summaryLoading = ref(false)
const submitting = ref(false)
const deptTree = ref([])
const periodDate = ref(new Date().toISOString().slice(0, 7))
// 数据
const revenueData = ref([])
const expenseData = ref([])
const summaryData = ref([])
const balance = reactive({
total_revenue: 0,
total_expense: 0,
balance: 0
})
// 类别选项
const revenueCategories = ref([])
const expenseCategories = ref([])
// 搜索表单
const searchForm = reactive({
department_id: null,
period_year: null,
period_month: null
})
// 收入表单
const revenueDialogVisible = ref(false)
const revenueDialogTitle = ref('新增收入')
const revenueFormRef = ref()
const revenueForm = reactive({
id: null,
department_id: null,
category: '',
amount: 0,
source: '',
remark: ''
})
// 支出表单
const expenseDialogVisible = ref(false)
const expenseDialogTitle = ref('新增支出')
const expenseFormRef = ref()
const expenseForm = reactive({
id: null,
department_id: null,
category: '',
amount: 0,
source: '',
remark: ''
})
// 表单验证规则
const revenueRules = {
department_id: [{ required: true, message: '请选择科室', trigger: 'change' }],
category: [{ required: true, message: '请选择收入类别', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }]
}
const expenseRules = {
department_id: [{ required: true, message: '请选择科室', trigger: 'change' }],
category: [{ required: true, message: '请选择支出类别', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }]
}
// 格式化金额
function formatMoney(value) {
if (value === null || value === undefined) return '0.00'
return Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// 处理月份变化
function handlePeriodChange(val) {
if (val) {
const [year, month] = val.split('-')
searchForm.period_year = parseInt(year)
searchForm.period_month = parseInt(month)
} else {
searchForm.period_year = null
searchForm.period_month = null
}
}
// 加载科室树
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
// 加载类别
async function loadCategories() {
try {
const res = await getCategories()
revenueCategories.value = res.data?.revenue || []
expenseCategories.value = res.data?.expense || []
} catch (error) {
console.error('加载类别失败', error)
}
}
// 加载收入数据
async function loadRevenueData() {
revenueLoading.value = true
try {
const res = await getRevenue(searchForm)
revenueData.value = res.data || []
} finally {
revenueLoading.value = false
}
}
// 加载支出数据
async function loadExpenseData() {
expenseLoading.value = true
try {
const res = await getExpense(searchForm)
expenseData.value = res.data || []
} finally {
expenseLoading.value = false
}
}
// 加载结余数据
async function loadBalanceData() {
try {
const res = await getBalance(searchForm)
Object.assign(balance, res.data || {})
} catch (error) {
console.error('加载结余失败', error)
}
}
// 加载汇总数据
async function loadSummaryData() {
if (!searchForm.period_year || !searchForm.period_month) return
summaryLoading.value = true
try {
const res = await getDepartmentSummary({
period_year: searchForm.period_year,
period_month: searchForm.period_month
})
summaryData.value = res.data || []
} finally {
summaryLoading.value = false
}
}
// 加载所有数据
async function loadAllData() {
await Promise.all([
loadRevenueData(),
loadExpenseData(),
loadBalanceData(),
loadSummaryData()
])
}
// 重置搜索
function resetSearch() {
searchForm.department_id = null
searchForm.period_year = null
searchForm.period_month = null
periodDate.value = null
loadAllData()
}
// 新增收入
function handleAddRevenue() {
revenueDialogTitle.value = '新增收入'
Object.assign(revenueForm, {
id: null,
department_id: searchForm.department_id,
category: '',
amount: 0,
source: '',
remark: ''
})
revenueDialogVisible.value = true
}
// 编辑收入
function handleEditRevenue(row) {
revenueDialogTitle.value = '编辑收入'
Object.assign(revenueForm, {
id: row.id,
department_id: row.department_id,
category: row.category,
amount: row.amount,
source: row.source,
remark: row.remark
})
revenueDialogVisible.value = true
}
// 新增支出
function handleAddExpense() {
expenseDialogTitle.value = '新增支出'
Object.assign(expenseForm, {
id: null,
department_id: searchForm.department_id,
category: '',
amount: 0,
source: '',
remark: ''
})
expenseDialogVisible.value = true
}
// 编辑支出
function handleEditExpense(row) {
expenseDialogTitle.value = '编辑支出'
Object.assign(expenseForm, {
id: row.id,
department_id: row.department_id,
category: row.category,
amount: row.amount,
source: row.source,
remark: row.remark
})
expenseDialogVisible.value = true
}
// 提交收入
async function handleSubmitRevenue() {
const valid = await revenueFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const data = {
...revenueForm,
finance_type: 'revenue',
period_year: searchForm.period_year || new Date().getFullYear(),
period_month: searchForm.period_month || new Date().getMonth() + 1
}
if (revenueForm.id) {
await updateFinanceRecord(revenueForm.id, data)
ElMessage.success('更新成功')
} else {
await createFinanceRecord(data)
ElMessage.success('创建成功')
}
revenueDialogVisible.value = false
loadAllData()
} finally {
submitting.value = false
}
}
// 提交支出
async function handleSubmitExpense() {
const valid = await expenseFormRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const data = {
...expenseForm,
finance_type: 'expense',
period_year: searchForm.period_year || new Date().getFullYear(),
period_month: searchForm.period_month || new Date().getMonth() + 1
}
if (expenseForm.id) {
await updateFinanceRecord(expenseForm.id, data)
ElMessage.success('更新成功')
} else {
await createFinanceRecord(data)
ElMessage.success('创建成功')
}
expenseDialogVisible.value = false
loadAllData()
} finally {
submitting.value = false
}
}
// 删除记录
async function handleDelete(row) {
await ElMessageBox.confirm('确定要删除该记录吗?', '提示', { type: 'warning' })
try {
await deleteFinanceRecord(row.id)
ElMessage.success('删除成功')
loadAllData()
} catch (error) {
console.error('删除失败', error)
}
}
// 初始化
onMounted(() => {
// 设置默认月份
const now = new Date()
searchForm.period_year = now.getFullYear()
searchForm.period_month = now.getMonth() + 1
loadDeptTree()
loadCategories()
loadAllData()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select, .el-tree-select {
width: 180px;
}
}
.summary-cards {
display: flex;
gap: 20px;
margin-bottom: 20px;
.summary-card {
flex: 1;
.card-content {
display: flex;
align-items: center;
gap: 16px;
}
.card-icon {
width: 64px;
height: 64px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.card-info {
flex: 1;
.card-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.card-value {
font-size: 24px;
font-weight: 600;
}
}
&.revenue-card .card-icon {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
}
&.expense-card .card-icon {
background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%);
}
&.positive-card .card-icon {
background: linear-gradient(135deg, #409eff 0%, #79bbff 100%);
}
&.negative-card .card-icon {
background: linear-gradient(135deg, #f56c6c 0%, #fab6b6 100%);
}
}
}
.data-tabs {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.positive {
color: #67c23a;
}
.negative {
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,741 @@
<template>
<div class="performance-plan-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-form :inline="true" :model="searchForm">
<el-form-item label="计划层级">
<el-select v-model="searchForm.plan_level" placeholder="请选择" clearable>
<el-option label="医院级" value="hospital" />
<el-option label="科室级" value="department" />
<el-option label="个人级" value="individual" />
</el-select>
</el-form-item>
<el-form-item label="计划年度">
<el-input-number v-model="searchForm.plan_year" :min="2020" :max="2100" placeholder="年度" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
<el-option label="草稿" value="draft" />
<el-option label="待审批" value="pending" />
<el-option label="已批准" value="approved" />
<el-option label="已驳回" value="rejected" />
<el-option label="执行中" value="active" />
<el-option label="已完成" value="completed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新建计划
</el-button>
<el-button @click="showTree = !showTree">
<el-icon><Component :is="showTree ? 'Hide' : 'Share'" /></el-icon>
{{ showTree ? '隐藏' : '显示' }}树形结构
</el-button>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 左侧树形结构 -->
<div v-if="showTree" class="tree-panel">
<el-card>
<template #header>
<span>计划树形结构</span>
</template>
<el-tree
:data="planTree"
:props="{ label: 'plan_name', children: 'children' }"
node-key="id"
@node-click="handleTreeClick"
/>
</el-card>
</div>
<!-- 右侧列表 -->
<div class="table-panel">
<!-- 统计卡片 -->
<el-row :gutter="16" class="stats-cards">
<el-col :span="4">
<el-card shadow="hover">
<template #header>总计划数</template>
<div class="stat-value">{{ stats.total_plans }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<template #header>草稿</template>
<div class="stat-value draft">{{ stats.draft_count }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<template #header>待审批</template>
<div class="stat-value pending">{{ stats.pending_count }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<template #header>已批准</template>
<div class="stat-value approved">{{ stats.approved_count }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<template #header>执行中</template>
<div class="stat-value active">{{ stats.active_count }}</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<template #header>已完成</template>
<div class="stat-value completed">{{ stats.completed_count }}</div>
</el-card>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
>
<el-table-column prop="plan_code" label="计划编码" width="120" />
<el-table-column prop="plan_name" label="计划名称" min-width="200" />
<el-table-column prop="plan_level" label="层级" width="100">
<template #default="{ row }">
<el-tag :type="getLevelTagType(row.plan_level)">
{{ getLevelLabel(row.plan_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="plan_year" label="年度" width="80" />
<el-table-column prop="plan_month" label="月份" width="60" />
<el-table-column prop="department_name" label="科室" width="120" />
<el-table-column prop="staff_name" label="责任人" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">详情</el-button>
<el-button v-if="row.status === 'draft'" type="warning" link @click="handleSubmit(row)">提交</el-button>
<el-button v-if="row.status === 'pending'" type="success" link @click="handleApprove(row, true)">通过</el-button>
<el-button v-if="row.status === 'pending'" type="danger" link @click="handleApprove(row, false)">驳回</el-button>
<el-button v-if="row.status === 'approved'" type="success" link @click="handleActivate(row)">激活</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</div>
<!-- 详情/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划编码" prop="plan_code">
<el-input v-model="form.plan_code" placeholder="请输入计划编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划名称" prop="plan_name">
<el-input v-model="form.plan_name" placeholder="请输入计划名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划层级" prop="plan_level">
<el-select v-model="form.plan_level" placeholder="请选择层级" style="width: 100%">
<el-option label="医院级" value="hospital" />
<el-option label="科室级" value="department" />
<el-option label="个人级" value="individual" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划年度" prop="plan_year">
<el-input-number v-model="form.plan_year" :min="2020" :max="2100" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划月份" prop="plan_month">
<el-input-number v-model="form.plan_month" :min="1" :max="12" :disabled="form.plan_type === 'annual'" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划类型" prop="plan_type">
<el-radio-group v-model="form.plan_type">
<el-radio label="annual">年度</el-radio>
<el-radio label="monthly">月度</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="所属科室" prop="department_id">
<el-tree-select
v-model="form.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="责任人" prop="staff_id">
<el-select v-model="form.staff_id" placeholder="请选择责任人" clearable style="width: 100%">
<el-option
v-for="staff in staffList"
:key="staff.id"
:label="staff.name"
:value="staff.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="上级计划" prop="parent_plan_id">
<el-select v-model="form.parent_plan_id" placeholder="请选择上级计划" clearable style="width: 100%">
<el-option
v-for="plan in parentPlanOptions"
:key="plan.id"
:label="plan.plan_name"
:value="plan.id"
/>
</el-select>
</el-form-item>
<el-form-item label="计划描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入计划描述"
/>
</el-form-item>
<el-form-item label="战略目标" prop="strategic_goals">
<el-input
v-model="form.strategic_goals"
type="textarea"
:rows="3"
placeholder="请输入战略目标"
/>
</el-form-item>
<el-form-item label="关键举措" prop="key_initiatives">
<el-input
v-model="form.key_initiatives"
type="textarea"
:rows="3"
placeholder="请输入关键举措"
/>
</el-form-item>
<!-- 指标关联 -->
<el-divider>考核指标</el-divider>
<el-table :data="form.kpi_relations" border style="margin-bottom: 16px">
<el-table-column label="指标" min-width="200">
<template #default="{ row, $index }">
<el-select
v-model="row.indicator_id"
placeholder="请选择指标"
filterable
style="width: 100%"
@change="handleIndicatorChange(row)"
>
<el-option
v-for="ind in indicatorList"
:key="ind.id"
:label="ind.name"
:value="ind.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="目标值" width="120">
<template #default="{ row }">
<el-input-number v-model="row.target_value" :precision="2" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="单位" width="100">
<template #default="{ row }">
<el-input v-model="row.target_unit" placeholder="单位" />
</template>
</el-table-column>
<el-table-column label="权重" width="100">
<template #default="{ row }">
<el-input-number v-model="row.weight" :precision="2" :step="0.1" :min="0" :max="100" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button type="danger" link @click="removeKpiRelation($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button type="primary" @click="addKpiRelation">添加指标</el-button>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitForm" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getPerformancePlans,
getPerformancePlanTree,
getPerformancePlanStats,
getPerformancePlan,
createPerformancePlan,
updatePerformancePlan,
submitPerformancePlan,
approvePerformancePlan,
activatePerformancePlan,
deletePerformancePlan,
addKpiRelation as apiAddKpiRelation,
updateKpiRelation as apiUpdateKpiRelation,
deleteKpiRelation as apiDeleteKpiRelation
} from '@/api/performance_plan'
import { getDepartmentTree } from '@/api/department'
import { getStaffList } from '@/api/staff'
import { getActiveIndicators } from '@/api/indicator'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const planTree = ref([])
const deptTree = ref([])
const staffList = ref([])
const indicatorList = ref([])
const showTree = ref(false)
const searchForm = reactive({
plan_level: '',
plan_year: new Date().getFullYear(),
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const stats = ref({
total_plans: 0,
draft_count: 0,
pending_count: 0,
approved_count: 0,
active_count: 0,
completed_count: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('新建计划')
const formRef = ref()
const form = reactive({
id: null,
plan_code: '',
plan_name: '',
plan_level: '',
plan_year: new Date().getFullYear(),
plan_month: null,
plan_type: 'annual',
department_id: null,
staff_id: null,
parent_plan_id: null,
description: '',
strategic_goals: '',
key_initiatives: '',
kpi_relations: []
})
const rules = {
plan_code: [{ required: true, message: '请输入计划编码', trigger: 'blur' }],
plan_name: [{ required: true, message: '请输入计划名称', trigger: 'blur' }],
plan_level: [{ required: true, message: '请选择计划层级', trigger: 'change' }],
plan_year: [{ required: true, message: '请输入计划年度', trigger: 'blur' }]
}
const parentPlanOptions = computed(() => {
return tableData.value.filter(p => p.id !== form.id)
})
// 层级标签
function getLevelLabel(level) {
const map = {
hospital: '医院级',
department: '科室级',
individual: '个人级'
}
return map[level] || level
}
function getLevelTagType(level) {
const map = {
hospital: 'danger',
department: 'primary',
individual: 'success'
}
return map[level] || ''
}
// 状态标签
function getStatusLabel(status) {
const map = {
draft: '草稿',
pending: '待审批',
approved: '已批准',
rejected: '已驳回',
active: '执行中',
completed: '已完成',
cancelled: '已取消'
}
return map[status] || status
}
function getStatusTagType(status) {
const map = {
draft: 'info',
pending: 'warning',
approved: 'success',
rejected: 'danger',
active: 'primary',
completed: 'success',
cancelled: 'info'
}
return map[status] || ''
}
async function loadData() {
loading.value = true
try {
const res = await getPerformancePlans({
...searchForm,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadPlanTree() {
try {
const res = await getPerformancePlanTree({ plan_year: searchForm.plan_year })
planTree.value = res.data || []
} catch (error) {
console.error('加载计划树失败', error)
}
}
async function loadStats() {
try {
const res = await getPerformancePlanStats({ plan_year: searchForm.plan_year })
stats.value = res.data || {}
} catch (error) {
console.error('加载统计失败', error)
}
}
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
async function loadStaffList() {
try {
const res = await getStaffList({ page: 1, page_size: 100 })
staffList.value = res.data || []
} catch (error) {
console.error('加载员工列表失败', error)
}
}
async function loadIndicatorList() {
try {
const res = await getActiveIndicators()
indicatorList.value = res.data || []
} catch (error) {
console.error('加载指标列表失败', error)
}
}
function handleTreeClick(node) {
searchForm.plan_level = node.plan_level
loadData()
}
function resetSearch() {
searchForm.plan_level = ''
searchForm.plan_year = new Date().getFullYear()
searchForm.status = ''
pagination.page = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新建计划'
Object.assign(form, {
id: null,
plan_code: '',
plan_name: '',
plan_level: '',
plan_year: new Date().getFullYear(),
plan_month: null,
plan_type: 'annual',
department_id: null,
staff_id: null,
parent_plan_id: null,
description: '',
strategic_goals: '',
key_initiatives: '',
kpi_relations: []
})
dialogVisible.value = true
}
function handleView(row) {
dialogTitle.value = '计划详情'
Object.assign(form, {
id: row.id,
plan_code: row.plan_code,
plan_name: row.plan_name,
plan_level: row.plan_level,
plan_year: row.plan_year,
plan_month: row.plan_month,
plan_type: row.plan_type,
department_id: row.department_id,
staff_id: row.staff_id,
parent_plan_id: row.parent_plan_id,
description: row.description,
strategic_goals: row.strategic_goals,
key_initiatives: row.key_initiatives,
kpi_relations: row.kpi_relations || []
})
dialogVisible.value = true
}
async function handleSubmit(row) {
try {
await ElMessageBox.confirm('确定要提交该计划吗?', '提示', { type: 'warning' })
await submitPerformancePlan(row.id)
ElMessage.success('提交成功')
loadData()
loadStats()
} catch (error) {
if (error !== 'cancel') {
console.error('提交失败', error)
}
}
}
async function handleApprove(row, approved) {
try {
const action = approved ? '通过' : '驳回'
await ElMessageBox.confirm(`确定要${action}该计划吗?`, '提示', { type: 'warning' })
await approvePerformancePlan(row.id, { approved, remark: '' })
ElMessage.success(approved ? '审核通过' : '已驳回')
loadData()
loadStats()
} catch (error) {
if (error !== 'cancel') {
console.error('审批失败', error)
}
}
}
async function handleActivate(row) {
try {
await ElMessageBox.confirm('确定要激活该计划吗?', '提示', { type: 'warning' })
await activatePerformancePlan(row.id)
ElMessage.success('激活成功')
loadData()
loadStats()
} catch (error) {
if (error !== 'cancel') {
console.error('激活失败', error)
}
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm('确定要删除该计划吗?', '提示', { type: 'warning' })
await deletePerformancePlan(row.id)
ElMessage.success('删除成功')
loadData()
loadStats()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败', error)
}
}
}
async function handleSubmitForm() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (form.id) {
await updatePerformancePlan(form.id, form)
ElMessage.success('更新成功')
} else {
await createPerformancePlan(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
loadStats()
} catch (error) {
console.error('保存失败', error)
} finally {
submitting.value = false
}
}
function addKpiRelation() {
form.kpi_relations.push({
indicator_id: null,
target_value: null,
target_unit: '',
weight: 1.0,
scoring_method: '',
scoring_params: null,
remark: ''
})
}
function removeKpiRelation(index) {
form.kpi_relations.splice(index, 1)
}
function handleIndicatorChange(row) {
const indicator = indicatorList.value.find(i => i.id === row.indicator_id)
if (indicator) {
row.target_unit = indicator.target_unit || ''
}
}
onMounted(() => {
loadData()
loadPlanTree()
loadStats()
loadDeptTree()
loadStaffList()
loadIndicatorList()
})
</script>
<style scoped lang="scss">
.performance-plan-container {
padding: 20px;
}
.search-bar {
margin-bottom: 20px;
}
.toolbar {
margin-bottom: 20px;
}
.main-content {
display: flex;
gap: 20px;
.tree-panel {
width: 300px;
flex-shrink: 0;
}
.table-panel {
flex: 1;
}
}
.stats-cards {
margin-bottom: 20px;
.stat-value {
font-size: 24px;
font-weight: bold;
text-align: center;
&.draft {
color: #909399;
}
&.pending {
color: #e6a23c;
}
&.approved {
color: #67c23a;
}
&.active {
color: #409eff;
}
&.completed {
color: #909399;
}
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div class="reports">
<!-- 筛选条件 -->
<div class="page-card filter-card">
<el-form inline>
<el-form-item label="统计周期">
<el-date-picker
v-model="period"
type="month"
placeholder="选择周期"
format="YYYY年MM月"
value-format="YYYY-MM"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadAllData">查询</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计概览 -->
<el-row :gutter="20" class="stat-row">
<el-col :span="6">
<div class="stat-card">
<div class="stat-label">在职员工</div>
<div class="stat-value">{{ periodStats.total_staff }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card success">
<div class="stat-label">已考核人数</div>
<div class="stat-value">{{ periodStats.assessed_count }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card warning">
<div class="stat-label">平均得分</div>
<div class="stat-value">{{ periodStats.avg_score?.toFixed(1) }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card info">
<div class="stat-label">奖金总额</div>
<div class="stat-value">¥{{ periodStats.total_bonus?.toLocaleString() || 0 }}</div>
</div>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20">
<el-col :span="12">
<div class="page-card">
<h3>科室绩效对比</h3>
<div ref="deptChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="12">
<div class="page-card">
<h3>绩效分布</h3>
<div ref="pieChartRef" class="chart-container"></div>
</div>
</el-col>
</el-row>
<!-- 科室统计表 -->
<div class="page-card">
<h3>科室绩效统计</h3>
<el-table :data="deptStats" stripe>
<el-table-column prop="department_name" label="科室" />
<el-table-column prop="staff_count" label="员工数" width="100" align="center" />
<el-table-column prop="avg_score" label="平均得分" width="120" align="center">
<template #default="{ row }">
<span :class="getScoreClass(row.avg_score)">{{ row.avg_score?.toFixed(1) }}</span>
</template>
</el-table-column>
<el-table-column prop="total_bonus" label="奖金总额" width="150" align="right">
<template #default="{ row }">
¥{{ row.total_bonus?.toLocaleString() || 0 }}
</template>
</el-table-column>
</el-table>
</div>
<!-- 员工排名 -->
<div class="page-card">
<h3>绩效排名 TOP 20</h3>
<el-table :data="ranking" stripe>
<el-table-column label="排名" width="80" align="center">
<template #default="{ $index }">
<el-tag :type="$index < 3 ? 'danger' : 'info'" effect="dark" size="small">
{{ $index + 1 }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="staff_name" label="姓名" width="120" />
<el-table-column prop="department_name" label="科室" />
<el-table-column prop="score" label="绩效得分" width="120" align="center">
<template #default="{ row }">
<span :class="getScoreClass(row.score)">{{ row.score?.toFixed(1) }}</span>
</template>
</el-table-column>
<el-table-column prop="bonus" label="绩效奖金" width="150" align="right">
<template #default="{ row }">
¥{{ row.bonus?.toLocaleString() || 0 }}
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'
import { getDepartmentStats, getPeriodStats, getStaffRanking } from '@/api/stats'
const period = ref('')
const periodStats = ref({})
const deptStats = ref([])
const ranking = ref([])
const deptChartRef = ref()
const pieChartRef = ref()
let deptChart = null
let pieChart = null
function getScoreClass(score) {
if (score >= 90) return 'score-level excellent'
if (score >= 80) return 'score-level good'
if (score >= 60) return 'score-level average'
return 'score-level poor'
}
async function loadAllData() {
const [year, month] = period.value ? period.value.split('-') : [new Date().getFullYear(), new Date().getMonth() + 1]
await Promise.all([
loadPeriodStats(parseInt(year), parseInt(month)),
loadDeptStats(parseInt(year), parseInt(month)),
loadRanking(parseInt(year), parseInt(month))
])
updateCharts()
}
async function loadPeriodStats(year, month) {
try {
const res = await getPeriodStats({ period_year: year, period_month: month })
periodStats.value = res.data || {}
} catch (error) {
console.error('加载周期统计失败', error)
}
}
async function loadDeptStats(year, month) {
try {
const res = await getDepartmentStats({ period_year: year, period_month: month })
deptStats.value = res.data || []
} catch (error) {
console.error('加载科室统计失败', error)
}
}
async function loadRanking(year, month) {
try {
const res = await getStaffRanking({ period_year: year, period_month: month, limit: 20 })
ranking.value = res.data || []
} catch (error) {
console.error('加载排名失败', error)
}
}
function initCharts() {
deptChart = echarts.init(deptChartRef.value)
pieChart = echarts.init(pieChartRef.value)
window.addEventListener('resize', () => {
deptChart?.resize()
pieChart?.resize()
})
}
function updateCharts() {
updateDeptChart()
updatePieChart()
}
function updateDeptChart() {
if (!deptChart || !deptStats.value.length) return
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: deptStats.value.map(d => d.department_name),
axisLabel: {
rotate: 30
}
},
yAxis: [
{
type: 'value',
name: '平均得分',
min: 0,
max: 100
},
{
type: 'value',
name: '奖金(元)',
position: 'right'
}
],
series: [
{
name: '平均得分',
type: 'bar',
data: deptStats.value.map(d => d.avg_score),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#79bbff' }
])
}
},
{
name: '奖金总额',
type: 'line',
yAxisIndex: 1,
data: deptStats.value.map(d => d.total_bonus),
itemStyle: { color: '#67c23a' }
}
]
}
deptChart.setOption(option)
}
function updatePieChart() {
if (!pieChart) return
const dist = periodStats.value.score_distribution || {}
const data = [
{ value: dist.excellent || 0, name: '优秀(≥90)', itemStyle: { color: '#67c23a' } },
{ value: dist.good || 0, name: '良好(80-89)', itemStyle: { color: '#409eff' } },
{ value: dist.average || 0, name: '合格(60-79)', itemStyle: { color: '#e6a23c' } },
{ value: dist.poor || 0, name: '不合格(<60)', itemStyle: { color: '#f56c6c' } }
]
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}人 ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}\n{c}人'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
data
}
]
}
pieChart.setOption(option)
}
watch(period, () => {
loadAllData()
})
onMounted(() => {
// 设置默认周期为当前月
const now = new Date()
period.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
loadAllData()
setTimeout(initCharts, 100)
})
</script>
<style scoped lang="scss">
.reports {
.filter-card {
margin-bottom: 20px;
}
.stat-row {
margin-bottom: 20px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px;
color: #fff;
&.success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
&.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-value {
font-size: 32px;
font-weight: 700;
margin: 12px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
}
.page-card {
margin-bottom: 20px;
h3 {
margin: 0 0 20px 0;
font-size: 16px;
color: #303133;
}
}
.chart-container {
width: 100%;
height: 350px;
}
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="page-card">
<div class="search-bar">
<el-tree-select
v-model="searchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="所属科室"
clearable
check-strictly
/>
<el-date-picker
v-model="searchForm.period"
type="month"
placeholder="工资周期"
format="YYYY年MM月"
value-format="YYYY-MM"
/>
<el-select v-model="searchForm.status" placeholder="状态" clearable>
<el-option label="待确认" value="pending" />
<el-option label="已确认" value="confirmed" />
</el-select>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleBatchGenerate">批量生成</el-button>
<el-button type="warning" @click="handleBatchConfirm">批量确认</el-button>
</div>
<el-table :data="tableData" stripe v-loading="loading">
<el-table-column prop="staff_name" label="姓名" width="100" />
<el-table-column prop="department_name" label="科室" />
<el-table-column label="工资周期" width="120">
<template #default="{ row }">
{{ row.period_year }}{{ row.period_month }}
</template>
</el-table-column>
<el-table-column prop="base_salary" label="基本工资" width="120" align="right">
<template #default="{ row }">
¥{{ row.base_salary?.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="performance_score" label="绩效得分" width="100" align="center">
<template #default="{ row }">
<span :class="getScoreClass(row.performance_score)">{{ row.performance_score?.toFixed(1) }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_bonus" label="绩效奖金" width="120" align="right">
<template #default="{ row }">
¥{{ row.performance_bonus?.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="allowance" label="补贴" width="100" align="right">
<template #default="{ row }">
¥{{ row.allowance?.toLocaleString() || 0 }}
</template>
</el-table-column>
<el-table-column prop="deduction" label="扣款" width="100" align="right">
<template #default="{ row }">
¥{{ row.deduction?.toLocaleString() || 0 }}
</template>
</el-table-column>
<el-table-column prop="total_salary" label="应发工资" width="120" align="right">
<template #default="{ row }">
<span class="total-salary">¥{{ row.total_salary?.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'confirmed' ? 'success' : 'warning'">
{{ row.status === 'confirmed' ? '已确认' : '待确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'pending'" type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'pending'" type="success" link @click="handleConfirm(row)">确认</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 编辑弹窗 -->
<el-dialog v-model="dialogVisible" title="编辑工资" width="500px">
<el-form :model="form" label-width="100px">
<el-form-item label="绩效奖金">
<el-input-number v-model="form.performance_bonus" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="补贴">
<el-input-number v-model="form.allowance" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="扣款">
<el-input-number v-model="form.deduction" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 批量生成弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量生成工资" width="400px">
<el-form :model="batchForm" label-width="100px">
<el-form-item label="科室">
<el-tree-select
v-model="batchForm.department_id"
:data="deptTree"
:props="{ label: 'name', value: 'id' }"
placeholder="请选择科室"
check-strictly
/>
</el-form-item>
<el-form-item label="工资周期">
<el-date-picker
v-model="batchForm.period"
type="month"
placeholder="选择工资周期"
format="YYYY年MM月"
value-format="YYYY-MM"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" @click="doBatchGenerate" :loading="batchLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSalaryRecords, updateSalaryRecord, confirmSalary, batchGenerateSalary, batchConfirmSalary } from '@/api/salary'
import { getDepartmentTree } from '@/api/department'
const loading = ref(false)
const saving = ref(false)
const batchLoading = ref(false)
const tableData = ref([])
const deptTree = ref([])
const dialogVisible = ref(false)
const batchDialogVisible = ref(false)
const searchForm = reactive({
department_id: null,
period: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const form = reactive({
id: null,
performance_bonus: 0,
allowance: 0,
deduction: 0,
remark: ''
})
const batchForm = reactive({
department_id: null,
period: ''
})
function getScoreClass(score) {
if (score >= 90) return 'score-level excellent'
if (score >= 80) return 'score-level good'
if (score >= 60) return 'score-level average'
return 'score-level poor'
}
async function loadData() {
loading.value = true
try {
const [year, month] = searchForm.period ? searchForm.period.split('-') : ['', '']
const res = await getSalaryRecords({
department_id: searchForm.department_id,
period_year: year ? parseInt(year) : undefined,
period_month: month ? parseInt(month) : undefined,
status: searchForm.status,
page: pagination.page,
page_size: pagination.pageSize
})
tableData.value = res.data || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
async function loadDeptTree() {
try {
const res = await getDepartmentTree()
deptTree.value = res.data || []
} catch (error) {
console.error('加载科室树失败', error)
}
}
function resetSearch() {
searchForm.department_id = null
searchForm.period = ''
searchForm.status = ''
pagination.page = 1
loadData()
}
function handleEdit(row) {
form.id = row.id
form.performance_bonus = row.performance_bonus
form.allowance = row.allowance
form.deduction = row.deduction
form.remark = row.remark
dialogVisible.value = true
}
async function handleSave() {
saving.value = true
try {
await updateSalaryRecord(form.id, form)
ElMessage.success('保存成功')
dialogVisible.value = false
loadData()
} finally {
saving.value = false
}
}
async function handleConfirm(row) {
try {
await confirmSalary(row.id)
ElMessage.success('确认成功')
loadData()
} catch (error) {
console.error('确认失败', error)
}
}
function handleBatchGenerate() {
batchForm.department_id = null
batchForm.period = ''
batchDialogVisible.value = true
}
async function doBatchGenerate() {
if (!batchForm.department_id || !batchForm.period) {
ElMessage.warning('请填写完整信息')
return
}
const [year, month] = batchForm.period.split('-')
batchLoading.value = true
try {
await batchGenerateSalary({
department_id: batchForm.department_id,
period_year: parseInt(year),
period_month: parseInt(month)
})
ElMessage.success('批量生成成功')
batchDialogVisible.value = false
loadData()
} finally {
batchLoading.value = false
}
}
async function handleBatchConfirm() {
if (!searchForm.period) {
ElMessage.warning('请选择工资周期')
return
}
await ElMessageBox.confirm('确定要批量确认该周期的所有工资吗?', '提示', { type: 'warning' })
const [year, month] = searchForm.period.split('-')
try {
const res = await batchConfirmSalary({
period_year: parseInt(year),
period_month: parseInt(month),
department_id: searchForm.department_id
})
ElMessage.success(`成功确认 ${res.data?.count || 0} 条工资记录`)
loadData()
} catch (error) {
console.error('批量确认失败', error)
}
}
onMounted(() => {
loadData()
loadDeptTree()
})
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
.el-input, .el-select, .el-tree-select, .el-date-picker {
width: 160px;
}
}
.el-pagination {
margin-top: 20px;
justify-content: flex-end;
}
.total-salary {
font-weight: 700;
color: #409eff;
}
</style>

View File

@@ -0,0 +1,264 @@
<template>
<div class="menu-management">
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon> 新建菜单
</el-button>
<el-button type="success" @click="handleInit">
<el-icon><Refresh /></el-icon> 初始化默认菜单
</el-button>
</div>
<el-table
v-loading="loading"
:data="tableData"
row-key="id"
border
stripe
:tree-props="{ children: 'children' }"
style="width: 100%"
>
<el-table-column prop="menu_name" label="菜单名称" min-width="150" />
<el-table-column prop="menu_icon" label="图标" width="100">
<template #default="{ row }">
<el-icon v-if="row.menu_icon" size="20"><component :is="row.menu_icon" /></el-icon>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="150" />
<el-table-column prop="component" label="组件" width="150" />
<el-table-column prop="sort_order" label="排序" width="60" />
<el-table-column prop="is_visible" label="可见" width="60">
<template #default="{ row }">
<el-tag :type="row.is_visible ? 'success' : 'info'" size="small">
{{ row.is_visible ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_active" label="启用" width="60">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="父菜单" prop="parent_id">
<el-tree-select
v-model="form.parent_id"
:data="menuTree"
:props="{ label: 'menu_name', value: 'id', children: 'children' }"
placeholder="选择父菜单(留空为一级菜单)"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单类型" prop="menu_type">
<el-radio-group v-model="form.menu_type">
<el-radio label="menu">菜单</el-radio>
<el-radio label="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单名称" prop="menu_name">
<el-input v-model="form.menu_name" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单图标" prop="menu_icon">
<el-input v-model="form.menu_icon" placeholder="Element Plus 图标名称" />
</el-form-item>
<el-form-item label="路由路径" prop="path">
<el-input v-model="form.path" placeholder="/example" />
</el-form-item>
<el-form-item label="组件路径" prop="component">
<el-input v-model="form.component" placeholder="Example" />
</el-form-item>
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="system:menu:list" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="是否可见" prop="is_visible">
<el-switch v-model="form.is_visible" />
</el-form-item>
<el-form-item label="是否启用" prop="is_active">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMenuTree, getMenus, createMenu, updateMenu, deleteMenu, initDefaultMenus } from '@/api/menu'
const loading = ref(false)
const submitting = ref(false)
const tableData = ref([])
const menuTree = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref()
const form = reactive({
id: null,
parent_id: null,
menu_type: 'menu',
menu_name: '',
menu_icon: '',
path: '',
component: '',
permission: '',
sort_order: 0,
is_visible: true,
is_active: true
})
const rules = {
menu_name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入路由路径', trigger: 'blur' }]
}
async function loadData() {
loading.value = true
try {
const res = await getMenus()
tableData.value = res.data || []
} finally {
loading.value = false
}
}
async function loadMenuTree() {
try {
const res = await getMenuTree()
menuTree.value = res.data || []
} catch (error) {
console.error('加载菜单树失败', error)
}
}
function handleAdd() {
dialogTitle.value = '新建菜单'
Object.assign(form, {
id: null,
parent_id: null,
menu_type: 'menu',
menu_name: '',
menu_icon: '',
path: '',
component: '',
permission: '',
sort_order: 0,
is_visible: true,
is_active: true
})
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑菜单'
Object.assign(form, {
id: row.id,
parent_id: row.parent_id,
menu_type: row.menu_type,
menu_name: row.menu_name,
menu_icon: row.menu_icon,
path: row.path,
component: row.component,
permission: row.permission,
sort_order: row.sort_order,
is_visible: row.is_visible,
is_active: row.is_active
})
dialogVisible.value = true
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?', '提示', { type: 'warning' })
await deleteMenu(row.id)
ElMessage.success('删除成功')
loadData()
loadMenuTree()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败', error)
}
}
}
async function handleInit() {
try {
await ElMessageBox.confirm('确定要初始化默认菜单吗?(会覆盖现有菜单)', '提示', { type: 'warning' })
await initDefaultMenus()
ElMessage.success('初始化成功')
loadData()
loadMenuTree()
} catch (error) {
if (error !== 'cancel') {
console.error('初始化失败', error)
}
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (form.id) {
await updateMenu(form.id, form)
ElMessage.success('更新成功')
} else {
await createMenu(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
loadMenuTree()
} catch (error) {
console.error('保存失败', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
loadData()
loadMenuTree()
})
</script>
<style scoped lang="scss">
.menu-management {
padding: 20px;
}
.toolbar {
margin-bottom: 20px;
}
</style>