Files
fad_oa/ruoyi-ui/src/views/oa/performance/rank/index.vue

522 lines
22 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="rank">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="left">
<el-date-picker v-model="query.period" type="month" format="yyyy-MM" value-format="yyyy-MM"
size="small" @change="load" style="width:160px;" />
<el-input v-model="query.nameLike" placeholder="搜索姓名" clearable size="small" style="width:200px;"
@keyup.enter.native="load" @clear="load">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
<el-button size="small" type="primary" icon="el-icon-search" @click="load">查询</el-button>
</div>
<div class="right">
<div class="filter-tabs">
<span class="tab" :class="{active: filter==='all'}" @click="filter='all'">全部 {{ list.length }}</span>
<span class="tab ok" :class="{active: filter==='A'}" @click="filter='A'">A {{ countByLetter('A') }}</span>
<span class="tab b" :class="{active: filter==='B'}" @click="filter='B'">B {{ countByLetter('B') }}</span>
<span class="tab c" :class="{active: filter==='C'}" @click="filter='C'">C {{ countByLetter('C') }}</span>
<span class="tab d" :class="{active: filter==='D'}" @click="filter='D'">D {{ countByLetter('D') }}</span>
</div>
</div>
</div>
<!-- 前三名领奖台 -->
<div v-if="!filter || filter==='all'" class="podium" v-loading="loading">
<template v-for="(row, idx) in podium">
<div :key="row.user_id" class="podium-slot" :class="'p-' + (idx+1)">
<div class="medal">
<i class="el-icon-trophy"></i>
<span class="rank-text">No.{{ idx+1 }}</span>
</div>
<div class="podium-card" :class="cardClass(row.grade)">
<div class="p-grade" :class="gradeClass(row.grade)">{{ row.grade }}</div>
<div class="p-name">{{ row.user_nick }}</div>
<div class="p-score">
<span class="num" :class="totalClass(row.total_score)">{{ row.total_score }}</span>
<span class="div">/ 100</span>
</div>
<div class="p-mini">
<span>基础 {{ row.base_score }}</span>
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
<span>主观 {{ row.bonus }}</span>
</div>
<div class="p-actions" v-if="canScore">
<el-button size="mini" type="primary" plain @click="openScore(row)">打分</el-button>
<el-button size="mini" type="warning" plain @click="openAdjust(row)">加减分</el-button>
</div>
<el-button size="mini" type="text" @click="openDetail(row)">查看流水 </el-button>
</div>
</div>
</template>
</div>
<!-- 其余排名表格 -->
<div class="rest" v-loading="loading">
<el-table :data="restList" border stripe :row-class-name="rowClass">
<el-table-column label="排名" type="index" width="70" align="center" :index="indexFn" />
<el-table-column label="员工" prop="user_nick" min-width="140">
<template slot-scope="{row}">
<span class="user-cell">
<span class="dot" :class="gradeDot(row.grade)"></span>
{{ row.user_nick }}
</span>
</template>
</el-table-column>
<el-table-column label="等级" width="80" align="center">
<template slot-scope="{row}">
<span class="grade-tag" :class="gradeClass(row.grade)">{{ row.grade }}</span>
</template>
</el-table-column>
<el-table-column label="总分" width="100" align="center" sortable prop="total_score">
<template slot-scope="{row}">
<b :class="totalClass(row.total_score)">{{ row.total_score }}</b>
<span class="of">/100</span>
</template>
</el-table-column>
<el-table-column label="基础" prop="base_score" width="80" align="center" />
<el-table-column label="加" width="80" align="center">
<template slot-scope="{row}">
<span v-if="row.reward > 0" class="ok">+{{ row.reward }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="扣" width="80" align="center">
<template slot-scope="{row}">
<span v-if="row.deduction > 0" class="bad">-{{ row.deduction }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column label="主观" width="110" align="center">
<template slot-scope="{row}">
<span :style="(row.bonus||0) > 0 ? 'color:#67c23a' : (row.bonus||0) < 0 ? 'color:#f56c6c' : ''">
{{ (row.bonus || 0) > 0 ? '+' : '' }}{{ row.bonus || 0 }}
</span>
<el-tag v-if="row.subjective_set !== 1 && row.subjectiveSet !== 1"
size="mini" type="info" effect="plain" style="margin-left:4px;">待评</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template slot-scope="{row}">
<el-button type="text" size="mini" @click="openDetail(row)">流水</el-button>
<el-button type="text" size="mini" style="color:#409EFF" @click="openScore(row)" v-if="canScore">打分</el-button>
<el-button type="text" size="mini" style="color:#e6a23c" @click="openAdjust(row)" v-if="canScore">/</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !restList.length && !podium.length" description="本周期暂无数据" />
</div>
<!-- 加减分弹窗 -->
<el-dialog title="手动加减分" :visible.sync="adjustOpen" width="500px" append-to-body :close-on-click-modal="false">
<div v-if="adjustForm.userId" class="form-body">
<div class="form-user">
<div class="u-name">{{ adjustForm.userName }}</div>
<div class="u-period">{{ query.period }} 月度</div>
</div>
<el-form ref="adjustFormRef" :model="adjustForm" :rules="adjustRules" label-width="90px" size="small">
<el-form-item label="操作">
<el-radio-group v-model="adjustForm.dir">
<el-radio-button :label="-1"><i class="el-icon-plus"></i> 加分</el-radio-button>
<el-radio-button :label="1"><i class="el-icon-minus"></i> 扣分</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="分值" prop="absPoints">
<el-input-number v-model="adjustForm.absPoints" :min="1" :max="40" size="small" />
<span class="preview" :class="adjustForm.dir < 0 ? 'ok' : 'bad'">
{{ adjustForm.dir < 0 ? '+' : '' }}{{ adjustForm.absPoints }}
</span>
</el-form-item>
<el-form-item label="原因" prop="reason" required>
<el-input v-model="adjustForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit
placeholder="必填:会写入流水留痕,员工可以在「我的绩效」看到" />
</el-form-item>
<el-form-item label="操作人">
<span class="op-name">{{ currentUserName }}自动</span>
</el-form-item>
</el-form>
</div>
<div slot="footer">
<el-button @click="adjustOpen = false">取消</el-button>
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">
{{ adjustForm.dir < 0 ? '保存加分' : '保存扣分' }}
</el-button>
</div>
</el-dialog>
<!-- 高总主观分弹窗 -->
<el-dialog title="高总主观打分" :visible.sync="scoreOpen" width="520px" append-to-body :close-on-click-modal="false">
<div v-if="scoreForm.userId" class="form-body">
<div class="form-user">
<div class="u-name">{{ scoreForm.userName }}</div>
<div class="u-period">{{ query.period }} 月度</div>
</div>
<div class="info-grid">
<div class="info-tile">
<div class="i-lbl">基础</div>
<div class="i-val">{{ scoreForm.baseScore }}</div>
</div>
<div class="info-tile">
<div class="i-lbl">扣分</div>
<div class="i-val bad">-{{ scoreForm.deduction }}</div>
</div>
<div class="info-tile">
<div class="i-lbl">加分</div>
<div class="i-val ok">+{{ scoreForm.reward || 0 }}</div>
</div>
</div>
<div class="slider-label">主观分-20 ~ +20默认 0 = 未评价/不调节</div>
<el-slider v-model="scoreForm.score" :min="-20" :max="20" show-input
:marks="{'-20':'-20','-10':'-10','0':'0','10':'+10','20':'+20'}" />
<div class="preview-total">
预估总分 =
<span class="calc">{{ scoreForm.baseScore }} - {{ scoreForm.deduction }} + {{ scoreForm.reward || 0 }} + {{ scoreForm.score }}</span>
=
<b :class="totalClass(previewTotal)">{{ previewTotal }}</b>
({{ gradeOf(previewTotal) }})
</div>
</div>
<div slot="footer">
<el-button @click="scoreOpen = false">取消</el-button>
<el-button type="primary" :loading="scoreSubmitting" @click="submitScore">保存打分</el-button>
</div>
</el-dialog>
<!-- 流水弹窗 -->
<el-dialog title="扣分 / 加分 流水" :visible.sync="detailOpen" width="700px" append-to-body>
<div v-if="detail.user" class="detail-head">
<span class="u-name">{{ detail.user }}</span>
<span class="u-period">{{ query.period }}</span>
</div>
<el-empty v-if="!detail.list.length" :image-size="60" description="无流水" />
<el-table v-else :data="detail.list" size="small" border>
<el-table-column label="时间" prop="createTime" width="160" />
<el-table-column label="来源" width="120" align="center">
<template slot-scope="{row}">
<el-tag size="mini" :type="srcTag(row.source)">{{ srcLabel(row.source) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务" width="80" align="center">
<template slot-scope="{row}">{{ bizLabel(row.sourceType) }}</template>
</el-table-column>
<el-table-column label="分值" width="70" align="center">
<template slot-scope="{row}">
<b :style="{color: row.points > 0 ? '#f56c6c' : '#67c23a'}">
{{ row.points > 0 ? '-' : '+' }}{{ Math.abs(row.points) }}
</b>
</template>
</el-table-column>
<el-table-column label="原因" prop="reason" show-overflow-tooltip />
</el-table>
</el-dialog>
</div>
</template>
<script>
import { rankPerformance, userPerformance, setSubjective, manualAdjust } from '@/api/oa/performance'
// 可以打分的人员白名单:高总 + 陆永强
const SCORER_USER_IDS = ['1859252208375152641', '1858417253738815490']
export default {
name: 'OaPerformanceRank',
data() {
return {
loading: false,
query: { period: '', nameLike: '' },
filter: 'all',
list: [],
detailOpen: false,
detail: { user: '', list: [] },
scoreOpen: false,
scoreSubmitting: false,
scoreForm: { userId: null, userName: '', baseScore: 80, deduction: 0, reward: 0, score: 0 },
adjustOpen: false,
adjustSubmitting: false,
adjustForm: { userId: null, userName: '', dir: -1, absPoints: 5, reason: '' },
adjustRules: {
reason: [{ required: true, message: '请填写原因(必填)', trigger: 'blur' },
{ min: 3, message: '原因至少 3 个字', trigger: 'blur' }]
}
}
},
computed: {
canScore() {
const uid = this.$store.getters.userId || (this.$store.state.user && this.$store.state.user.userId)
const uidStr = String(uid || '')
const roles = (this.$store.state.user && this.$store.state.user.roles) || []
return SCORER_USER_IDS.includes(uidStr)
|| roles.includes('admin')
|| (this.$store.state.user && this.$store.state.user.name === 'admin')
},
currentUserName() {
return (this.$store.state.user && (this.$store.state.user.nickName || this.$store.state.user.name)) || '当前操作人'
},
filteredList() {
if (this.filter === 'all') return this.list
return this.list.filter(r => r.grade && r.grade.charAt(0) === this.filter)
},
podium() {
return this.filteredList.slice(0, 3)
},
restList() {
return this.filteredList.slice(3)
},
previewTotal() {
const t = (this.scoreForm.baseScore || 0) - (this.scoreForm.deduction || 0)
+ (this.scoreForm.reward || 0) + (this.scoreForm.score || 0)
return Math.max(0, Math.min(100, t))
}
},
created() {
const d = new Date()
this.query.period = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
this.load()
},
methods: {
load() {
this.loading = true
rankPerformance(this.query).then(res => {
this.list = res.data || []
}).finally(() => { this.loading = false })
},
countByLetter(letter) {
return this.list.filter(r => r.grade && r.grade.charAt(0) === letter).length
},
indexFn(i) { return i + 4 },
cardClass(g) {
if (!g) return ''
if (g.startsWith('A')) return 'card-a'
if (g.startsWith('B')) return 'card-b'
if (g.startsWith('C')) return 'card-c'
return 'card-d'
},
gradeClass(g) {
if (!g) return ''
if (g.startsWith('A')) return 'g-a'
if (g.startsWith('B')) return 'g-b'
if (g.startsWith('C')) return 'g-c'
return 'g-d'
},
gradeDot(g) { return this.gradeClass(g) },
rowClass({ row }) {
if (!row.grade) return ''
if (row.grade.startsWith('D')) return 'row-d'
return ''
},
totalClass(n) {
n = Number(n) || 0
if (n >= 90) return 'ok'
if (n >= 75) return 'warn'
return 'bad'
},
gradeOf(n) {
if (n >= 95) return 'A+'; if (n >= 90) return 'A'; if (n >= 85) return 'A-'
if (n >= 80) return 'B+'; if (n >= 75) return 'B'; if (n >= 70) return 'B-'
if (n >= 65) return 'C+'; if (n >= 60) return 'C'; if (n >= 55) return 'C-'
return 'D'
},
openDetail(row) {
userPerformance(row.user_id, this.query.period).then(res => {
const d = res.data || {}
this.detail = { user: row.user_nick, list: d.deductions || [] }
this.detailOpen = true
})
},
openScore(row) {
this.scoreForm = {
userId: row.user_id, userName: row.user_nick,
baseScore: row.base_score || 80,
deduction: row.deduction || 0,
reward: row.reward || 0,
score: row.bonus == null ? 0 : row.bonus
}
this.scoreOpen = true
},
submitScore() {
this.scoreSubmitting = true
setSubjective(this.scoreForm.userId, this.scoreForm.userName, this.query.period, this.scoreForm.score)
.then(() => {
this.$modal.msgSuccess('已保存')
this.scoreOpen = false
this.load()
}).finally(() => { this.scoreSubmitting = false })
},
openAdjust(row) {
this.adjustForm = {
userId: row.user_id, userName: row.user_nick,
dir: -1, absPoints: 5, reason: ''
}
this.adjustOpen = true
},
submitAdjust() {
this.$refs.adjustFormRef.validate(valid => {
if (!valid) {
this.$modal.msgWarning('请填写原因(必填)')
return
}
this.adjustSubmitting = true
const points = this.adjustForm.dir * this.adjustForm.absPoints
const fullReason = '[' + this.currentUserName + '] ' + this.adjustForm.reason.trim()
manualAdjust(this.adjustForm.userId, this.adjustForm.userName, points, fullReason)
.then(() => {
this.$modal.msgSuccess(points > 0 ? '已扣分' : '已加分')
this.adjustOpen = false
this.load()
}).finally(() => { this.adjustSubmitting = false })
})
},
srcLabel(s) { return ({ postpone:'顺延', overdue30:'超期30天', overdue_daily:'日逾期', noreport:'忘报工', manual:'手动', manual_subjective:'高总打分', reward_task:'完成任务', reward_step:'完成步骤', reward_report:'报工', reward_trip:'出差', reward_attend:'全勤' })[s] || s },
srcTag(s) {
if (s && s.startsWith('reward_')) return 'success'
if (s === 'manual') return 'info'
return 'danger'
},
bizLabel(t) { return ({ task:'任务', step:'步骤', requirement:'采购需求' })[t] || (t || '-') }
}
}
</script>
<style scoped>
.rank {
padding: 16px 20px; background: #f5f7fa; min-height: calc(100vh - 50px);
}
.muted { color: #909399; font-size: 12px; }
.ok { color: #67c23a; }
.warn { color: #e6a23c; }
.bad { color: #f56c6c; }
.of { color: #909399; font-size: 11px; margin-left: 2px; }
/* ====== Toolbar ====== */
.toolbar {
background: #fff; padding: 12px 16px; border-radius: 10px; margin-bottom: 14px;
display: flex; justify-content: space-between; align-items: center; gap: 12px;
flex-wrap: wrap;
}
.left { display: flex; gap: 10px; align-items: center; }
.filter-tabs { display: flex; gap: 4px; background: #f4f4f5; padding: 4px; border-radius: 18px; }
.tab {
padding: 4px 14px; border-radius: 14px; font-size: 12px; cursor: pointer;
color: #606266; transition: all .15s; user-select: none;
}
.tab.active { background: #fff; color: #303133; box-shadow: 0 1px 3px rgba(0,0,0,.1); font-weight: 600; }
.tab.ok.active { color: #67c23a; }
.tab.b.active { color: #409EFF; }
.tab.c.active { color: #e6a23c; }
.tab.d.active { color: #f56c6c; }
/* ====== Podium 前三名 ====== */
.podium {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 14px;
}
.podium-slot { position: relative; }
.podium-slot.p-1 { order: 2; } /* 第一名居中 */
.podium-slot.p-2 { order: 1; }
.podium-slot.p-3 { order: 3; }
.podium-card {
background: #fff; border-radius: 14px; padding: 22px 18px 16px; text-align: center;
position: relative; box-shadow: 0 2px 10px rgba(0,0,0,.04);
transition: all .2s;
margin-top: 30px;
}
.podium-slot.p-1 .podium-card { margin-top: 0; padding: 30px 18px 18px; }
.podium-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,.1); transform: translateY(-3px); }
.podium-card.card-a { background: linear-gradient(180deg, #f0f9eb 0%, #fff 50%); border-top: 4px solid #67c23a; }
.podium-card.card-b { background: linear-gradient(180deg, #ecf5ff 0%, #fff 50%); border-top: 4px solid #409EFF; }
.podium-card.card-c { background: linear-gradient(180deg, #fdf6ec 0%, #fff 50%); border-top: 4px solid #e6a23c; }
.podium-card.card-d { background: linear-gradient(180deg, #fef0f0 0%, #fff 50%); border-top: 4px solid #f56c6c; }
.medal {
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
z-index: 2; display: flex; align-items: center; gap: 4px;
padding: 6px 14px; border-radius: 20px; color: #fff;
font-size: 12px; font-weight: 600;
box-shadow: 0 4px 12px rgba(0,0,0,.15);
}
.p-1 .medal { background: linear-gradient(135deg, #ffd700, #ff9500); top: -16px; }
.p-2 .medal { background: linear-gradient(135deg, #d0d4d8, #909399); }
.p-3 .medal { background: linear-gradient(135deg, #cd7f32, #a0522d); }
.medal i { font-size: 14px; }
.p-grade {
font-size: 60px; font-weight: 800; line-height: 1; margin: 6px 0 10px;
color: #67c23a;
}
.podium-slot.p-1 .p-grade { font-size: 72px; }
.p-grade.g-a { color: #67c23a; }
.p-grade.g-b { color: #409EFF; }
.p-grade.g-c { color: #e6a23c; }
.p-grade.g-d { color: #f56c6c; }
.p-name { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 6px; }
.p-score { display: flex; justify-content: center; align-items: baseline; gap: 3px; margin-bottom: 10px; }
.p-score .num { font-size: 26px; font-weight: 700; }
.p-score .num.ok { color: #67c23a; }
.p-score .num.warn { color: #e6a23c; }
.p-score .num.bad { color: #f56c6c; }
.p-score .div { font-size: 12px; color: #909399; }
.p-mini {
display: flex; justify-content: center; gap: 8px; font-size: 11px;
color: #606266; padding: 8px 0; border-top: 1px dashed #ebeef5;
border-bottom: 1px dashed #ebeef5; margin-bottom: 10px;
}
.p-actions { display: flex; gap: 6px; justify-content: center; margin-bottom: 8px; }
/* ====== Rest Table ====== */
.rest { background: #fff; border-radius: 10px; padding: 10px 14px; }
.user-cell { display: flex; align-items: center; gap: 8px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #c0c4cc; }
.dot.g-a { background: #67c23a; }
.dot.g-b { background: #409EFF; }
.dot.g-c { background: #e6a23c; }
.dot.g-d { background: #f56c6c; }
.grade-tag {
display: inline-block; padding: 2px 12px; border-radius: 12px;
color: #fff; font-weight: 600; font-size: 12px;
}
.grade-tag.g-a { background: #67c23a; }
.grade-tag.g-b { background: #409EFF; }
.grade-tag.g-c { background: #e6a23c; }
.grade-tag.g-d { background: #f56c6c; }
::v-deep .row-d { background: #fff5f5 !important; }
/* ====== Dialogs ====== */
.form-body { padding: 0 8px; }
.form-user {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 18px;
padding-bottom: 14px; border-bottom: 1px dashed #ebeef5;
}
.form-user .u-name { font-size: 18px; font-weight: 600; color: #303133; }
.form-user .u-period { color: #909399; font-size: 13px; }
.preview { margin-left: 14px; font-size: 15px; font-weight: 600; }
.op-name { color: #606266; }
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 18px; }
.info-tile { background: #fafafa; border-radius: 6px; padding: 10px; text-align: center; }
.i-lbl { font-size: 11px; color: #909399; margin-bottom: 3px; }
.i-val { font-size: 20px; font-weight: 700; color: #303133; }
.i-val.ok { color: #67c23a; }
.i-val.bad { color: #f56c6c; }
.slider-label { color: #303133; font-weight: 500; margin-bottom: 14px; font-size: 13px; }
.preview-total {
margin-top: 24px; padding: 12px 16px; background: #fafafa;
border-radius: 8px; font-size: 13px; color: #606266; line-height: 1.7;
}
.preview-total .calc { color: #909399; font-family: Menlo, Consolas, monospace; }
.preview-total b { font-size: 22px; margin: 0 4px; }
.preview-total b.ok { color: #67c23a; }
.preview-total b.warn { color: #e6a23c; }
.preview-total b.bad { color: #f56c6c; }
.detail-head { display: flex; gap: 14px; align-items: baseline; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px dashed #ebeef5; }
.detail-head .u-name { font-size: 16px; font-weight: 600; }
.detail-head .u-period { color: #909399; }
</style>