first commit
This commit is contained in:
16
frontend/src/App.vue
Normal file
16
frontend/src/App.vue
Normal 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>
|
||||
49
frontend/src/api/assessment.js
Normal file
49
frontend/src/api/assessment.js
Normal 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
21
frontend/src/api/auth.js
Normal 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)
|
||||
}
|
||||
31
frontend/src/api/department.js
Normal file
31
frontend/src/api/department.js
Normal 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}`)
|
||||
}
|
||||
51
frontend/src/api/finance.js
Normal file
51
frontend/src/api/finance.js
Normal 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}`)
|
||||
}
|
||||
8
frontend/src/api/index.js
Normal file
8
frontend/src/api/index.js
Normal 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'
|
||||
31
frontend/src/api/indicator.js
Normal file
31
frontend/src/api/indicator.js
Normal 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
36
frontend/src/api/menu.js
Normal 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')
|
||||
}
|
||||
66
frontend/src/api/performance_plan.js
Normal file
66
frontend/src/api/performance_plan.js
Normal 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}`)
|
||||
}
|
||||
65
frontend/src/api/request.js
Normal file
65
frontend/src/api/request.js
Normal 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
|
||||
41
frontend/src/api/salary.js
Normal file
41
frontend/src/api/salary.js
Normal 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
31
frontend/src/api/staff.js
Normal 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
42
frontend/src/api/stats.js
Normal 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 })
|
||||
}
|
||||
61
frontend/src/api/template.js
Normal file
61
frontend/src/api/template.js
Normal 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)
|
||||
}
|
||||
185
frontend/src/assets/main.scss
Normal file
185
frontend/src/assets/main.scss
Normal 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
23
frontend/src/main.js
Normal 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')
|
||||
115
frontend/src/router/index.js
Normal file
115
frontend/src/router/index.js
Normal 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
|
||||
30
frontend/src/stores/app.js
Normal file
30
frontend/src/stores/app.js
Normal 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
|
||||
}
|
||||
})
|
||||
2
frontend/src/stores/index.js
Normal file
2
frontend/src/stores/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useUserStore } from './user'
|
||||
export { useAppStore } from './app'
|
||||
48
frontend/src/stores/user.js
Normal file
48
frontend/src/stores/user.js
Normal 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
|
||||
}
|
||||
})
|
||||
1081
frontend/src/views/Dashboard.vue
Normal file
1081
frontend/src/views/Dashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
240
frontend/src/views/Layout.vue
Normal file
240
frontend/src/views/Layout.vue
Normal 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>
|
||||
154
frontend/src/views/Login.vue
Normal file
154
frontend/src/views/Login.vue
Normal 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>
|
||||
256
frontend/src/views/assessment/AssessmentDetail.vue
Normal file
256
frontend/src/views/assessment/AssessmentDetail.vue
Normal 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>
|
||||
310
frontend/src/views/assessment/Assessments.vue
Normal file
310
frontend/src/views/assessment/Assessments.vue
Normal 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>
|
||||
289
frontend/src/views/basic/Departments.vue
Normal file
289
frontend/src/views/basic/Departments.vue
Normal 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>
|
||||
295
frontend/src/views/basic/Indicators.vue
Normal file
295
frontend/src/views/basic/Indicators.vue
Normal 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>
|
||||
312
frontend/src/views/basic/Staff.vue
Normal file
312
frontend/src/views/basic/Staff.vue
Normal 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>
|
||||
637
frontend/src/views/basic/Templates.vue
Normal file
637
frontend/src/views/basic/Templates.vue
Normal 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>
|
||||
613
frontend/src/views/finance/Finance.vue
Normal file
613
frontend/src/views/finance/Finance.vue
Normal 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>
|
||||
741
frontend/src/views/plan/Plans.vue
Normal file
741
frontend/src/views/plan/Plans.vue
Normal 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>
|
||||
366
frontend/src/views/reports/Reports.vue
Normal file
366
frontend/src/views/reports/Reports.vue
Normal 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>
|
||||
334
frontend/src/views/salary/Salary.vue
Normal file
334
frontend/src/views/salary/Salary.vue
Normal 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>
|
||||
264
frontend/src/views/system/Menus.vue
Normal file
264
frontend/src/views/system/Menus.vue
Normal 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>
|
||||
Reference in New Issue
Block a user