绩效统计页面开发

This commit is contained in:
朱昊天
2026-07-10 17:25:23 +08:00
parent 9c9d3d4017
commit ea779e131e
8 changed files with 680 additions and 248 deletions

View File

@@ -35,6 +35,15 @@ export function updatePerfDeptSummary(data) {
})
}
// 保存车间月度汇总(按 deptId + period 自动新增或更新)
export function savePerfDeptSummaryByPeriod(data) {
return request({
url: '/perf/deptSummary/saveByPeriod',
method: 'post',
data: data
})
}
// 删除车间月度汇总
export function delPerfDeptSummary(ids) {
return request({

View File

@@ -1,57 +1,92 @@
<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>
<el-card class="config-card mb8" shadow="never">
<div slot="header" class="card-header">
<div class="card-left">
<span class="card-title">考核参数</span>
<el-tag size="mini" :type="cfgStatus.type" class="cfg-status">{{ cfgStatus.text }}</el-tag>
</div>
<el-button size="mini" :type="cfgBtnType" :loading="cfgSubmitting" @click="handleSaveCfg">{{ cfgBtnText }}</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>
<div v-loading="cfgLoading" class="card-body">
<el-form v-if="cfgReady" ref="cfgFormRef" :model="cfgForm" :rules="cfgRules" label-width="96px" size="small" class="cfg-form">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="月产量目标" prop="monthlyProductionTarget">
<el-input v-model="cfgForm.monthlyProductionTarget" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="产量单位" prop="productionUnit">
<el-select v-model="cfgForm.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="6">
<el-form-item label="绩效单价" prop="perfUnitPrice">
<el-input v-model="cfgForm.perfUnitPrice" placeholder="元" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="生产班数" prop="productionShifts">
<el-input v-model="cfgForm.productionShifts" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="保底班数" prop="guaranteedShifts">
<el-input v-model="cfgForm.guaranteedShifts" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="车间系数" prop="deptCoeff">
<el-input v-model="cfgForm.deptCoeff" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<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-col>
<el-col :span="6">
<el-form-item label="保底除数" prop="baseDivisor">
<el-input
:value="cfgForm.salaryMode === 1 ? cfgForm.baseDivisor : '普通模式不可编辑'"
placeholder="请输入"
:disabled="cfgForm.salaryMode !== 1"
@input="val => cfgForm.baseDivisor = val"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="备注">
<el-input v-model="cfgForm.remark" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</div>
</el-card>
<!-- 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>
<el-card class="config-card mb8" shadow="never">
<div slot="header" class="card-header">
<span class="card-title">扣款/奖励项目配置</span>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="handleAddItem">新增</el-button>
</div>
<el-table :data="itemList" stripe size="small" v-loading="itemLoading" empty-text="暂无项目配置">
<div class="card-body">
<el-table :data="itemList" stripe size="mini" 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">
@@ -70,20 +105,23 @@
</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">
<el-table-column label="操作" width="160" 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>
<div class="op-actions">
<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>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
<!-- 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>
<el-card class="config-card" shadow="never" v-loading="ctxLoading">
<div slot="header" class="card-header">
<span class="card-title">岗位绩效考核模板 <span class="card-hint">右键岗位可编辑或删除</span></span>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="handleAddTemplate">新增</el-button>
</div>
<div v-if="templateList.length" class="template-tabs">
<span
@@ -100,9 +138,9 @@
<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>
<el-button size="mini" 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 :data="dimList" stripe size="mini" 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" />
@@ -114,16 +152,18 @@
<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">
<el-table-column label="操作" width="140" 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>
<div class="op-actions">
<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>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div v-else class="dim-empty">点击 tab 查看维度右键 tab 可编辑或删除模板</div>
</div>
</el-card>
<!-- 右键菜单 -->
<div
@@ -160,7 +200,7 @@
<el-input v-model="itemForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<div slot="footer" class="dialog-footer">
<el-button @click="itemDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="itemSubmitting" @click="submitItem">保存</el-button>
</div>
@@ -179,7 +219,7 @@
<el-input v-model="templateForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<div slot="footer" class="dialog-footer">
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="templateSubmitting" @click="submitTemplate">保存</el-button>
</div>
@@ -234,7 +274,7 @@
<el-input v-model="dimForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<div slot="footer" class="dialog-footer">
<el-button @click="dimFormVisible = false">取消</el-button>
<el-button type="primary" :loading="dimSubmitting" @click="submitDim">保存</el-button>
</div>
@@ -262,7 +302,13 @@ export default {
cfgLoading: false,
cfgSubmitting: false,
cfgReady: false,
// cfgId 存在表示该部门该月份已存在配置记录;为空表示首次配置(未落库)
cfgId: null,
// 方案C通过“快照对比”识别是否改动未保存
cfgSnapshot: '',
cfgDirty: false,
// 后端回填表单时锁定快照对比,避免回填过程误判为“已修改未保存”
cfgSnapshotLock: false,
cfgForm: { monthlyProductionTarget: '', productionUnit: '吨', perfUnitPrice: '', productionShifts: '', guaranteedShifts: '', deptCoeff: '', salaryMode: 0, baseDivisor: '', remark: '' },
cfgRules: {
monthlyProductionTarget: [{ required: true, message: '请输入月产量目标', trigger: 'blur' }],
@@ -315,13 +361,48 @@ export default {
computed: {
activeTpl() {
return this.templateList.find(t => String(t.id) === this.activeTemplateId) || null
},
cfgStatus() {
if (this.cfgDirty) return { type: 'danger', text: '已修改未保存' }
if (this.cfgId) return { type: 'success', text: '已保存' }
return { type: 'warning', text: '未保存(首次配置)' }
},
cfgBtnText() {
return this.cfgId ? '保存修改' : '新增并保存'
},
cfgBtnType() {
return this.cfgDirty ? 'danger' : 'primary'
}
},
watch: {
deptId: { immediate: true, handler: 'loadAll' },
period: 'loadAll'
period: 'loadAll',
cfgForm: {
deep: true,
handler() {
// 表单未就绪或正在回填时,不做“脏状态”判断
if (!this.cfgReady) return
if (this.cfgSnapshotLock) return
this.cfgDirty = this.calcCfgSnapshot() !== this.cfgSnapshot
}
}
},
methods: {
// 将考核参数表单按字段归一化,用于快照比对(避免 undefined/null 造成误判)
calcCfgSnapshot() {
const f = this.cfgForm || {}
return JSON.stringify({
monthlyProductionTarget: f.monthlyProductionTarget ?? '',
productionUnit: f.productionUnit ?? '',
perfUnitPrice: f.perfUnitPrice ?? '',
productionShifts: f.productionShifts ?? '',
guaranteedShifts: f.guaranteedShifts ?? '',
deptCoeff: f.deptCoeff ?? '',
salaryMode: f.salaryMode ?? 0,
baseDivisor: f.baseDivisor ?? '',
remark: f.remark ?? ''
})
},
loadAll() {
if (!this.deptId) return
this.loadDeptConfig()
@@ -332,6 +413,8 @@ export default {
loadDeptConfig() {
this.cfgLoading = true
this.cfgReady = false
this.cfgSnapshotLock = true
// 约定:后端按 month=YYYY-MM-01 查询,前端 period 为 YYYY-MM
listPerfDeptConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
const rows = res.rows || []
if (rows.length > 0) {
@@ -353,13 +436,22 @@ export default {
this.cfgForm = { monthlyProductionTarget: '', productionUnit: '吨', perfUnitPrice: '', productionShifts: '', guaranteedShifts: '', deptCoeff: '', salaryMode: 0, baseDivisor: '', remark: '' }
}
this.cfgReady = true
}).finally(() => { this.cfgLoading = false })
this.$nextTick(() => {
// 表单回填完成后建立快照,并重置“脏状态”
this.cfgSnapshot = this.calcCfgSnapshot()
this.cfgDirty = false
this.cfgSnapshotLock = false
})
}).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' }
// cfgId 存在走更新;不存在走新增(首次保存)
const api = this.cfgId ? updatePerfDeptConfig : addPerfDeptConfig
if (this.cfgId) data.id = this.cfgId
api(data).then(() => {
@@ -539,44 +631,59 @@ export default {
overflow-y: auto;
}
.config-section {
background-color: #fff;
margin-bottom: 12px;
.config-card {
border-radius: 0;
box-shadow: none;
border: 1px solid #ebeef5;
::v-deep .el-card {
border-radius: 0;
}
::v-deep .el-card__header {
padding: 10px 12px;
border-bottom: 1px solid #ebeef5;
}
::v-deep .el-card__body {
padding: 12px;
}
}
.section-header {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
justify-content: space-between;
}
.section-title {
.card-left {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-status {
line-height: 1;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.section-hint {
.card-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;
}
.card-body {
padding: 0;
}
::v-deep .el-table {
border: none;
&::before, &::after { display: none; }
.cfg-form {
::v-deep .el-form-item {
margin-bottom: 10px;
}
}
/* 自定义模板 tabs */
@@ -584,17 +691,17 @@ export default {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
padding: 0 0 10px;
border-bottom: 1px solid #ebeef5;
}
.template-tab {
display: inline-block;
padding: 4px 14px;
padding: 4px 12px;
font-size: 12px;
color: #606266;
background: #f4f4f5;
border-radius: 12px;
border-radius: 0;
cursor: pointer;
user-select: none;
transition: all 0.15s;
@@ -617,14 +724,14 @@ export default {
/* 维度区域 */
.dim-section {
padding: 8px 0 0;
padding: 10px 0 0;
}
.dim-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px 8px;
padding: 0 0 8px;
}
.dim-subtitle {
@@ -645,8 +752,8 @@ export default {
position: fixed;
z-index: 9999;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
border: none;
border-radius: 0;
box-shadow: 0 2px 12px rgba(0,0,0,.12);
padding: 4px 0;
min-width: 120px;
@@ -666,4 +773,16 @@ export default {
&:hover { background-color: #fef0f0; }
}
}
.op-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
white-space: nowrap;
}
.op-actions ::v-deep .el-button--text {
padding: 0;
}
</style>

View File

@@ -4,11 +4,12 @@
<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">
<!-- 状态筛选当前阶段为保证统计页能直接聚合展示临时停用 status 参数筛选 -->
<!-- <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-select> -->
<el-button size="small" type="primary" icon="el-icon-search" @click="loadList">查询</el-button>
</div>
<div class="toolbar-right">
@@ -22,11 +23,12 @@
<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">
<!-- 状态列同上临时停用 -->
<!-- <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> -->
<!-- 薪资基数 -->
<el-table-column label="底薪" width="90" align="right"><template slot-scope="s">
@@ -166,7 +168,7 @@ export default {
pageNum: 1,
pageSize: 10,
employeeId: '',
status: ''
// status: ''
},
deductionItems: [],
bonusItems: [],
@@ -181,12 +183,12 @@ export default {
fmt(val) {
return val != null ? val : '-'
},
statusType(status) {
return { '0': 'info', '1': 'warning', '2': 'success' }[status] || 'info'
},
statusLabel(status) {
return { '0': '草稿', '1': '已确认', '2': '已归档' }[status] || '草稿'
},
// statusType(status) {
// return { '0': 'info', '1': 'warning', '2': 'success' }[status] || 'info'
// },
// statusLabel(status) {
// return { '0': '草稿', '1': '已确认', '2': '已归档' }[status] || '草稿'
// },
onDeptChange() {
this.loadItemConfig()
this.loadList()
@@ -236,8 +238,7 @@ export default {
pageSize: this.query.pageSize,
deptId: this.deptId,
period: this.period,
employeeId: this.query.employeeId || undefined,
status: this.query.status || undefined
employeeId: this.query.employeeId || undefined
}
listPerfSalary(params).then(res => {
this.list = (res.rows || []).map(r => {

View File

@@ -7,113 +7,373 @@
</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 class="page-title">
<span>{{ titleText }}</span>
<el-button size="mini" type="primary" :loading="saving" @click="saveSummary">保存汇总</el-button>
</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>
<el-row :gutter="10" class="mb8">
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value">{{ fmtInt(summary.totalEmployees) }}</div>
<div class="stat-label">总人数</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value stat-value--primary">{{ fmtMoney(payableSalary) }}</div>
<div class="stat-label">应发总薪</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value stat-value--primary">{{ fmtMoney(summary.totalActualSalary) }}</div>
<div class="stat-label">实发总薪</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value">{{ fmtMoney(summary.avgActualSalary) }}</div>
<div class="stat-label">人均薪资</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value stat-value--danger">{{ fmtMoney(summary.totalDeductions) }}</div>
<div class="stat-label">扣款总额</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card">
<div class="stat-item">
<div class="stat-value stat-value--success">{{ fmtMoney(summary.totalBonuses) }}</div>
<div class="stat-label">奖励总额</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 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>
<el-row :gutter="10" class="mb8">
<el-col :span="8">
<el-card shadow="never" class="mini-card">
<div class="mini-row">
<span class="mini-label">薪资记录数</span>
<span class="mini-value">{{ fmtInt(salaryStats.total) }}</span>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="mini-card">
<div class="mini-row">
<span class="mini-label">当月确认数</span>
<span class="mini-value">{{ fmtInt(salaryStats.confirmed) }}</span>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="mini-card">
<div class="mini-row">
<span class="mini-label">状态</span>
<span class="mini-value">{{ summary.status === 1 ? '已确认' : '草稿' }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="chart-card">
<div slot="header" class="chart-header">
<span class="chart-title">薪资分布</span>
</div>
<div ref="salaryChart" class="salary-chart" />
</el-card>
</template>
</div>
</template>
<script>
import { listPerfDeptSummary } from '@/api/perf/deptSummary'
import { listPerfSalary } from '@/api/perf/salary'
import { listEmployeeInfo } from '@/api/wms/employeeInfo'
import * as echarts from 'echarts'
import { savePerfDeptSummaryByPeriod } from '@/api/perf/deptSummary'
export default {
name: 'SummaryPanel',
props: {
deptId: { type: [Number, String], default: null },
deptName: { type: String, default: '' },
period: { type: String, default: '' }
},
data() {
return {
loading: false,
summary: null
saving: false,
// 汇总数据来源:
// 1) 优先后端汇总表 perf_dept_summary
// 2) 若汇总表为空但薪资明细有数据,则在前端基于 perf_salary 聚合生成 summary
summary: null,
// 薪资明细列表(用于图表展示、以及汇总表缺失时的前端聚合兜底)
salaryList: [],
// 员工ID -> 员工姓名 映射(用于图表显示更友好)
employeeNameMap: {},
chart: null
}
},
computed: {
titleText() {
const name = this.deptName || '车间'
return `${name} · ${this.period} 车间汇总`
},
// 应发总薪(展示层计算口径)
payableSalary() {
if (!this.summary) return null
const base = this.toNumber(this.summary.totalBaseSalary)
const perf = this.toNumber(this.summary.totalPerfWage)
const ded = this.toNumber(this.summary.totalDeductions)
const bon = this.toNumber(this.summary.totalBonuses)
const oth = this.toNumber(this.summary.totalOther)
return base + perf + bon + oth - ded
},
salaryStats() {
const total = this.salaryList.length
// 当前阶段已临时停用“状态”筛选与状态列,因此确认数直接按总数展示
const confirmed = total
return { total, confirmed }
},
salaryChartData() {
// 取实领薪资 Top12从低到高展示用于横向柱状图
const list = (this.salaryList || []).map(r => {
const id = r.employeeId != null ? String(r.employeeId) : ''
const name = this.employeeNameMap[id] || id || '-'
return {
name,
value: this.toNumber(r.actualSalary)
}
})
const top = list.sort((a, b) => b.value - a.value).slice(0, 12)
top.reverse()
return top
}
},
watch: {
deptId: { immediate: true, handler: 'loadSummary' },
period: 'loadSummary'
deptId: { immediate: true, handler: 'loadAll' },
period: 'loadAll',
deptName: 'loadAll',
salaryChartData() {
this.$nextTick(() => this.renderChart())
}
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
methods: {
fmt(val) {
return val != null ? val : '-'
toNumber(val) {
if (val == null || val === '') return 0
const n = typeof val === 'number' ? val : parseFloat(val)
return isNaN(n) ? 0 : n
},
loadSummary() {
fmtInt(val) {
if (val == null || val === '') return '-'
const n = typeof val === 'number' ? val : parseFloat(val)
if (isNaN(n)) return '-'
return String(Math.round(n))
},
fmtMoney(val) {
if (val == null || val === '') return '-'
const n = this.toNumber(val)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(n)
},
handleResize() {
if (this.chart) this.chart.resize()
},
// 将当前页面展示的汇总数据落库到 perf_dept_summary
// 后端按 deptId + period 做新增或更新(无须前端关心是否已有记录)
saveSummary() {
if (!this.summary) return
this.saving = true
const payload = {
id: this.summary.id,
deptId: this.deptId,
period: this.period,
totalEmployees: this.summary.totalEmployees,
totalCoeffSum: this.summary.totalCoeffSum,
avgPerfScore: this.summary.avgPerfScore,
avgPerfCoeff: this.summary.avgPerfCoeff,
totalBaseSalary: this.summary.totalBaseSalary,
totalPerfWage: this.summary.totalPerfWage,
totalDeductions: this.summary.totalDeductions,
totalBonuses: this.summary.totalBonuses,
totalOther: this.summary.totalOther,
totalActualSalary: this.summary.totalActualSalary,
avgActualSalary: this.summary.avgActualSalary,
monthlyProduction: this.summary.monthlyProduction,
unitPrice: this.summary.unitPrice,
status: this.summary.status,
remark: this.summary.remark
}
savePerfDeptSummaryByPeriod(payload).then(() => {
this.$message.success('保存成功')
this.loadAll()
}).finally(() => {
this.saving = false
})
},
loadAll() {
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.salaryList = []
this.employeeNameMap = {}
// p1: 汇总表(可能为空)
// p2: 薪资明细(用于图表,也用于汇总表缺失时的前端聚合)
// p3: 员工列表(用于 employeeId -> name 的映射,缺 deptName 时跳过)
const p1 = listPerfDeptSummary({ deptId: this.deptId, period: this.period })
const p2 = listPerfSalary({ deptId: this.deptId, period: this.period, pageSize: 9999 })
const p3 = this.deptName
? listEmployeeInfo({ dept: this.deptName, pageSize: 9999 })
: Promise.resolve({ rows: [] })
Promise.all([p1, p2, p3]).then(([summaryRes, salaryRes, empRes]) => {
const rows = summaryRes.rows || []
this.summary = rows.length > 0 ? rows[0] : null
this.salaryList = salaryRes.rows || []
const map = {}
const empRows = empRes.rows || []
empRows.forEach(e => {
const id = e.employeeId != null ? String(e.employeeId) : (e.infoId != null ? String(e.infoId) : '')
if (id) map[id] = e.name || e.nickName || e.userName || id
})
this.employeeNameMap = map
// 后端汇总表未生成时,直接从薪资明细聚合出汇总,保证统计页可用
if (!this.summary && this.salaryList.length) {
this.summary = this.buildSummaryFromSalary()
}
}).finally(() => {
this.loading = false
this.$nextTick(() => this.renderChart())
})
},
// 前端聚合汇总(仅在 perf_dept_summary 为空时触发)
buildSummaryFromSalary() {
const salaryList = this.salaryList || []
const empSet = new Set()
let totalBaseSalary = 0
let totalPerfWage = 0
let totalDeductions = 0
let totalBonuses = 0
let totalOther = 0
let totalActualSalary = 0
salaryList.forEach(r => {
if (r.employeeId != null) empSet.add(String(r.employeeId))
totalBaseSalary += this.toNumber(r.baseSalary)
totalPerfWage += this.toNumber(r.perfWage)
totalDeductions += this.toNumber(r.deductionsTotal)
totalBonuses += this.toNumber(r.bonusesTotal)
totalOther += this.toNumber(r.otherAmount)
totalActualSalary += this.toNumber(r.actualSalary)
})
const totalEmployees = empSet.size || this.toNumber(this.salaryList.length)
const avgActualSalary = totalEmployees ? totalActualSalary / totalEmployees : 0
return {
deptId: this.deptId,
period: this.period,
totalEmployees,
totalCoeffSum: 0,
avgPerfScore: 0,
avgPerfCoeff: 0,
totalBaseSalary,
totalPerfWage,
totalDeductions,
totalBonuses,
totalOther,
totalActualSalary,
avgActualSalary,
monthlyProduction: 0,
unitPrice: 0,
status: 0
}
},
renderChart() {
if (!this.summary) return
const el = this.$refs.salaryChart
if (!el) return
if (!this.chart) {
this.chart = echarts.init(el)
}
const data = this.salaryChartData
if (!data.length) {
this.chart.clear()
return
}
this.chart.setOption({
grid: { left: 120, right: 24, top: 10, bottom: 10, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const p = Array.isArray(params) ? params[0] : params
return `${p.name}<br/>${this.fmtMoney(p.value)}`
}
},
xAxis: {
type: 'value',
axisLabel: {
formatter: v => {
const n = this.toNumber(v)
return n >= 10000 ? (n / 10000).toFixed(1) + 'w' : String(n)
}
},
splitLine: { lineStyle: { color: '#f0f2f5' } }
},
yAxis: {
type: 'category',
data: data.map(d => d.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#606266', fontSize: 12 }
},
series: [
{
type: 'bar',
data: data.map(d => d.value),
barWidth: 14,
itemStyle: {
color: '#409eff',
borderRadius: [2, 2, 2, 2]
}
}
]
}, true)
}
}
}
@@ -123,8 +383,7 @@ export default {
.summary-panel {
height: 100%;
overflow-y: auto;
background-color: #f5f7fa;
padding: 16px;
padding: 12px;
}
.empty-state {
@@ -136,105 +395,109 @@ export default {
border-radius: 4px;
}
.stat-cards {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-bottom: 16px;
.page-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 4px 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-card {
background-color: #fff;
border-radius: 4px;
padding: 20px 16px;
box-shadow: none;
border: 1px solid #ebeef5;
::v-deep .el-card__header {
border-bottom: none;
}
::v-deep .el-card__body {
padding: 10px 12px;
}
}
.stat-item {
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
margin-top: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
font-size: 18px;
font-weight: 600;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.highlight {
color: #409eff;
.stat-value--primary {
color: #409eff;
}
.stat-value--danger {
color: #f56c6c;
}
.stat-value--success {
color: #67c23a;
}
.mini-card {
border-radius: 4px;
box-shadow: none;
border: 1px solid #ebeef5;
::v-deep .el-card__header {
border-bottom: none;
}
::v-deep .el-card__body {
padding: 10px 12px;
}
}
.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 {
.mini-row {
display: flex;
flex-direction: column;
gap: 4px;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #303133;
}
.detail-label {
font-size: 12px;
.mini-label {
color: #909399;
}
.detail-value {
font-size: 16px;
font-weight: 600;
color: #303133;
&.highlight {
color: #409eff;
.chart-card {
border-radius: 4px;
box-shadow: none;
::v-deep .el-card__header {
padding: 10px 12px;
border-bottom: 1px solid #ebeef5;
}
&.danger {
color: #f56c6c;
}
&.success {
color: #67c23a;
}
&.total {
color: #e6a23c;
font-size: 18px;
::v-deep .el-card__body {
padding: 12px;
}
border: 1px solid #ebeef5;
}
.status-bar {
.chart-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background-color: #fff;
border-radius: 4px;
justify-content: space-between;
}
.status-label {
.chart-title {
font-size: 13px;
color: #606266;
font-weight: 600;
color: #303133;
}
.salary-chart {
height: 320px;
}
</style>

View File

@@ -40,7 +40,7 @@
<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" />
<SummaryPanel v-if="activeTab === 'summary'" :dept-id="currentDept.id" :dept-name="currentDept.deptName" :period="periodStr" />
</div>
</div>
</div>