Files
klp-oa/klp-ui/src/views/perf/index/SalaryPanel.vue
砂糖 475a051b1c fix: 修复多处表单与接口适配问题,优化钢卷状态筛选逻辑
1. 修复SalaryPanel输入框边框缺失样式
2. 调整wms报表日期范围计算与视图类型默认值
3. 统一多页面钢卷状态筛选组件:替换为下拉选项新增历史卷,新增状态转换逻辑,修正接口传参格式
4. 同步crm合同、业务员页面的钢卷筛选适配改动
2026-07-13 11:19:27 +08:00

1005 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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" />
<!-- 状态筛选当前阶段为保证统计页能直接聚合展示临时停用 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-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>
<el-button size="small" type="warning" icon="el-icon-upload" :loading="batchSaving" @click="handleBatchSave">批量保存</el-button>
</div>
</div>
<!-- 口径说明 -->
<div class="formula-bar" :class="{ expanded: formulaExpanded }" @click="formulaExpanded = !formulaExpanded">
<span class="formula-bar-title">
<i :class="formulaExpanded ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" />
口径说明
</span>
<span class="formula-bar-hint" v-if="!formulaExpanded">总系数 = (岗位系数+固定系数+调整系数+手动系数+绩效系数) × 车间系数</span>
</div>
<div v-if="formulaExpanded" class="formula-detail">
<div class="formula-row"><b>绩效系数</b> = 加权总分 ÷ 100考核后自动同步</div>
<div class="formula-row"><b>车间系数</b> = 车间固定系数默认 1.0不参与加法作为整体乘数</div>
<div class="formula-row"><b>总系数</b> = (岗位系数 + 固定系数 + 调整系数 + 手动系数 + 绩效系数) × 车间系数</div>
<div class="formula-row"><b>绩效工资</b> = 绩效基数 × 总系数</div>
<div class="formula-row"><b>实领薪资</b> = 底薪 + 绩效工资 + 奖励合计 扣款合计 + 其他金额自动计算</div>
</div>
<!-- 表格 -->
<div class="table-wrap">
<el-table ref="salaryTable" :data="list" stripe size="small" v-loading="loading" empty-text="暂无薪资记录" class="inline-edit-table" border>
<!-- 薪资基数 -->
<el-table-column label="姓名" width="70" align="center">
<template slot-scope="scope">
<span class="cell-clickable" @click.stop="toggleRowExpand(scope.row)">{{ scope.row.wmsEmployeeInfo ? scope.row.wmsEmployeeInfo.name : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="岗位" width="70" align="center">
<template slot-scope="scope">
<span class="cell-clickable" @click.stop="toggleRowExpand(scope.row)">{{ scope.row.wmsEmployeeInfo ? scope.row.wmsEmployeeInfo.jobType : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="底薪" width="60" align="right"><template slot-scope="s">
<input v-model="s.row.baseSalary" class="cell-input cell-input--base" @change="recalcSalary(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="绩效基数" width="80" 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="80" align="center">
<template slot-scope="scope">
<span class="cell-clickable cell-clickable--score" @click.stop="toggleRowExpand(scope.row)">{{ scope.row.perfScore != null ? scope.row.perfScore : '-' }}</span>
</template>
</el-table-column>
<!-- 系数 -->
<el-table-column label="绩效系数" width="80" align="center">
<template slot-scope="scope">
<span class="cell-clickable cell-clickable--coeff" @click.stop="toggleRowExpand(scope.row)">{{ scope.row.perfCoeff != null ? scope.row.perfCoeff : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="车间系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.deptCoeff" class="cell-input cell-input--coeff" @change="recalcCoeffs(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="岗位系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.posCoeff" class="cell-input cell-input--coeff" @change="recalcCoeffs(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="固定系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.fixedCoeff" class="cell-input cell-input--coeff" @change="recalcCoeffs(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="调整系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.adjCoeff" class="cell-input cell-input--coeff" @change="recalcCoeffs(s.row); markDirty(s.row)" />
</template></el-table-column>
<!-- 手动系数 隐藏由考核得分自动覆盖 -->
<!-- <el-table-column label="手动系数" width="80" 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="70" align="right">
<template slot-scope="scope"><span class="cell-readonly">{{ fmt(scope.row.totalCoeff) }}</span></template>
</el-table-column>
<!-- 绩效工资 -->
<el-table-column label="绩效工资" width="80" align="right">
<template slot-scope="scope"><span class="cell-readonly">{{ fmt(scope.row.perfWage) }}</span></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="80" 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="80" 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="80" align="right"><template slot-scope="s">
<input v-model="s.row.otherAmount" class="cell-input cell-input--amount" @change="recalcSalary(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">{{ fmt(scope.row.actualSalary) }}</span></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="80"><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 v-if="expandedRow" class="expand-panel" v-loading="appLoading">
<div class="expand-panel-header">
<span class="expand-panel-title">
绩效考核评分
{{ expandedRow.wmsEmployeeInfo ? expandedRow.wmsEmployeeInfo.name : expandedRow.employeeId }}
{{ expandedRow.wmsEmployeeInfo ? expandedRow.wmsEmployeeInfo.jobType : '' }}
</span>
<el-tag v-if="expandedRow._appraisal" :type="expandedRow._appraisal.status === '1' ? 'warning' : 'info'" size="mini">
{{ expandedRow._appraisal.status === '1' ? '已确认' : '草稿' }}
</el-tag>
<span v-if="expandedRow._appraisal" class="expand-panel-score">
加权总分{{ weightedTotal }}
</span>
<el-button size="mini" type="success" :loading="saveLoading" @click="handleSaveScores">保存</el-button>
<el-button type="text" size="mini" icon="el-icon-close" @click="handleCollapseExpand" style="margin-left:auto">收起</el-button>
</div>
<el-table v-if="expandedRow._appDetails && expandedRow._appDetails.length" :data="expandedRow._appDetails" size="mini" border class="expand-detail-table">
<el-table-column prop="dimName" label="考核维度" width="90" />
<el-table-column prop="quantitativeIndicator" label="考核指标" min-width="100" />
<el-table-column label="实际值" width="100" align="center">
<template slot-scope="scope">
<input v-model="scope.row.actualValue" class="cell-input cell-input--actual" @change="markDetailDirty(scope.row)" />
</template>
</el-table-column>
<el-table-column prop="targetValue" label="目标值" width="80" align="center" />
<el-table-column label="得分" width="80" align="center">
<template slot-scope="scope">
<input v-model="scope.row.dimScore" class="cell-input cell-input--score" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="加分" width="70" align="center">
<template slot-scope="scope">
<input v-model="scope.row.bonus" class="cell-input cell-input--bonus" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="扣分" width="70" align="center">
<template slot-scope="scope">
<input v-model="scope.row.penalty" class="cell-input cell-input--penalty" placeholder="0" />
</template>
</el-table-column>
<el-table-column prop="weight" label="权重" width="70" align="center" />
<el-table-column label="总分" width="80" align="center">
<template slot-scope="scope">
<span class="cell-subtotal">{{ subtotal(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="原始总分" width="80" align="center">
<template slot-scope="scope">
<span class="cell-rawtotal">{{ rawRowTotal(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column prop="scoreRule" label="评分规则" min-width="120" show-overflow-tooltip />
</el-table>
<div v-else-if="expandedRow._appLoaded && !appLoading" class="expand-empty">暂无绩效考核数据</div>
</div>
<!-- 一键生成弹窗 -->
<GenerateDialog
:visible.sync="showGenerateDialog"
:dept-id="deptId"
:dept-name="deptName"
:period="period"
@done="onGenerateDone"
/>
<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 { listPerfAppraisal, updatePerfAppraisal } from '@/api/perf/appraisal'
import { listPerfAppraisalDetail, updatePerfAppraisalDetail, batchUpdatePerfAppraisalDetail } from '@/api/perf/appraisalDetail'
import { listPerfDeptItemConfig } from '@/api/perf/deptItemConfig'
import { listPerfDeptConfig } from '@/api/perf/deptConfig'
import GenerateDialog from './GenerateDialog.vue'
import { calculateTotal } from '@/utils/scoreEngine'
import { fetchProductionData } from '@/utils/perfDataService'
export default {
name: 'SalaryPanel',
components: { GenerateDialog },
props: {
deptId: { type: [Number, String], default: null },
deptName: { type: String, default: '' },
period: { type: String, default: '' },
productionLine: { type: String, default: '' }
},
data() {
return {
loading: false,
list: [],
total: 0,
query: {
pageNum: 1,
pageSize: 10,
employeeId: '',
// status: ''
},
deductionItems: [],
bonusItems: [],
generating: false,
batchSaving: false,
showGenerateDialog: false,
expandedRow: null,
appLoading: false,
calcLoading: false,
saveLoading: false,
_prodCache: null,
formulaExpanded: false,
_defaultDeptCoeff: 1
}
},
computed: {
weightedTotal() {
if (!this.expandedRow || !this.expandedRow._appDetails || !this.expandedRow._appDetails.length) return '-'
let sum = 0
this.expandedRow._appDetails.forEach(d => {
sum += (parseFloat(d.dimScore) || 0) * (parseFloat(d.weight) || 0) / 100
})
const bonus = this.expandedRow._appDetails.reduce((s, d) => s + (parseFloat(d.bonus) || 0), 0)
const penalty = this.expandedRow._appDetails.reduce((s, d) => s + (parseFloat(d.penalty) || 0), 0)
const total = Math.min(120, Math.max(0, sum + bonus - penalty))
return total.toFixed(1)
}
},
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 = []
})
// 加载考核参数获取车间系数默认值
listPerfDeptConfig({ deptId: this.deptId, month: this.period + '-01', pageNum: 1, pageSize: 1 }).then(res => {
const rows = res.rows || []
this._defaultDeptCoeff = rows.length ? (rows[0].deptCoeff || 1) : 1
}).catch(() => {
this._defaultDeptCoeff = 1
})
},
/* 解析扣款/奖励 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)
},
/** 系数变化时自动重算总系数和绩效工资 */
recalcCoeffs(row) {
const pos = parseFloat(row.posCoeff) || 0
const fixed = parseFloat(row.fixedCoeff) || 0
const adj = parseFloat(row.adjCoeff) || 0
const adjManual = parseFloat(row.adjCoeffManual) || 0
const perf = parseFloat(row.perfCoeff) || 0
const dept = parseFloat(row.deptCoeff) || 1
const total = (pos + fixed + adj + adjManual + perf) * dept
this.$set(row, 'totalCoeff', total.toFixed(2))
const perfBase = parseFloat(row.perfBase) || 0
this.$set(row, 'perfWage', (perfBase * total).toFixed(2))
this.recalcSalary(row)
},
/** 自动计算实领薪资 */
recalcSalary(row) {
const baseSalary = parseFloat(row.baseSalary) || 0
const perfWage = parseFloat(row.perfWage) || 0
const bonuses = parseFloat(row._bonusesTotal) || 0
const deductions = parseFloat(row._deductionsTotal) || 0
const other = parseFloat(row.otherAmount) || 0
const actual = baseSalary + perfWage + bonuses - deductions + other
this.$set(row, 'actualSalary', actual.toFixed(2))
},
onDeductChange(row) { this.recalcTotals(row); this.recalcSalary(row) },
onBonusChange(row) { this.recalcTotals(row); this.recalcSalary(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
}
listPerfSalary(params).then(res => {
this.list = (res.rows || []).map(r => {
this.$set(r, '_dirty', false)
this.$set(r, '_saving', false)
// 车间系数默认从考核参数获取,否则为 1
if (!r.deptCoeff || parseFloat(r.deptCoeff) === 0) {
this.$set(r, 'deptCoeff', (this._defaultDeptCoeff || 1).toString())
}
this.parseRowItems(r)
this.recalcCoeffs(r)
this.recalcSalary(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.showGenerateDialog = true
},
onGenerateDone() {
this.loadList()
this.$message.success('薪资记录生成完成')
},
/** 批量保存所有已修改的行 */
handleBatchSave() {
const dirtyRows = this.list.filter(r => r._dirty)
if (dirtyRows.length === 0) {
this.$message.info('没有需要保存的修改')
return
}
this.batchSaving = true
const tasks = dirtyRows.map(row => {
this.$set(row, '_saving', true)
const data = { ...row }
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
// 清除前端扩展字段
delete data.wmsEmployeeInfo
delete data._appLoaded; delete data._appraisal; delete data._appDetails
const api = data.id ? updatePerfSalary : addPerfSalary
return api(data).then(res => {
if (!data.id && res.data) {
this.$set(row, 'id', res.data.id || res.data)
}
this.$set(row, '_dirty', false)
return { ok: true }
}).catch(() => {
return { ok: false }
}).finally(() => {
this.$set(row, '_saving', false)
})
})
Promise.all(tasks).then(results => {
const ok = results.filter(r => r.ok).length
const fail = results.filter(r => !r.ok).length
if (fail > 0) {
this.$message.warning(`保存完成:成功 ${ok} 条,失败 ${fail}`)
} else {
this.$message.success(`已保存 ${ok} 条记录`)
}
}).finally(() => {
this.batchSaving = false
})
},
/** 点击可点击列时切换展开,并加载绩效考核明细 */
toggleRowExpand(row) {
if (this.expandedRow === row) {
this.expandedRow = null
return
}
this.expandedRow = row
if (row._appLoaded) return
this.appLoading = true
const empId = row.employeeId
if (!empId) {
this.appLoading = false
this.$set(row, '_appLoaded', true)
return
}
listPerfAppraisal({ employeeId: empId, deptId: this.deptId, period: this.period, pageNum: 1, pageSize: 1 }).then(appRes => {
const appRows = appRes.rows || []
if (appRows.length === 0) {
this.$set(row, '_appraisal', null)
this.$set(row, '_appDetails', [])
return
}
this.$set(row, '_appraisal', appRows[0])
return listPerfAppraisalDetail({ appraisalId: appRows[0].id, pageSize: 9999 })
}).then(detailRes => {
if (detailRes) {
this.$set(row, '_appDetails', detailRes.rows || [])
}
}).catch(() => {
this.$set(row, '_appraisal', null)
this.$set(row, '_appDetails', [])
}).finally(() => {
this.appLoading = false
this.$set(row, '_appLoaded', true)
})
},
/** 计算得分 */
async handleCalcScore() {
if (!this.expandedRow._appDetails || !this.expandedRow._appDetails.length) return
this.calcLoading = true
try {
// 拉取部门生产数据(缓存按 deptId+period
if (!this._prodCache || this._prodCache.key !== `${this.deptId}_${this.period}`) {
this._prodCache = {
key: `${this.deptId}_${this.period}`,
data: await fetchProductionData(this.productionLine, this.period)
}
}
const prod = this._prodCache.data
// 将实际数据填入各维度
const details = this.expandedRow._appDetails
details.forEach(d => {
switch (d.dimName) {
case '生产效率':
if (prod.totalWeight > 0 && !d.actualValue) {
this.$set(d, 'actualValue', String(Math.round(prod.totalWeight * 100) / 100))
}
break
case '产品质量':
if (prod.totalCount > 0 && !d.actualValue) {
const ratio = Math.round((prod.abCount / prod.totalCount) * 10000) / 100
this.$set(d, 'actualValue', String(ratio))
}
break
case '成本控制':
if (prod.avgCostPerTon > 0 && !d.actualValue) {
this.$set(d, 'actualValue', String(Math.round(prod.avgCostPerTon * 100) / 100))
}
break
}
})
const { finalScore, details: computed } = calculateTotal(details)
computed.forEach((d, i) => {
this.$set(details[i], 'dimScore', d._dimScore)
})
this.$message.success('计算完成,请检查后保存')
} finally {
this.calcLoading = false
}
},
/** 保存得分到后端 */
async handleSaveScores() {
if (!this.expandedRow._appDetails || !this.expandedRow._appDetails.length) return
this.saveLoading = true
try {
const details = this.expandedRow._appDetails
// 构建批量更新数据
const batchData = details.map(d => ({
id: d.id,
actualValue: d.actualValue || '',
dimScore: parseFloat(d.dimScore) || 0,
bonus: parseFloat(d.bonus) || 0,
penalty: parseFloat(d.penalty) || 0
}))
// 批量更新维度明细
await batchUpdatePerfAppraisalDetail(batchData)
// 按照权重计算总分
let weightedSum = 0
let totalBonus = 0
let totalPenalty = 0
details.forEach(d => {
const score = parseFloat(d.dimScore) || 0
const weight = parseFloat(d.weight) || 0
weightedSum += score * weight / 100
totalBonus += parseFloat(d.bonus) || 0
totalPenalty += parseFloat(d.penalty) || 0
})
const finalScore = Math.min(120, Math.max(0, weightedSum + totalBonus - totalPenalty))
// 更新总考核记录
if (this.expandedRow._appraisal && this.expandedRow._appraisal.id) {
await updatePerfAppraisal({ id: this.expandedRow._appraisal.id, finalScore })
this.$set(this.expandedRow._appraisal, 'finalScore', finalScore)
}
// 同步绩效系数到薪资记录
const perfCoeff = finalScore / 100
if (this.expandedRow.id) {
await updatePerfSalary({ id: this.expandedRow.id, perfCoeff })
this.$set(this.expandedRow, 'perfCoeff', perfCoeff)
this.recalcCoeffs(this.expandedRow)
}
this.$message.success('保存成功')
} catch {
this.$message.error('保存失败')
} finally {
this.saveLoading = false
}
},
/** 收起展开面板 */
handleCollapseExpand() {
this.expandedRow = null
},
/** 标记 detail 已修改 */
markDetailDirty() {},
subtotal(row) {
const score = parseFloat(row.dimScore) || 0
const weight = parseFloat(row.weight) || 0
return (score * weight / 100).toFixed(1)
},
rawRowTotal(row) {
const score = parseFloat(row.dimScore) || 0
const bonus = parseFloat(row.bonus) || 0
const penalty = parseFloat(row.penalty) || 0
return (score + bonus - penalty).toFixed(1)
},
handleExpandChange() {}
}
}
</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: 1px solid #dcdfe6;
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;
}
/* 口径说明 */
.formula-bar {
display: flex;
align-items: center;
padding: 4px 8px;
background: #f0f5ff;
border: 1px solid #d9ecff;
border-radius: 4px;
cursor: pointer;
user-select: none;
flex-shrink: 0;
transition: background 0.15s;
&:hover {
background: #e6f0fc;
}
&.expanded {
border-radius: 4px 4px 0 0;
margin-bottom: 0;
}
}
.formula-bar-title {
font-size: 12px;
font-weight: 600;
color: #409eff;
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.formula-bar-hint {
font-size: 11px;
color: #909399;
margin-left: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.formula-detail {
padding: 8px 12px;
background: #fafbfc;
border: 1px solid #d9ecff;
border-top: none;
border-radius: 0 0 4px 4px;
flex-shrink: 0;
}
.formula-row {
font-size: 12px;
color: #606266;
line-height: 1.8;
b {
color: #303133;
}
}
.el-table .el-button + .el-button {
margin-left: 0;
}
/* 展开面板 */
.expand-panel {
margin-top: 8px;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
flex-shrink: 0;
}
.expand-panel-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.expand-panel-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.expand-panel-score {
margin-left: 12px;
font-size: 14px;
color: #409eff;
font-weight: 600;
}
.expand-detail-table {
width: 100%;
}
.expand-empty {
color: #909399;
font-size: 13px;
text-align: center;
padding: 12px 0;
}
/* 实际值输入 */
.cell-input--actual {
width: 100%;
height: 26px;
border: 1px solid #dcdfe6;
outline: none;
text-align: center;
padding: 0 4px;
font-size: 12px;
color: #303133;
box-sizing: border-box;
transition: border-color 0.15s;
&:focus {
border-color: #409eff;
}
}
/* 加分扣分输入 */
.cell-input--bonus,
.cell-input--penalty {
width: 100%;
height: 26px;
border: 1px solid #dcdfe6;
outline: none;
text-align: center;
padding: 0 4px;
font-size: 12px;
box-sizing: border-box;
transition: border-color 0.15s;
&:focus {
border-color: #409eff;
}
}
.cell-input--bonus {
color: #67c23a;
background: #f0f9eb;
}
.cell-input--penalty {
color: #f56c6c;
background: #fef0f0;
}
/* 得分手动输入 */
.cell-input--score {
width: 100%;
height: 26px;
border: 1px solid #dcdfe6;
outline: none;
text-align: center;
padding: 0 4px;
font-size: 12px;
color: #409eff;
font-weight: 600;
box-sizing: border-box;
transition: border-color 0.15s;
&:focus {
border-color: #409eff;
}
}
/* 总分列 */
.cell-subtotal {
font-weight: 600;
color: #e6a23c;
font-size: 13px;
}
.cell-rawtotal {
font-weight: 600;
color: #409eff;
font-size: 13px;
}
/* 得分显示 */
.cell-score {
font-weight: 600;
color: #409eff;
&--empty {
color: #c0c4cc;
}
}
/* 可点击列样式 */
.cell-clickable {
display: inline-block;
cursor: pointer;
color: #409eff;
border-bottom: 1px dashed #409eff;
padding: 0 2px;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: #337ecc;
border-bottom-style: solid;
}
&--score {
color: #e6a23c;
border-bottom-color: #e6a23c;
&:hover { color: #cf9236; }
}
&--coeff {
color: #67c23a;
border-bottom-color: #67c23a;
&:hover { color: #529b2e; }
}
}
</style>