feat: 新增绩效系统核心模块并修复菜单路径问题

本次提交完成多项核心功能开发与优化:
1. 修复菜单路径计算逻辑,目录节点路径拼接祖先路径
2. 为绩效相关实体类添加日期格式化注解,统一参数绑定格式
3. 新增10+个绩效系统API接口,覆盖部门、薪资、考核等模块
4. 新增绩效系统首页、部门管理、薪资明细等多个页面组件
This commit is contained in:
砂糖
2026-07-10 10:43:16 +08:00
parent 94876b1c86
commit ef0614f987
23 changed files with 3236 additions and 2 deletions

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询月度绩效考核记录列表
export function listPerfAppraisal(query) {
return request({
url: '/perf/appraisal/list',
method: 'get',
params: query
})
}
// 查询月度绩效考核记录详细
export function getPerfAppraisal(id) {
return request({
url: '/perf/appraisal/' + id,
method: 'get'
})
}
// 新增月度绩效考核记录
export function addPerfAppraisal(data) {
return request({
url: '/perf/appraisal',
method: 'post',
data: data
})
}
// 修改月度绩效考核记录
export function updatePerfAppraisal(data) {
return request({
url: '/perf/appraisal',
method: 'put',
data: data
})
}
// 删除月度绩效考核记录
export function delPerfAppraisal(ids) {
return request({
url: '/perf/appraisal/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询考核维度评分明细列表
export function listPerfAppraisalDetail(query) {
return request({
url: '/perf/appraisalDetail/list',
method: 'get',
params: query
})
}
// 查询考核维度评分明细详细
export function getPerfAppraisalDetail(id) {
return request({
url: '/perf/appraisalDetail/' + id,
method: 'get'
})
}
// 新增考核维度评分明细
export function addPerfAppraisalDetail(data) {
return request({
url: '/perf/appraisalDetail',
method: 'post',
data: data
})
}
// 修改考核维度评分明细
export function updatePerfAppraisalDetail(data) {
return request({
url: '/perf/appraisalDetail',
method: 'put',
data: data
})
}
// 删除考核维度评分明细
export function delPerfAppraisalDetail(ids) {
return request({
url: '/perf/appraisalDetail/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询绩效系统部门列表
export function listPerfDept(query) {
return request({
url: '/perf/dept/list',
method: 'get',
params: query
})
}
// 查询绩效系统部门详细
export function getPerfDept(id) {
return request({
url: '/perf/dept/' + id,
method: 'get'
})
}
// 新增绩效系统部门
export function addPerfDept(data) {
return request({
url: '/perf/dept',
method: 'post',
data: data
})
}
// 修改绩效系统部门
export function updatePerfDept(data) {
return request({
url: '/perf/dept',
method: 'put',
data: data
})
}
// 删除绩效系统部门
export function delPerfDept(ids) {
return request({
url: '/perf/dept/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询车间考核参数配置列表
export function listPerfDeptConfig(query) {
return request({
url: '/perf/deptConfig/list',
method: 'get',
params: query
})
}
// 查询车间考核参数配置详细
export function getPerfDeptConfig(id) {
return request({
url: '/perf/deptConfig/' + id,
method: 'get'
})
}
// 新增车间考核参数配置
export function addPerfDeptConfig(data) {
return request({
url: '/perf/deptConfig',
method: 'post',
data: data
})
}
// 修改车间考核参数配置
export function updatePerfDeptConfig(data) {
return request({
url: '/perf/deptConfig',
method: 'put',
data: data
})
}
// 删除车间考核参数配置
export function delPerfDeptConfig(ids) {
return request({
url: '/perf/deptConfig/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询车间扣款/奖励项目配置列表
export function listPerfDeptItemConfig(query) {
return request({
url: '/perf/deptItemConfig/list',
method: 'get',
params: query
})
}
// 查询车间扣款/奖励项目配置详细
export function getPerfDeptItemConfig(id) {
return request({
url: '/perf/deptItemConfig/' + id,
method: 'get'
})
}
// 新增车间扣款/奖励项目配置
export function addPerfDeptItemConfig(data) {
return request({
url: '/perf/deptItemConfig',
method: 'post',
data: data
})
}
// 修改车间扣款/奖励项目配置
export function updatePerfDeptItemConfig(data) {
return request({
url: '/perf/deptItemConfig',
method: 'put',
data: data
})
}
// 删除车间扣款/奖励项目配置
export function delPerfDeptItemConfig(ids) {
return request({
url: '/perf/deptItemConfig/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询车间月度汇总列表
export function listPerfDeptSummary(query) {
return request({
url: '/perf/deptSummary/list',
method: 'get',
params: query
})
}
// 查询车间月度汇总详细
export function getPerfDeptSummary(id) {
return request({
url: '/perf/deptSummary/' + id,
method: 'get'
})
}
// 新增车间月度汇总
export function addPerfDeptSummary(data) {
return request({
url: '/perf/deptSummary',
method: 'post',
data: data
})
}
// 修改车间月度汇总
export function updatePerfDeptSummary(data) {
return request({
url: '/perf/deptSummary',
method: 'put',
data: data
})
}
// 删除车间月度汇总
export function delPerfDeptSummary(ids) {
return request({
url: '/perf/deptSummary/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询员工绩效薪资基本信息列表
export function listPerfEmployeeInfo(query) {
return request({
url: '/perf/employeeInfo/list',
method: 'get',
params: query
})
}
// 查询员工绩效薪资基本信息详细
export function getPerfEmployeeInfo(id) {
return request({
url: '/perf/employeeInfo/' + id,
method: 'get'
})
}
// 新增员工绩效薪资基本信息
export function addPerfEmployeeInfo(data) {
return request({
url: '/perf/employeeInfo',
method: 'post',
data: data
})
}
// 修改员工绩效薪资基本信息
export function updatePerfEmployeeInfo(data) {
return request({
url: '/perf/employeeInfo',
method: 'put',
data: data
})
}
// 删除员工绩效薪资基本信息
export function delPerfEmployeeInfo(ids) {
return request({
url: '/perf/employeeInfo/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询岗位绩效考核模板列表
export function listPerfPositionTemplate(query) {
return request({
url: '/perf/positionTemplate/list',
method: 'get',
params: query
})
}
// 查询岗位绩效考核模板详细
export function getPerfPositionTemplate(id) {
return request({
url: '/perf/positionTemplate/' + id,
method: 'get'
})
}
// 新增岗位绩效考核模板
export function addPerfPositionTemplate(data) {
return request({
url: '/perf/positionTemplate',
method: 'post',
data: data
})
}
// 修改岗位绩效考核模板
export function updatePerfPositionTemplate(data) {
return request({
url: '/perf/positionTemplate',
method: 'put',
data: data
})
}
// 删除岗位绩效考核模板
export function delPerfPositionTemplate(ids) {
return request({
url: '/perf/positionTemplate/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询月度薪资计算记录列表
export function listPerfSalary(query) {
return request({
url: '/perf/salary/list',
method: 'get',
params: query
})
}
// 查询月度薪资计算记录详细
export function getPerfSalary(id) {
return request({
url: '/perf/salary/' + id,
method: 'get'
})
}
// 新增月度薪资计算记录
export function addPerfSalary(data) {
return request({
url: '/perf/salary',
method: 'post',
data: data
})
}
// 修改月度薪资计算记录
export function updatePerfSalary(data) {
return request({
url: '/perf/salary',
method: 'put',
data: data
})
}
// 删除月度薪资计算记录
export function delPerfSalary(ids) {
return request({
url: '/perf/salary/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询考核维度明细列表
export function listPerfTemplateDimension(query) {
return request({
url: '/perf/templateDimension/list',
method: 'get',
params: query
})
}
// 查询考核维度明细详细
export function getPerfTemplateDimension(id) {
return request({
url: '/perf/templateDimension/' + id,
method: 'get'
})
}
// 新增考核维度明细
export function addPerfTemplateDimension(data) {
return request({
url: '/perf/templateDimension',
method: 'post',
data: data
})
}
// 修改考核维度明细
export function updatePerfTemplateDimension(data) {
return request({
url: '/perf/templateDimension',
method: 'put',
data: data
})
}
// 删除考核维度明细
export function delPerfTemplateDimension(ids) {
return request({
url: '/perf/templateDimension/' + ids,
method: 'delete'
})
}

View File

@@ -0,0 +1,580 @@
<template>
<div class="perf-dept-page">
<div class="main-layout">
<!-- Left: Department List -->
<el-card class="dept-list-card">
<div slot="header" class="card-header">
<span class="header-title">部门列表</span>
<el-tooltip content="点击后输入部门名称,按回车保存" placement="top" :open-delay="500">
<el-button size="mini" type="primary" icon="el-icon-plus" :disabled="editingDeptId !== null" @click="startAddDept">新增</el-button>
</el-tooltip>
</div>
<div class="dept-tip">左侧选择部门右侧显示该部门的员工</div>
<div v-loading="deptLoading" class="dept-custom-list">
<!-- 新增行 -->
<div v-if="showAddInput" class="dept-item dept-item--input">
<el-input
v-model="newDeptName"
size="small"
placeholder="请输入部门名称,按回车保存"
ref="addInput"
:disabled="deptSubmitting"
@keyup.enter.native="submitAddDept"
@keyup.esc.native="cancelAddDept"
@blur="submitAddDept"
>
<template v-if="deptSubmitting" slot="prefix"><i class="el-icon-loading" /></template>
</el-input>
</div>
<div
v-for="dept in deptList"
:key="dept.id"
class="dept-item"
:class="{ active: currentDeptId === dept.id }"
@click="handleDeptSelect(dept)"
>
<!-- 编辑态 -->
<template v-if="editingDeptId === dept.id">
<el-input
v-model="editDeptName"
size="small"
ref="editInput"
:disabled="deptSubmitting"
@keyup.enter.native="submitEditDept(dept)"
@keyup.esc.native="cancelEditDept"
@blur="submitEditDept(dept)"
@click.stop
>
<template v-if="deptSubmitting" slot="prefix"><i class="el-icon-loading" /></template>
</el-input>
</template>
<!-- 展示态 -->
<template v-else>
<span class="dept-name">{{ dept.deptName }}</span>
<span class="dept-actions">
<el-tooltip content="修改部门名称" placement="top" :open-delay="500">
<el-button type="text" size="mini" icon="el-icon-edit" @click.stop="startEditDept(dept)">编辑</el-button>
</el-tooltip>
<el-tooltip content="删除此部门及其员工" placement="top" :open-delay="500">
<el-button type="text" size="mini" icon="el-icon-delete" @click.stop="handleDelDept(dept)">删除</el-button>
</el-tooltip>
</span>
</template>
</div>
<div v-if="!deptLoading && deptList.length === 0 && !showAddInput" class="dept-empty">暂无部门数据</div>
</div>
</el-card>
<!-- Right: Employee List -->
<el-card class="employee-list-card">
<div slot="header" class="card-header">
<span class="header-title">员工列表</span>
<div class="header-actions">
<el-input v-model="empQuery.name" size="small" placeholder="姓名" clearable style="width: 140px" @keyup.enter.native="loadEmpList" />
<el-select v-model="empQuery.isLeave" size="small" placeholder="在职状态" clearable style="width: 110px" @change="loadEmpList">
<el-option label="在职" :value="0" />
<el-option label="离职" :value="1" />
</el-select>
<el-button size="small" type="primary" icon="el-icon-search" @click="loadEmpList">查询</el-button>
</div>
</div>
<el-table v-loading="empLoading" :data="empList" stripe :empty-text="currentDeptId ? '暂无员工数据' : '请先在左侧点击选择一个部门'">
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="dept" label="部门" min-width="120" />
<el-table-column prop="jobType" label="岗位工种" min-width="120" />
<el-table-column prop="isLeave" label="在职状态" min-width="90" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.isLeave === 0 ? 'success' : 'info'" size="small">{{ scope.row.isLeave === 0 ? '在职' : '离职' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="baseSalary" label="底薪(元)" min-width="110" align="right" />
<el-table-column prop="perfBase" label="绩效基数(元)" min-width="120" align="right" />
<el-table-column prop="posCoeffDefault" label="岗位系数" min-width="100" align="center" />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template slot-scope="scope">
<el-tooltip content="修改该员工的绩效薪资信息" placement="top" :open-delay="500">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEditEmp(scope.row)">编辑</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
:current-page="empQuery.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="empQuery.pageSize"
:total="empTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleEmpSizeChange"
@current-change="handleEmpPageChange"
/>
</div>
</el-card>
</div>
<!-- 底薪编辑弹窗 -->
<el-dialog :visible.sync="empDialogVisible" title="修改薪资信息" width="480px" append-to-body @closed="resetEmpForm">
<!-- 员工信息卡片 -->
<div class="emp-info-card">
<div class="emp-info-row" v-for="(item, idx) in empInfoRows" :key="idx" :class="{ 'emp-info-row--alt': idx % 2 === 1 }">
<span class="emp-info-label">{{ item.label }}</span>
<span class="emp-info-value" :class="{ 'emp-info-value--dim': !item.value }">{{ item.value || '-' }}</span>
</div>
</div>
<!-- 薪资编辑区 -->
<div class="emp-salary-section">
<div class="emp-salary-title">薪资信息</div>
<el-form ref="empFormRef" :model="empForm" :rules="empRules" label-width="96px" size="small">
<el-form-item label="底薪(元)" prop="baseSalary">
<el-input v-model="empForm.baseSalary" placeholder="请输入底薪" />
</el-form-item>
<el-form-item label="绩效基数(元)" prop="perfBase">
<el-input v-model="empForm.perfBase" placeholder="请输入绩效基数" />
</el-form-item>
<el-form-item label="岗位系数" prop="posCoeffDefault">
<el-input v-model="empForm.posCoeffDefault" placeholder="请输入岗位系数" />
</el-form-item>
</el-form>
</div>
<div slot="footer">
<el-button @click="empDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="empSubmitting" @click="submitEmp">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPerfDept, addPerfDept, updatePerfDept, delPerfDept } from '@/api/perf/dept'
import { listEmployeeInfo, updateEmployeeInfo } from '@/api/wms/employeeInfo'
export default {
name: 'PerfDept',
data() {
return {
// 部门
deptLoading: false,
deptList: [],
currentDeptId: null,
currentDeptName: '',
showAddInput: false,
newDeptName: '',
deptSubmitting: false,
editingDeptId: null,
editDeptName: '',
// 员工
empLoading: false,
empList: [],
empTotal: 0,
empQuery: {
pageNum: 1,
pageSize: 10,
name: '',
isLeave: null
},
empDialogVisible: false,
empSubmitting: false,
empForm: {},
empRules: {
baseSalary: [
{ required: true, message: '请输入底薪', trigger: 'blur' },
{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入有效的金额', trigger: 'blur' }
],
perfBase: [
{ required: true, message: '请输入绩效基数', trigger: 'blur' },
{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入有效的金额', trigger: 'blur' }
],
posCoeffDefault: [
{ pattern: /^\d+(\.\d{1,2})?$/, message: '请输入有效的系数', trigger: 'blur' }
]
}
}
},
created() {
this.loadDeptList()
},
computed: {
empInfoRows() {
return [
{ label: '姓名', value: this.empForm.name },
{ label: '部门', value: this.empForm.dept },
{ label: '岗位工种', value: this.empForm.jobType },
{ label: '在职状态', value: this.empForm.isLeave === 0 ? '在职' : this.empForm.isLeave === 1 ? '离职' : '' },
{ label: '备注', value: this.empForm.remark }
]
}
},
methods: {
// ========== 部门 ==========
loadDeptList() {
this.deptLoading = true
listPerfDept({}).then(res => {
this.deptList = res.rows || []
}).finally(() => {
this.deptLoading = false
})
},
handleDeptSelect(row) {
if (row) {
this.currentDeptId = row.id
this.currentDeptName = row.deptName
this.empQuery.pageNum = 1
this.loadEmpList()
} else {
this.currentDeptId = null
this.currentDeptName = ''
this.empList = []
this.empTotal = 0
}
},
startAddDept() {
this.showAddInput = true
this.newDeptName = ''
this.$nextTick(() => { this.$refs.addInput && this.$refs.addInput.focus() })
},
cancelAddDept() {
this.showAddInput = false
this.newDeptName = ''
},
submitAddDept() {
const name = this.newDeptName.trim()
if (!name) {
this.showAddInput = false
return
}
if (this.deptSubmitting) return
this.deptSubmitting = true
addPerfDept({ deptName: name, orderNum: 0, hasBaoSalary: 0, status: '0' }).then(() => {
this.$message.success('新增成功')
this.showAddInput = false
this.newDeptName = ''
this.loadDeptList()
}).finally(() => {
this.deptSubmitting = false
})
},
startEditDept(dept) {
this.editingDeptId = dept.id
this.editDeptName = dept.deptName
this.$nextTick(() => {
const input = this.$refs.editInput
if (input && input[0]) input[0].focus()
})
},
cancelEditDept() {
this.editingDeptId = null
this.editDeptName = ''
},
submitEditDept(dept) {
const name = this.editDeptName.trim()
if (!name || name === dept.deptName) {
this.editingDeptId = null
return
}
if (this.deptSubmitting) return
this.deptSubmitting = true
updatePerfDept({ id: dept.id, deptName: name }).then(() => {
this.$message.success('修改成功')
this.editingDeptId = null
this.loadDeptList()
}).finally(() => {
this.deptSubmitting = false
})
},
handleDelDept(row) {
this.$confirm(`确认删除部门"${row.deptName}"吗?删除后该部门下的员工数据也将一并移除。`, '提示', { type: 'warning' }).then(() => {
delPerfDept(row.id).then(() => {
this.$message.success('删除成功')
if (this.currentDeptId === row.id) {
this.currentDeptId = null
this.currentDeptName = ''
this.empList = []
this.empTotal = 0
}
this.loadDeptList()
})
}).catch(() => {})
},
// ========== 员工 ==========
loadEmpList() {
if (!this.currentDeptId) return
this.empLoading = true
const params = {
...this.empQuery,
dept: this.currentDeptName
}
// 处理 isLeave 空字符串,不传无效值
if (params.isLeave === '' || params.isLeave === null || params.isLeave === undefined) {
delete params.isLeave
}
listEmployeeInfo(params).then(res => {
this.empList = res.rows || []
this.empTotal = res.total || 0
}).finally(() => {
this.empLoading = false
})
},
handleEmpSizeChange(val) {
this.empQuery.pageSize = val
this.loadEmpList()
},
handleEmpPageChange(val) {
this.empQuery.pageNum = val
this.loadEmpList()
},
handleEditEmp(row) {
this.empForm = { ...row }
this.empDialogVisible = true
this.$nextTick(() => { this.$refs.empFormRef && this.$refs.empFormRef.clearValidate() })
},
submitEmp() {
this.$refs.empFormRef.validate(valid => {
if (!valid) return
this.empSubmitting = true
updateEmployeeInfo({
infoId: this.empForm.infoId,
baseSalary: this.empForm.baseSalary,
perfBase: this.empForm.perfBase,
posCoeffDefault: this.empForm.posCoeffDefault
}).then(() => {
this.$message.success('修改成功')
this.empDialogVisible = false
this.loadEmpList()
}).finally(() => {
this.empSubmitting = false
})
})
},
resetEmpForm() {
this.empForm = {}
}
}
}
</script>
<style lang="scss" scoped>
.perf-dept-page {
height: calc(100vh - 84px);
padding: 0;
overflow: hidden;
}
.main-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
height: 100%;
padding: 12px;
}
.dept-list-card,
.employee-list-card {
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
overflow: hidden;
::v-deep .el-card__header {
border: none;
padding: 10px 16px;
flex-shrink: 0;
}
::v-deep .el-card__body {
border: none;
flex: 1;
overflow: hidden;
}
}
.dept-list-card ::v-deep .el-card__body {
padding: 0;
display: flex;
flex-direction: column;
}
.employee-list-card {
::v-deep .el-card__body {
display: flex;
flex-direction: column;
}
}
.employee-list-card ::v-deep .el-table {
border: none;
flex: 1;
&::before, &::after {
display: none;
}
th {
border: none;
}
td {
border: none;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
.dept-custom-list {
flex: 1;
overflow-y: auto;
}
.dept-tip {
padding: 6px 12px;
font-size: 12px;
color: #909399;
background-color: #f4f4f5;
flex-shrink: 0;
}
.dept-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: #f5f7fa;
}
&.active {
background-color: #ecf5ff;
.dept-name {
color: #409eff;
font-weight: 600;
}
}
.dept-name {
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.dept-actions {
display: flex;
gap: 2px;
}
&--input {
padding: 4px 8px;
background-color: #fff;
}
}
.dept-empty {
padding: 24px 0;
text-align: center;
color: #c0c4cc;
font-size: 13px;
}
.pagination-wrapper {
margin-top: 12px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.emp-field-readonly {
color: #606266;
font-size: 13px;
line-height: 32px;
}
/* 员工信息卡片 */
.emp-info-card {
margin-bottom: 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
.emp-info-row {
display: flex;
align-items: center;
padding: 0;
min-height: 36px;
transition: background-color 0.12s;
&:hover {
background-color: #f5f7fa;
}
&--alt {
background-color: #fafbfc;
}
}
.emp-info-label {
flex: 0 0 80px;
padding: 6px 12px;
font-size: 12px;
color: #909399;
text-align: right;
letter-spacing: 1px;
user-select: none;
}
.emp-info-value {
flex: 1;
padding: 6px 12px 6px 0;
font-size: 14px;
font-weight: 500;
color: #303133;
letter-spacing: 0.5px;
&--dim {
color: #c0c4cc;
font-weight: 400;
}
}
/* 薪资编辑区 */
.emp-salary-section {
.emp-salary-title {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
padding-left: 2px;
letter-spacing: 1px;
}
::v-deep .el-form-item {
margin-bottom: 14px;
&:last-child {
margin-bottom: 0;
}
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<div class="dept-config-panel" v-loading="loading">
<div class="panel-body">
<div v-if="!hasConfig && !loading" class="empty-state">
<el-empty description="暂无考核参数配置">
<el-button type="primary" size="small" @click="handleAdd">新增配置</el-button>
</el-empty>
</div>
<el-form v-else ref="formRef" :model="form" :rules="rules" label-width="130px" size="small" class="config-form">
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="月产量目标" prop="monthlyProductionTarget">
<el-input-number v-model="form.monthlyProductionTarget" :min="0" :precision="2" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="产量单位" prop="productionUnit">
<el-select v-model="form.productionUnit" placeholder="请选择" style="width: 100%">
<el-option label="吨" value="吨" />
<el-option label="m" value="m" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="绩效单价(元)" prop="perfUnitPrice">
<el-input-number v-model="form.perfUnitPrice" :min="0" :precision="2" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="生产班数" prop="productionShifts">
<el-input-number v-model="form.productionShifts" :min="0" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="停机保底班数" prop="guaranteedShifts">
<el-input-number v-model="form.guaranteedShifts" :min="0" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="车间系数" prop="deptCoeff">
<el-input-number v-model="form.deptCoeff" :min="0" :precision="2" :step="0.1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="薪资模式" prop="salaryMode">
<el-radio-group v-model="form.salaryMode">
<el-radio :label="0">普通模式</el-radio>
<el-radio :label="1">保底模式</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.salaryMode === 1">
<el-form-item label="保底薪资除数" prop="baseDivisor">
<el-input-number v-model="form.baseDivisor" :min="1" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="16">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: center; padding-top: 12px;">
<el-button type="primary" size="small" :loading="submitting" @click="handleSave">保存</el-button>
<el-button size="small" v-if="!isNew" @click="handleReset">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
</div>
</template>
<script>
import { listPerfDeptConfig, addPerfDeptConfig, updatePerfDeptConfig } from '@/api/perf/deptConfig'
export default {
name: 'DeptConfigPanel',
props: {
deptId: { type: [Number, String], default: null },
period: { type: String, default: '' }
},
data() {
return {
loading: false,
submitting: false,
hasConfig: false,
isNew: false,
configId: null,
form: this.getEmptyForm(),
rules: {
monthlyProductionTarget: [{ required: true, message: '请输入月产量目标', trigger: 'blur' }],
productionUnit: [{ required: true, message: '请选择产量单位', trigger: 'change' }],
perfUnitPrice: [{ required: true, message: '请输入绩效单价', trigger: 'blur' }],
productionShifts: [{ required: true, message: '请输入生产班数', trigger: 'blur' }],
deptCoeff: [{ required: true, message: '请输入车间系数', trigger: 'blur' }]
}
}
},
watch: {
deptId: { immediate: true, handler: 'loadData' },
period: 'loadData'
},
methods: {
getEmptyForm() {
return {
monthlyProductionTarget: null,
productionUnit: '吨',
perfUnitPrice: null,
productionShifts: null,
guaranteedShifts: 0,
deptCoeff: 1.0,
salaryMode: 0,
baseDivisor: 60,
remark: ''
}
},
loadData() {
if (!this.deptId || !this.period) return
this.loading = true
this.hasConfig = false
this.isNew = false
this.configId = null
listPerfDeptConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
const rows = res.rows || []
if (rows.length > 0) {
const item = rows[0]
this.hasConfig = true
this.isNew = false
this.configId = item.id
this.form = {
monthlyProductionTarget: item.monthlyProductionTarget,
productionUnit: item.productionUnit || '吨',
perfUnitPrice: item.perfUnitPrice,
productionShifts: item.productionShifts,
guaranteedShifts: item.guaranteedShifts || 0,
deptCoeff: item.deptCoeff ?? 1.0,
salaryMode: item.salaryMode ?? 0,
baseDivisor: item.baseDivisor ?? 60,
remark: item.remark || ''
}
} else {
this.hasConfig = false
this.form = this.getEmptyForm()
}
}).finally(() => {
this.loading = false
})
},
handleAdd() {
this.hasConfig = true
this.isNew = true
this.configId = null
this.form = this.getEmptyForm()
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.clearValidate()
})
},
handleSave() {
this.$refs.formRef.validate(valid => {
if (!valid) return
this.submitting = true
const data = {
...this.form,
deptId: this.deptId,
month: this.period + '-01'
}
if (this.isNew) {
addPerfDeptConfig(data).then(() => {
this.$message.success('新增成功')
this.loadData()
}).finally(() => {
this.submitting = false
})
} else {
data.id = this.configId
updatePerfDeptConfig(data).then(() => {
this.$message.success('保存成功')
this.loadData()
}).finally(() => {
this.submitting = false
})
}
})
},
handleReset() {
this.loadData()
}
}
}
</script>
<style lang="scss" scoped>
.dept-config-panel {
height: 100%;
overflow-y: auto;
background-color: #fff;
}
.panel-body {
padding: 24px 24px 40px;
}
.config-form {
max-width: 900px;
margin: 0 auto;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="page-header">
<div class="header-left">
<span class="breadcrumb">{{ deptName }} · {{ period }}</span>
</div>
<div class="header-right">
<div class="custom-tabs">
<div
v-for="tab in tabOptions"
:key="tab.key"
class="custom-tab"
:class="{ active: activeTab === tab.key }"
@click="$emit('update:activeTab', tab.key)"
>{{ tab.label }}</div>
</div>
<div class="header-actions">
<el-tooltip content="刷新" placement="top">
<el-button class="refresh-btn" size="mini" icon="el-icon-refresh" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PageHeader',
props: {
deptName: { type: String, default: '' },
period: { type: String, default: '' },
activeTab: { type: String, default: '' },
tabOptions: {
type: Array,
default: () => [
{ key: 'perfConfig', label: '考核配置' },
{ key: 'salary', label: '薪资明细' },
{ key: 'summary', label: '统计' }
]
}
}
}
</script>
<style lang="scss" scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 20px;
border-bottom: 1px solid #e4e7ed;
background-color: #fff;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
flex-shrink: 0;
}
.breadcrumb {
font-size: 13px;
color: #606266;
white-space: nowrap;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background-color: #409eff;
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
justify-content: flex-end;
}
.custom-tabs {
display: flex;
align-items: center;
gap: 0;
}
.custom-tab {
position: relative;
padding: 0 16px;
height: 40px;
display: flex;
align-items: center;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: color 0.2s;
user-select: none;
&:hover {
color: #409eff;
}
&.active {
color: #409eff;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 10px;
right: 10px;
height: 2px;
background-color: #409eff;
border-radius: 1px;
}
}
}
.header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.refresh-btn {
border: none;
font-size: 14px;
color: #909399;
&:hover {
color: #409eff;
background-color: #f0f5ff;
}
}
</style>

View File

@@ -0,0 +1,669 @@
<template>
<div class="perf-config-panel">
<!-- Section 0: 考核参数 -->
<div class="config-section">
<div class="section-header">
<span class="section-title">考核参数</span>
<el-button size="small" type="primary" :loading="cfgSubmitting" @click="handleSaveCfg">{{ cfgId ? '保存' : '新增' }}</el-button>
</div>
<div class="cfg-form" v-loading="cfgLoading">
<el-form v-if="cfgReady" ref="cfgFormRef" :model="cfgForm" :rules="cfgRules" label-width="110px" size="small" inline>
<el-form-item label="月产量目标" prop="monthlyProductionTarget">
<el-input v-model="cfgForm.monthlyProductionTarget" placeholder="月产量目标" style="width: 120px" />
</el-form-item>
<el-form-item label="产量单位" prop="productionUnit">
<el-select v-model="cfgForm.productionUnit" style="width: 80px">
<el-option label="吨" value="吨" />
<el-option label="m" value="m" />
</el-select>
</el-form-item>
<el-form-item label="绩效单价(元)" prop="perfUnitPrice">
<el-input v-model="cfgForm.perfUnitPrice" placeholder="绩效单价" style="width: 110px" />
</el-form-item>
<el-form-item label="生产班数" prop="productionShifts">
<el-input v-model="cfgForm.productionShifts" placeholder="班数" style="width: 80px" />
</el-form-item>
<el-form-item label="保底班数" prop="guaranteedShifts">
<el-input v-model="cfgForm.guaranteedShifts" placeholder="保底班数" style="width: 80px" />
</el-form-item>
<el-form-item label="车间系数" prop="deptCoeff">
<el-input v-model="cfgForm.deptCoeff" placeholder="系数" style="width: 80px" />
</el-form-item>
<el-form-item label="薪资模式" prop="salaryMode">
<el-radio-group v-model="cfgForm.salaryMode" size="small">
<el-radio :label="0">普通</el-radio>
<el-radio :label="1">保底</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="保底薪资除数" prop="baseDivisor" v-if="cfgForm.salaryMode === 1">
<el-input v-model="cfgForm.baseDivisor" placeholder="除数" style="width: 80px" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="cfgForm.remark" placeholder="备注" style="width: 200px" />
</el-form-item>
</el-form>
</div>
</div>
<!-- Section 1: 扣款/奖励项目配置 -->
<div class="config-section">
<div class="section-header">
<span class="section-title">扣款/奖励项目配置</span>
<el-button size="small" type="primary" icon="el-icon-plus" @click="handleAddItem">新增项目</el-button>
</div>
<el-table :data="itemList" stripe size="small" v-loading="itemLoading" empty-text="暂无项目配置">
<el-table-column label="类型" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.itemType === 2 ? 'success' : 'danger'" size="small">
{{ scope.row.itemType === 2 ? '奖励' : '扣款' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemName" label="项目名称" min-width="160" />
<el-table-column label="月份" width="100" align="center">
<template slot-scope="scope">{{ scope.row.month ? (scope.row.month).substring(0, 7) : '-' }}</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="60" align="center" />
<el-table-column label="启用" width="60" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" :active-value="1" :inactive-value="0" size="small" @change="handleToggleItemEnabled(scope.row)" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="140" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEditItem(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" style="color: #f56c6c" @click="handleDelItem(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Section 2: 岗位绩效考核模板 -->
<div class="config-section" v-loading="ctxLoading">
<div class="section-header">
<span class="section-title">岗位绩效考核模板 <span class="section-hint">右键 tab 可编辑或删除</span></span>
<el-button size="small" type="primary" icon="el-icon-plus" @click="handleAddTemplate">新增岗位</el-button>
</div>
<div v-if="templateList.length" class="template-tabs">
<span
v-for="tpl in templateList"
:key="tpl.id"
class="template-tab"
:class="{ 'template-tab--active': activeTemplateId === String(tpl.id) }"
@click="activeTemplateId = String(tpl.id); loadDims()"
@contextmenu.prevent.stop="onTabContextMenu($event, tpl)"
>{{ tpl.positionName }}</span>
</div>
<!-- 维度表格 -->
<div v-if="activeTpl" class="dim-section">
<div class="dim-toolbar">
<span class="dim-subtitle">{{ activeTpl.positionName }} 考核维度</span>
<el-button size="small" type="primary" icon="el-icon-plus" @click="handleAddDim">新增维度</el-button>
</div>
<el-table :data="dimList" stripe size="small" v-loading="dimLoading" empty-text="暂无维度配置">
<el-table-column prop="seq" label="序号" width="60" align="center" />
<el-table-column prop="dimName" label="考核维度" min-width="120" />
<el-table-column prop="weight" label="权重" width="70" align="center" />
<el-table-column prop="indicator" label="量化指标" min-width="140" show-overflow-tooltip />
<el-table-column prop="targetValue" label="目标值" min-width="100" />
<el-table-column prop="scoreRule" label="评分规则" min-width="180" show-overflow-tooltip />
<el-table-column label="启用" width="60" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isEnabled" :active-value="1" :inactive-value="0" size="small" @change="handleToggleDimEnabled(scope.row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-edit" @click="handleEditDim(scope.row)">编辑</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" style="color: #f56c6c" @click="handleDelDim(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-else class="dim-empty">点击 tab 查看维度右键 tab 可编辑或删除模板</div>
</div>
<!-- 右键菜单 -->
<div
v-show="ctxMenu.visible"
class="ctx-menu"
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
>
<div class="ctx-menu-item" @click="ctxEditTemplate">编辑模板</div>
<div class="ctx-menu-item ctx-menu-item--danger" @click="ctxDelTemplate">删除模板</div>
</div>
<!-- Item Dialog -->
<el-dialog :visible.sync="itemDialogVisible" :title="itemDialogTitle" width="480px" append-to-body @closed="resetItemForm">
<el-form ref="itemFormRef" :model="itemForm" :rules="itemRules" label-width="90px" size="small">
<el-form-item label="类型" prop="itemType">
<el-radio-group v-model="itemForm.itemType">
<el-radio :label="1">扣款项目</el-radio>
<el-radio :label="2">奖励项目</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="月份" prop="month">
<el-date-picker v-model="itemForm.month" type="month" placeholder="选择月份" value-format="yyyy-MM-dd" style="width: 100%" />
</el-form-item>
<el-form-item label="项目名称" prop="itemName">
<el-input v-model="itemForm.itemName" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="排序">
<el-input v-model="itemForm.sortOrder" placeholder="排序号" />
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="itemForm.isEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="itemForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="itemDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="itemSubmitting" @click="submitItem">保存</el-button>
</div>
</el-dialog>
<!-- Template Dialog -->
<el-dialog :visible.sync="templateDialogVisible" :title="templateDialogTitle" width="480px" append-to-body @closed="resetTemplateForm">
<el-form ref="templateFormRef" :model="templateForm" :rules="templateRules" label-width="90px" size="small">
<el-form-item label="岗位名称" prop="positionName">
<el-input v-model="templateForm.positionName" placeholder="与员工表岗位字段匹配,同时作为模板标题" />
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="templateForm.isEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="templateForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="templateSubmitting" @click="submitTemplate">保存</el-button>
</div>
</el-dialog>
<!-- Dimension Form Dialog -->
<el-dialog :visible.sync="dimFormVisible" :title="dimFormTitle" width="560px" append-to-body @closed="resetDimForm">
<el-form ref="dimFormRef" :model="dimForm" :rules="dimRules" label-width="90px" size="small">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="序号" prop="seq">
<el-input v-model="dimForm.seq" placeholder="如 1.1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="考核维度" prop="dimName">
<el-select v-model="dimForm.dimName" placeholder="请选择考核维度" style="width: 100%">
<el-option label="生产效率" value="生产效率" />
<el-option label="产品质量" value="产品质量" />
<el-option label="成本控制" value="成本控制" />
<el-option label="安全管理" value="安全管理" />
<el-option label="设备维护" value="设备维护" />
<el-option label="团队管理" value="团队管理" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="权重" prop="weight">
<el-input v-model="dimForm.weight" placeholder="如 40%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="量化指标" prop="indicator">
<el-input v-model="dimForm.indicator" placeholder="如:月产量达标率" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标值" prop="targetValue">
<el-input v-model="dimForm.targetValue" placeholder="如:≥98%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="评分公式" prop="scoreFormula">
<el-input v-model="dimForm.scoreFormula" placeholder="如: =IF(E4>0,25*(F4/E4),0)" />
</el-form-item>
<el-form-item label="评分规则" prop="scoreRule">
<el-input v-model="dimForm.scoreRule" type="textarea" :rows="2" placeholder="评分规则文字描述" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="dimForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dimFormVisible = false">取消</el-button>
<el-button type="primary" :loading="dimSubmitting" @click="submitDim">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPerfDeptItemConfig, addPerfDeptItemConfig, updatePerfDeptItemConfig, delPerfDeptItemConfig } from '@/api/perf/deptItemConfig'
import { listPerfPositionTemplate, addPerfPositionTemplate, updatePerfPositionTemplate, delPerfPositionTemplate } from '@/api/perf/positionTemplate'
import { listPerfTemplateDimension, addPerfTemplateDimension, updatePerfTemplateDimension, delPerfTemplateDimension } from '@/api/perf/templateDimension'
import { listPerfDeptConfig, addPerfDeptConfig, updatePerfDeptConfig } from '@/api/perf/deptConfig'
export default {
name: 'PerfConfigPanel',
props: {
deptId: { type: [Number, String], default: null },
period: { type: String, default: '' }
},
data() {
return {
pageLoading: false,
// Dept Config (考核参数)
cfgLoading: false,
cfgSubmitting: false,
cfgReady: false,
cfgId: null,
cfgForm: { monthlyProductionTarget: '', productionUnit: '吨', perfUnitPrice: '', productionShifts: '', guaranteedShifts: '', deptCoeff: '', salaryMode: 0, baseDivisor: '', remark: '' },
cfgRules: {
monthlyProductionTarget: [{ required: true, message: '请输入月产量目标', trigger: 'blur' }],
productionUnit: [{ required: true, message: '请选择产量单位', trigger: 'change' }],
perfUnitPrice: [{ required: true, message: '请输入绩效单价', trigger: 'blur' }],
productionShifts: [{ required: true, message: '请输入生产班数', trigger: 'blur' }],
deptCoeff: [{ required: true, message: '请输入车间系数', trigger: 'blur' }]
},
// Item Config
itemLoading: false,
itemList: [],
itemDialogVisible: false,
itemDialogTitle: '新增项目',
itemSubmitting: false,
itemForm: {},
itemRules: {
itemType: [{ required: true, message: '请选择类型', trigger: 'change' }],
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
},
// Template
templateLoading: false,
templateList: [],
activeTemplateId: '',
templateDialogVisible: false,
templateDialogTitle: '新增模板',
templateSubmitting: false,
templateForm: {},
templateRules: {
positionName: [{ required: true, message: '请输入岗位名称', trigger: 'blur' }]
},
// Context menu
ctxMenu: { visible: false, x: 0, y: 0, tpl: null },
ctxLoading: false,
// Dimension
dimLoading: false,
dimList: [],
dimFormVisible: false,
dimFormTitle: '新增维度',
dimSubmitting: false,
dimForm: {},
dimRules: {
dimName: [{ required: true, message: '请选择考核维度', trigger: 'change' }]
}
}
},
computed: {
activeTpl() {
return this.templateList.find(t => String(t.id) === this.activeTemplateId) || null
}
},
watch: {
deptId: { immediate: true, handler: 'loadAll' },
period: 'loadAll'
},
methods: {
loadAll() {
if (!this.deptId) return
this.loadDeptConfig()
this.loadItems()
this.loadTemplates()
},
// ======== Dept Config ========
loadDeptConfig() {
this.cfgLoading = true
this.cfgReady = false
listPerfDeptConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
const rows = res.rows || []
if (rows.length > 0) {
const item = rows[0]
this.cfgId = item.id
this.cfgForm = {
monthlyProductionTarget: item.monthlyProductionTarget ?? '',
productionUnit: item.productionUnit || '吨',
perfUnitPrice: item.perfUnitPrice ?? '',
productionShifts: item.productionShifts ?? '',
guaranteedShifts: item.guaranteedShifts ?? '',
deptCoeff: item.deptCoeff ?? '',
salaryMode: item.salaryMode ?? 0,
baseDivisor: item.baseDivisor ?? '',
remark: item.remark || ''
}
} else {
this.cfgId = null
this.cfgForm = { monthlyProductionTarget: '', productionUnit: '吨', perfUnitPrice: '', productionShifts: '', guaranteedShifts: '', deptCoeff: '', salaryMode: 0, baseDivisor: '', remark: '' }
}
this.cfgReady = true
}).finally(() => { this.cfgLoading = false })
},
handleSaveCfg() {
this.$refs.cfgFormRef.validate(valid => {
if (!valid) return
this.cfgSubmitting = true
const data = { ...this.cfgForm, deptId: this.deptId, month: this.period + '-01' }
const api = this.cfgId ? updatePerfDeptConfig : addPerfDeptConfig
if (this.cfgId) data.id = this.cfgId
api(data).then(() => {
this.$message.success('保存成功')
this.loadDeptConfig()
}).finally(() => { this.cfgSubmitting = false })
})
},
// ======== Items ========
loadItems() {
this.itemLoading = true
listPerfDeptItemConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
this.itemList = res.rows || []
}).finally(() => { this.itemLoading = false })
},
handleAddItem() {
this.itemDialogTitle = '新增项目'
this.itemForm = { itemType: 1, itemName: '', sortOrder: 0, isEnabled: 1, remark: '', month: this.period + '-01' }
this.itemDialogVisible = true
this.$nextTick(() => { this.$refs.itemFormRef?.clearValidate() })
},
handleEditItem(row) {
this.itemDialogTitle = '编辑项目'
this.itemForm = { ...row }
this.itemDialogVisible = true
this.$nextTick(() => { this.$refs.itemFormRef?.clearValidate() })
},
handleDelItem(row) {
this.$confirm(`确认删除项目"${row.itemName}"吗?`, '提示', { type: 'warning' }).then(() => {
delPerfDeptItemConfig(row.id).then(() => {
this.$message.success('删除成功')
this.loadItems()
})
}).catch(() => {})
},
handleToggleItemEnabled(row) {
updatePerfDeptItemConfig({ id: row.id, isEnabled: row.isEnabled })
},
submitItem() {
this.$refs.itemFormRef.validate(valid => {
if (!valid) return
this.itemSubmitting = true
const data = { ...this.itemForm, deptId: this.deptId }
const api = data.id ? updatePerfDeptItemConfig : addPerfDeptItemConfig
api(data).then(() => {
this.$message.success(data.id ? '修改成功' : '新增成功')
this.itemDialogVisible = false
this.loadItems()
}).finally(() => { this.itemSubmitting = false })
})
},
resetItemForm() { this.itemForm = {} },
// ======== Templates ========
loadTemplates() {
this.templateLoading = true
listPerfPositionTemplate({ deptId: this.deptId }).then(res => {
this.templateList = res.rows || []
if (this.templateList.length && !this.templateList.find(t => String(t.id) === this.activeTemplateId)) {
this.activeTemplateId = String(this.templateList[0].id)
this.loadDims()
}
}).finally(() => { this.templateLoading = false })
},
handleAddTemplate() {
this.templateDialogTitle = '新增模板'
this.templateForm = { positionName: '', isEnabled: 1, remark: '' }
this.templateDialogVisible = true
this.$nextTick(() => { this.$refs.templateFormRef?.clearValidate() })
},
// 右键菜单
onTabContextMenu(event, tpl) {
this.$set(this.ctxMenu, 'tpl', tpl)
this.$set(this.ctxMenu, 'x', event.pageX)
this.$set(this.ctxMenu, 'y', event.pageY)
this.$set(this.ctxMenu, 'visible', true)
this.$nextTick(() => {
const close = () => this.closeCtxMenu()
document.addEventListener('click', close, { once: true })
document.addEventListener('contextmenu', close, { once: true })
})
},
closeCtxMenu() {
this.ctxMenu.visible = false
},
ctxEditTemplate() {
if (this.ctxLoading) return
const tpl = this.ctxMenu.tpl
this.ctxMenu.visible = false
this.templateDialogTitle = '编辑模板'
this.templateForm = { ...tpl }
this.templateDialogVisible = true
this.$nextTick(() => { this.$refs.templateFormRef?.clearValidate() })
},
ctxDelTemplate() {
if (this.ctxLoading) return
const tpl = this.ctxMenu.tpl
this.ctxMenu.visible = false
this.$confirm(`确认删除模板"${tpl.positionName}"吗?关联的维度也将一并删除。`, '提示', { type: 'warning' }).then(() => {
this.ctxLoading = true
delPerfPositionTemplate(tpl.id).then(() => {
this.$message.success('删除成功')
if (String(tpl.id) === this.activeTemplateId) this.activeTemplateId = ''
this.loadTemplates()
}).finally(() => { this.ctxLoading = false })
}).catch(() => {})
},
submitTemplate() {
this.$refs.templateFormRef.validate(valid => {
if (!valid) return
this.templateSubmitting = true
const data = { ...this.templateForm, deptId: this.deptId, title: this.templateForm.positionName }
const api = data.id ? updatePerfPositionTemplate : addPerfPositionTemplate
api(data).then(() => {
this.$message.success(data.id ? '修改成功' : '新增成功')
this.templateDialogVisible = false
this.loadTemplates()
}).finally(() => { this.templateSubmitting = false })
})
},
resetTemplateForm() { this.templateForm = {} },
// ======== Dimensions ========
loadDims() {
if (!this.activeTpl) { this.dimList = []; return }
this.dimLoading = true
listPerfTemplateDimension({ templateId: this.activeTpl.id }).then(res => {
this.dimList = res.rows || []
}).finally(() => { this.dimLoading = false })
},
handleAddDim() {
this.dimFormTitle = '新增维度'
this.dimForm = { seq: '', dimName: '', weight: '', indicator: '', targetValue: '', scoreFormula: '', scoreRule: '', isEnabled: 1, remark: '' }
this.dimFormVisible = true
this.$nextTick(() => { this.$refs.dimFormRef?.clearValidate() })
},
handleEditDim(row) {
this.dimFormTitle = '编辑维度'
this.dimForm = { ...row }
this.dimFormVisible = true
this.$nextTick(() => { this.$refs.dimFormRef?.clearValidate() })
},
handleDelDim(row) {
this.$confirm(`确认删除维度"${row.dimName}"吗?`, '提示', { type: 'warning' }).then(() => {
delPerfTemplateDimension(row.id).then(() => {
this.$message.success('删除成功')
this.loadDims()
})
}).catch(() => {})
},
handleToggleDimEnabled(row) {
updatePerfTemplateDimension({ id: row.id, isEnabled: row.isEnabled })
},
submitDim() {
this.$refs.dimFormRef.validate(valid => {
if (!valid) return
this.dimSubmitting = true
const data = { ...this.dimForm, templateId: this.activeTpl.id }
const api = data.id ? updatePerfTemplateDimension : addPerfTemplateDimension
api(data).then(() => {
this.$message.success(data.id ? '修改成功' : '新增成功')
this.dimFormVisible = false
this.loadDims()
}).finally(() => { this.dimSubmitting = false })
})
},
resetDimForm() { this.dimForm = {} }
}
}
</script>
<style lang="scss" scoped>
.perf-config-panel {
padding: 8px;
height: 100%;
overflow-y: auto;
}
.config-section {
background-color: #fff;
margin-bottom: 12px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.section-hint {
font-weight: 400;
font-size: 12px;
color: #c0c4cc;
margin-left: 8px;
}
.cfg-form {
padding: 12px;
::v-deep .el-form-item {
margin-bottom: 8px;
margin-right: 16px;
}
}
::v-deep .el-table {
border: none;
&::before, &::after { display: none; }
}
/* 自定义模板 tabs */
.template-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid #ebeef5;
}
.template-tab {
display: inline-block;
padding: 4px 14px;
font-size: 12px;
color: #606266;
background: #f4f4f5;
border-radius: 12px;
cursor: pointer;
user-select: none;
transition: all 0.15s;
white-space: nowrap;
&:hover {
color: #409eff;
background: #ecf5ff;
}
&--active {
color: #fff;
background: #409eff;
&:hover {
color: #fff;
background: #66b1ff;
}
}
}
/* 维度区域 */
.dim-section {
padding: 8px 0 0;
}
.dim-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px 8px;
}
.dim-subtitle {
font-size: 13px;
font-weight: 500;
color: #606266;
}
.dim-empty {
padding: 24px;
text-align: center;
color: #c0c4cc;
font-size: 13px;
}
/* 右键菜单 */
.ctx-menu {
position: fixed;
z-index: 9999;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,.12);
padding: 4px 0;
min-width: 120px;
}
.ctx-menu-item {
padding: 8px 16px;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: background-color 0.15s;
&:hover { background-color: #f5f7fa; }
&--danger {
color: #f56c6c;
&:hover { background-color: #fef0f0; }
}
}
</style>

View File

@@ -0,0 +1,509 @@
<template>
<div class="salary-panel">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="query.employeeId" size="small" placeholder="员工ID" clearable style="width: 140px" @keyup.enter.native="loadList" />
<el-select v-model="query.status" size="small" placeholder="状态" clearable style="width: 110px" @change="loadList">
<el-option label="草稿" :value="'0'" />
<el-option label="已确认" :value="'1'" />
<el-option label="已归档" :value="'2'" />
</el-select>
<el-button size="small" type="primary" icon="el-icon-search" @click="loadList">查询</el-button>
</div>
<div class="toolbar-right">
<el-button size="small" type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
<el-button size="small" type="success" icon="el-icon-s-operation" :loading="generating" @click="handleBatchGenerate">一键生成</el-button>
</div>
</div>
<!-- 表格 -->
<div class="table-wrap">
<el-table :data="list" stripe size="small" v-loading="loading" empty-text="暂无薪资记录" class="inline-edit-table" border>
<!-- 只读列 -->
<!-- <el-table-column prop="employeeId" label="员工ID" width="90" /> -->
<el-table-column prop="status" label="状态" width="70" align="center">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<!-- 薪资基数 -->
<el-table-column label="底薪" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.baseSalary" class="cell-input cell-input--base" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="绩效基数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfBase" class="cell-input cell-input--base" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 考核得分 -->
<el-table-column label="考核得分" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfScore" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 系数 -->
<el-table-column label="绩效系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="车间系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.deptCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="岗位系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.posCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="固定系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.fixedCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="调整系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.adjCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="手动调整系数" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.adjCoeffManual" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="总系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.totalCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 绩效工资 -->
<el-table-column label="绩效工资" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.perfWage" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 动态扣款列 -->
<el-table-column
v-for="item in deductionItems" :key="'ded-'+item.itemName"
:label="item.itemName" width="100" align="right"
><template slot-scope="s">
<input v-model="s.row._d[item.itemName]" class="cell-input cell-input--deduct" @change="onDeductChange(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="扣款合计" width="90" align="right">
<template slot-scope="scope"><span class="cell-readonly cell-readonly--deduct">{{ scope.row._deductionsTotal }}</span></template>
</el-table-column>
<!-- 动态奖励列 -->
<el-table-column
v-for="item in bonusItems" :key="'bon-'+item.itemName"
:label="item.itemName" width="100" align="right"
><template slot-scope="s">
<input v-model="s.row._b[item.itemName]" class="cell-input cell-input--bonus" @change="onBonusChange(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="奖励合计" width="90" align="right">
<template slot-scope="scope"><span class="cell-readonly cell-readonly--bonus">{{ scope.row._bonusesTotal }}</span></template>
</el-table-column>
<!-- 金额 -->
<el-table-column label="其他金额" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.otherAmount" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="实领薪资" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.actualSalary" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 参考值 -->
<el-table-column label="固薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.fixedSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="调薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.adjSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="包薪每班参考" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.baoPerShiftRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="包薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.baoSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="总薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.totalSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 备注 -->
<el-table-column label="备注" min-width="140"><template slot-scope="s">
<input v-model="s.row.remark" class="cell-input cell-input--remark" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="90" align="center" fixed="right">
<template slot-scope="scope">
<el-button v-if="scope.row._dirty" type="text" size="mini" icon="el-icon-check" :loading="scope.row._saving" class="btn-save" @click="saveRow(scope.row)">保存</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" @click="handleDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrapper">
<el-pagination
:current-page="query.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="query.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script>
import { listPerfSalary, addPerfSalary, updatePerfSalary, delPerfSalary } from '@/api/perf/salary'
import { listEmployeeInfo } from '@/api/wms/employeeInfo'
import { listPerfDeptItemConfig } from '@/api/perf/deptItemConfig'
export default {
name: 'SalaryPanel',
props: {
deptId: { type: [Number, String], default: null },
deptName: { type: String, default: '' },
period: { type: String, default: '' }
},
data() {
return {
loading: false,
list: [],
total: 0,
query: {
pageNum: 1,
pageSize: 10,
employeeId: '',
status: ''
},
deductionItems: [],
bonusItems: [],
generating: false
}
},
watch: {
deptId: { immediate: true, handler: 'onDeptChange' },
period: 'onDeptChange'
},
methods: {
fmt(val) {
return val != null ? val : '-'
},
statusType(status) {
return { '0': 'info', '1': 'warning', '2': 'success' }[status] || 'info'
},
statusLabel(status) {
return { '0': '草稿', '1': '已确认', '2': '已归档' }[status] || '草稿'
},
onDeptChange() {
this.loadItemConfig()
this.loadList()
},
loadItemConfig() {
if (!this.deptId || !this.period) return
listPerfDeptItemConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
const rows = res.rows || []
this.deductionItems = rows.filter(r => r.itemType === 1 && r.isEnabled === 1)
this.bonusItems = rows.filter(r => r.itemType === 2 && r.isEnabled === 1)
}).catch(() => {
this.deductionItems = []
this.bonusItems = []
})
},
/* 解析扣款/奖励 JSON 到行对象 */
parseRowItems(row) {
if (!row._d) {
const d = {}
try { Object.assign(d, JSON.parse(row.deductionsJson || '{}')) } catch {}
this.deductionItems.forEach(item => { if (!(item.itemName in d)) d[item.itemName] = '' })
this.$set(row, '_d', d)
}
if (!row._b) {
const b = {}
try { Object.assign(b, JSON.parse(row.bonusesJson || '{}')) } catch {}
this.bonusItems.forEach(item => { if (!(item.itemName in b)) b[item.itemName] = '' })
this.$set(row, '_b', b)
}
this.recalcTotals(row)
},
recalcTotals(row) {
const sumD = Object.values(row._d || {}).reduce((s, v) => s + (parseFloat(v) || 0), 0)
const sumB = Object.values(row._b || {}).reduce((s, v) => s + (parseFloat(v) || 0), 0)
this.$set(row, '_deductionsTotal', sumD)
this.$set(row, '_bonusesTotal', sumB)
},
onDeductChange(row) { this.recalcTotals(row) },
onBonusChange(row) { this.recalcTotals(row) },
markDirty(row) { this.$set(row, '_dirty', true) },
loadList() {
if (!this.deptId || !this.period) return
this.loading = true
const params = {
pageNum: this.query.pageNum,
pageSize: this.query.pageSize,
deptId: this.deptId,
period: this.period,
employeeId: this.query.employeeId || undefined,
status: this.query.status || undefined
}
listPerfSalary(params).then(res => {
this.list = (res.rows || []).map(r => {
this.$set(r, '_dirty', false)
this.$set(r, '_saving', false)
this.parseRowItems(r)
return r
})
this.total = res.total || 0
}).finally(() => {
this.loading = false
})
},
handleSizeChange(val) {
this.query.pageSize = val
this.loadList()
},
handlePageChange(val) {
this.query.pageNum = val
this.loadList()
},
handleAdd() {
const row = {
employeeId: '',
deptId: this.deptId,
period: this.period,
baseSalary: '',
perfBase: '',
perfScore: '',
perfCoeff: '',
deptCoeff: '',
posCoeff: '',
fixedCoeff: '',
adjCoeff: '',
adjCoeffManual: '',
totalCoeff: '',
perfWage: '',
deductionsJson: '{}',
bonusesJson: '{}',
otherAmount: '',
actualSalary: '',
fixedSalaryRef: '',
adjSalaryRef: '',
baoPerShiftRef: '',
baoSalaryRef: '',
totalSalaryRef: '',
status: '0',
remark: '',
_dirty: true,
_saving: false,
_d: {},
_b: {},
_deductionsTotal: 0,
_bonusesTotal: 0
}
this.deductionItems.forEach(item => { row._d[item.itemName] = '' })
this.bonusItems.forEach(item => { row._b[item.itemName] = '' })
this.list.unshift(row)
},
saveRow(row) {
this.$set(row, '_saving', true)
const data = { ...row }
// 序列化扣款/奖励 JSON
data.deductionsJson = JSON.stringify(row._d || {})
data.bonusesJson = JSON.stringify(row._b || {})
data.deductionsTotal = row._deductionsTotal
data.bonusesTotal = row._bonusesTotal
// 清理内部字段
delete data._d; delete data._b; delete data._dirty; delete data._saving
delete data._deductionsTotal; delete data._bonusesTotal
const api = data.id ? updatePerfSalary : addPerfSalary
api(data).then(res => {
// 乐观更新:如果是新增,用返回值更新 id
if (!data.id && res.data) {
this.$set(row, 'id', res.data.id || res.data)
}
this.$set(row, '_dirty', false)
this.$message.success('保存成功')
}).catch(() => {
this.$message.error('保存失败')
this.loadList() // 失败则重新加载以回滚
}).finally(() => {
this.$set(row, '_saving', false)
})
},
handleDel(row) {
if (!row.id) {
// 未保存的新行直接移除
const idx = this.list.indexOf(row)
if (idx >= 0) this.list.splice(idx, 1)
return
}
this.$confirm(`确认删除该薪资记录吗?`, '提示', { type: 'warning' }).then(() => {
delPerfSalary(row.id).then(() => {
this.$message.success('删除成功')
this.loadList()
})
}).catch(() => {})
},
handleBatchGenerate() {
if (!this.deptName) {
this.$message.warning('缺少部门名称')
return
}
this.$confirm(`确认根据"${this.deptName}"下的在职员工,一键生成本月薪资记录吗?`, '提示', { type: 'info' }).then(() => {
this.generating = true
listEmployeeInfo({ dept: this.deptName, isLeave: 0, pageSize: 9999 }).then(empRes => {
const employees = empRes.rows || []
if (employees.length === 0) {
this.$message.warning('该部门下暂无在职员工')
return
}
listPerfSalary({ deptId: this.deptId, period: this.period, pageSize: 9999 }).then(salaryRes => {
const existing = new Set((salaryRes.rows || []).map(r => String(r.employeeId)))
const toInsert = employees.filter(e => !existing.has(String(e.employeeId) || String(e.infoId)))
if (toInsert.length === 0) {
this.$message.info('该部门下所有在职员工已有本月薪资记录')
return
}
const emptyJson = JSON.stringify({})
const tasks = toInsert.map(emp => addPerfSalary({
employeeId: emp.employeeId || emp.infoId || '',
deptId: this.deptId,
period: this.period,
baseSalary: emp.baseSalary || 0,
perfBase: emp.perfBase || 0,
posCoeff: emp.posCoeffDefault || 0,
perfScore: 0,
perfCoeff: 0,
deptCoeff: 0,
fixedCoeff: 0,
adjCoeff: 0,
adjCoeffManual: 0,
totalCoeff: 0,
perfWage: 0,
deductionsJson: emptyJson,
deductionsTotal: 0,
bonusesJson: emptyJson,
bonusesTotal: 0,
otherAmount: 0,
actualSalary: 0,
fixedSalaryRef: '',
adjSalaryRef: '',
baoPerShiftRef: '',
baoSalaryRef: '',
totalSalaryRef: '',
status: '0',
remark: ''
}))
return Promise.allSettled(tasks)
}).then(results => {
if (!results) return
const ok = results.filter(r => r.status === 'fulfilled').length
const fail = results.filter(r => r.status === 'rejected').length
this.$message.success(`生成完成:成功 ${ok}${fail > 0 ? `,失败 ${fail}` : ''}`)
this.loadList()
})
}).finally(() => {
this.generating = false
})
}).catch(() => {})
}
}
}
</script>
<style lang="scss" scoped>
.salary-panel {
padding: 8px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
flex-shrink: 0;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.table-wrap {
flex: 1;
overflow: auto;
}
::v-deep .inline-edit-table {
border: none;
&::before, &::after { display: none; }
th { border: none; padding: 2px 4px; font-size: 12px; color: #909399; }
td {
border: none;
border-right: 1px solid #ebeef5;
padding: 0;
}
.el-table__body tr:hover td { background-color: #f5f7fa !important; }
.cell { padding: 0; height: 100%; }
}
/* 原生 input */
.cell-input {
width: 100%;
height: 28px;
border: none;
outline: none;
text-align: right;
padding: 0 6px;
font-size: 12px;
color: #303133;
font-family: inherit;
box-sizing: border-box;
display: block;
line-height: 28px;
transition: background-color 0.15s;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 1px #409eff inset;
border-radius: 0;
}
&--base { background-color: #e8f4fd; }
&--coeff { background-color: #fff8e1; }
&--deduct { background-color: #ffebee; }
&--bonus { background-color: #e8f5e9; }
&--amount { background-color: #f3e5f5; }
&--ref { background-color: #f5f5f5; }
&--remark { background-color: #fafafa; text-align: left; }
}
.cell-readonly {
display: block;
text-align: right;
padding: 0 6px;
font-size: 12px;
color: #303133;
font-weight: 500;
line-height: 28px;
height: 28px;
&--deduct { color: #e53935; }
&--bonus { color: #43a047; }
}
.btn-save {
color: #67c23a !important;
}
.pagination-wrapper {
margin-top: 6px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.el-table .el-button + .el-button {
margin-left: 0;
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div class="summary-panel" v-loading="loading">
<div v-if="!summary && !loading" class="empty-state">
<el-empty description="暂无统计数据">
<span slot="extra" style="color: #909399; font-size: 13px;">请确认已配置考核参数并生成薪资记录</span>
</el-empty>
</div>
<template v-if="summary">
<!-- Overview Cards -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-label">总人数</div>
<div class="stat-value">{{ fmt(summary.totalEmployees) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">系数总计</div>
<div class="stat-value">{{ fmt(summary.totalCoeffSum) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">平均考核得分</div>
<div class="stat-value highlight">{{ fmt(summary.avgPerfScore) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">平均绩效系数</div>
<div class="stat-value">{{ fmt(summary.avgPerfCoeff) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">月产量</div>
<div class="stat-value">{{ fmt(summary.monthlyProduction) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">系数单价</div>
<div class="stat-value highlight">{{ fmt(summary.unitPrice) }}</div>
</div>
</div>
<!-- Salary Breakdown Card -->
<div class="detail-card">
<div class="card-title">薪资构成</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">底薪合计</span>
<span class="detail-value">{{ fmt(summary.totalBaseSalary) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">绩效工资合计</span>
<span class="detail-value highlight">{{ fmt(summary.totalPerfWage) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">扣款合计</span>
<span class="detail-value danger">{{ fmt(summary.totalDeductions) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">奖励合计</span>
<span class="detail-value success">{{ fmt(summary.totalBonuses) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">其他合计</span>
<span class="detail-value">{{ fmt(summary.totalOther) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">实领薪资合计</span>
<span class="detail-value total">{{ fmt(summary.totalActualSalary) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">人均实领薪资</span>
<span class="detail-value highlight">{{ fmt(summary.avgActualSalary) }}</span>
</div>
</div>
</div>
<!-- Status -->
<div class="status-bar">
<span class="status-label">状态</span>
<el-tag :type="summary.status === 1 ? 'success' : 'info'" size="small">
{{ summary.status === 1 ? '已确认' : '草稿' }}
</el-tag>
</div>
</template>
</div>
</template>
<script>
import { listPerfDeptSummary } from '@/api/perf/deptSummary'
export default {
name: 'SummaryPanel',
props: {
deptId: { type: [Number, String], default: null },
period: { type: String, default: '' }
},
data() {
return {
loading: false,
summary: null
}
},
watch: {
deptId: { immediate: true, handler: 'loadSummary' },
period: 'loadSummary'
},
methods: {
fmt(val) {
return val != null ? val : '-'
},
loadSummary() {
if (!this.deptId || !this.period) return
this.loading = true
this.summary = null
listPerfDeptSummary({ deptId: this.deptId, period: this.period }).then(res => {
const rows = res.rows || []
this.summary = rows.length > 0 ? rows[0] : null
}).finally(() => {
this.loading = false
})
}
}
}
</script>
<style lang="scss" scoped>
.summary-panel {
height: 100%;
overflow-y: auto;
background-color: #f5f7fa;
padding: 16px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
background-color: #fff;
border-radius: 4px;
}
.stat-cards {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background-color: #fff;
border-radius: 4px;
padding: 20px 16px;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #303133;
&.highlight {
color: #409eff;
}
}
.detail-card {
background-color: #fff;
border-radius: 4px;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: 12px;
color: #909399;
}
.detail-value {
font-size: 16px;
font-weight: 600;
color: #303133;
&.highlight {
color: #409eff;
}
&.danger {
color: #f56c6c;
}
&.success {
color: #67c23a;
}
&.total {
color: #e6a23c;
font-size: 18px;
}
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background-color: #fff;
border-radius: 4px;
}
.status-label {
font-size: 13px;
color: #606266;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="year-month-tabs">
<!-- Year Tabs -->
<div class="year-row">
<div
v-for="y in years"
:key="y"
class="year-tab"
:class="{ active: currentYear === y }"
@click="handleYearClick(y)"
>{{ y }}</div>
</div>
<!-- Month Tabs -->
<div class="month-row" v-if="currentYear">
<div
v-for="m in 12"
:key="m"
class="month-tab"
:class="{ active: currentYear === selectedYear && currentMonth === m }"
:disabled="!isMonthAvailable(currentYear, m)"
@click="handleMonthClick(m)"
>{{ String(m).padStart(2, '0') }}</div>
</div>
</div>
</template>
<script>
export default {
name: 'YearMonthTabs',
props: {
/** 当前选中的年月,格式 { year, month } */
value: {
type: Object,
default: null
},
/** 可选的年份范围偏移:往前推 N 年 */
pastYears: {
type: Number,
default: 5
},
/** 可选的年份范围偏移:往后推 N 年 */
futureYears: {
type: Number,
default: 1
},
/** 月份可用性判断函数 (year, month) => boolean默认全部可用 */
monthValidator: {
type: Function,
default: null
}
},
data() {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
return {
selectedYear: this.value?.year || currentYear,
currentMonth: this.value?.month || currentMonth
}
},
computed: {
currentYear: {
get() { return this.selectedYear },
set(v) { this.selectedYear = v }
},
years() {
const now = new Date().getFullYear()
const start = now - this.pastYears
const end = now + this.futureYears
const list = []
for (let y = start; y <= end; y++) {
list.push(y)
}
return list
}
},
watch: {
value: {
immediate: true,
handler(v) {
if (v && v.year && v.month) {
this.selectedYear = v.year
this.currentMonth = v.month
}
}
}
},
methods: {
isMonthAvailable(year, month) {
if (this.monthValidator) {
return this.monthValidator(year, month)
}
// 默认不限制未来月份
return true
},
handleYearClick(year) {
this.selectedYear = year
// 切换年份时如果当前月份不可用则重置为1月
if (!this.isMonthAvailable(year, this.currentMonth)) {
this.currentMonth = 1
}
this.emitChange()
},
handleMonthClick(month) {
if (!this.isMonthAvailable(this.selectedYear, month)) return
this.currentMonth = month
this.emitChange()
},
emitChange() {
this.$emit('input', { year: this.selectedYear, month: this.currentMonth })
this.$emit('change', { year: this.selectedYear, month: this.currentMonth })
}
}
}
</script>
<style lang="scss" scoped>
.year-month-tabs {
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
user-select: none;
}
.year-row {
display: flex;
align-items: center;
padding: 0 16px;
height: 36px;
gap: 0;
border-bottom: 1px solid #f0f0f0;
}
.year-tab {
position: relative;
padding: 0 16px;
height: 100%;
display: flex;
align-items: center;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #409eff;
}
&.active {
color: #409eff;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 12px;
right: 12px;
height: 2px;
background-color: #409eff;
}
}
}
.month-row {
display: flex;
align-items: center;
padding: 0 16px;
height: 34px;
gap: 0;
}
.month-tab {
padding: 0 10px;
height: 100%;
display: flex;
align-items: center;
font-size: 12px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
position: relative;
&:hover:not([disabled]) {
color: #409eff;
}
&.active {
color: #409eff;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 6px;
right: 6px;
height: 2px;
background-color: #409eff;
}
}
&[disabled] {
color: #c0c4cc;
cursor: not-allowed;
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="perf-index-page">
<!-- Top: Year-Month Tabs -->
<YearMonthTabs v-model="selectedDate" @change="handleDateChange" />
<!-- Main: Left Dept List + Right Content -->
<div class="main-layout">
<!-- Left: Department List -->
<div class="dept-panel">
<div class="dept-panel-header">
<span class="dept-panel-title">部门列表</span>
</div>
<div class="dept-list" v-loading="deptLoading">
<div
v-for="dept in deptList"
:key="dept.id"
class="dept-item"
:class="{ active: currentDept && currentDept.id === dept.id }"
@click="handleDeptSelect(dept)"
>
<span class="dept-name">{{ dept.deptName }}</span>
</div>
<div v-if="!deptLoading && deptList.length === 0" class="dept-empty">暂无部门数据</div>
</div>
</div>
<!-- Right: Content -->
<div class="content-panel">
<PageHeader
v-if="currentDept"
:dept-name="currentDept.deptName"
:period="periodStr"
:active-tab.sync="activeTab"
@refresh="handleRefresh"
/>
<div v-else class="no-dept-tip">
<span class="placeholder-text">请先在左侧选择一个部门</span>
</div>
<div class="panel-container" v-if="currentDept">
<PerfConfigPanel v-if="activeTab === 'perfConfig'" :dept-id="currentDept.id" :period="periodStr" />
<SalaryPanel v-if="activeTab === 'salary'" :dept-id="currentDept.id" :dept-name="currentDept.deptName" :period="periodStr" />
<SummaryPanel v-if="activeTab === 'summary'" :dept-id="currentDept.id" :period="periodStr" />
</div>
</div>
</div>
</div>
</template>
<script>
import YearMonthTabs from './YearMonthTabs'
import PageHeader from './PageHeader'
import PerfConfigPanel from './PerfConfigPanel'
import SalaryPanel from './SalaryPanel'
import SummaryPanel from './SummaryPanel'
import { listPerfDept } from '@/api/perf/dept'
export default {
name: 'PerfIndex',
components: { YearMonthTabs, PageHeader, PerfConfigPanel, SalaryPanel, SummaryPanel },
data() {
const now = new Date()
return {
deptLoading: false,
deptList: [],
currentDept: null,
selectedDate: {
year: now.getFullYear(),
month: now.getMonth() + 1
},
activeTab: 'perfConfig'
}
},
computed: {
periodStr() {
const { year, month } = this.selectedDate
return `${year}-${String(month).padStart(2, '0')}`
}
},
created() {
this.loadDeptList()
},
methods: {
loadDeptList() {
this.deptLoading = true
listPerfDept({}).then(res => {
this.deptList = res.rows || []
}).finally(() => {
this.deptLoading = false
})
},
handleDeptSelect(dept) {
this.currentDept = dept
},
handleDateChange(date) {
this.selectedDate = date
},
handleRefresh() {
// Force child components to reload by toggling through the tab
// (Each panel watches deptId/period and will reload on change)
}
}
}
</script>
<style lang="scss" scoped>
.perf-index-page {
height: calc(100vh - 84px);
display: flex;
flex-direction: column;
background-color: #f0f2f5;
}
.main-layout {
flex: 1;
display: grid;
grid-template-columns: 220px 1fr;
gap: 0;
overflow: hidden;
}
// ======== Left: Department Panel ========
.dept-panel {
background-color: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dept-panel-header {
padding: 14px 16px 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f0f0;
}
.dept-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.dept-item {
display: flex;
align-items: center;
padding: 10px 16px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: #f5f7fa;
}
&.active {
background-color: #ecf5ff;
.dept-name {
color: #409eff;
font-weight: 600;
}
}
}
.dept-name {
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.dept-empty {
padding: 32px 0;
text-align: center;
color: #c0c4cc;
font-size: 13px;
}
// ======== Right: Content Panel ========
.content-panel {
background-color: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.no-dept-tip {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.placeholder-text {
font-size: 14px;
color: #c0c4cc;
}
.panel-container {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -633,8 +633,8 @@ export default {
fullPath = '/' + parent + ownPath;
}
}
// 目录(M)的路径作为子节点的父路径
const currentParentPath = node.menuType === 'M' ? ownPath : parentPath;
// 目录(M)的路径拼接祖先路径后作为子节点的父路径
const currentParentPath = node.menuType === 'M' ? (parentPath ? parentPath + '/' + ownPath : ownPath) : parentPath;
result.push({
menuName: prefix + node.menuName,