| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 |
- <template>
- <div class="page">
- <div class="page-hero">
- <div class="hero-info">
- <h2>绩效考评</h2>
- <p>{{ data.targetUser?.realName }} · {{ data.period?.name }}</p>
- </div>
- <el-tag size="large" effect="dark" round>{{ data.period?.startDate }} ~ {{ data.period?.endDate }}</el-tag>
- </div>
- <div class="same-screen" v-if="data.targetUser">
- <!-- Left: OKR Records + Self Assessment -->
- <div class="left-panel">
- <!-- OKR execution records -->
- <div class="panel-head">当期 OKR 完整记录</div>
- <div v-if="!okrRecords.length" class="panel-empty">暂无 OKR 记录</div>
- <div v-for="o in okrRecords" :key="o.id" class="okr-record-card">
- <!-- O header -->
- <div class="orr-o-head">
- <span class="orr-o-badge">O</span>
- <span class="orr-o-title">{{ o.title }}</span>
- <el-tag v-if="o.reviewedAt" size="small" type="success" effect="plain" round>已通过</el-tag>
- </div>
- <div class="orr-o-meta" v-if="o.reviewedAt">
- {{ o.reviewerName }} 审核 · {{ o.reviewedAt?.slice(0, 10) }}
- </div>
- <!-- KR list -->
- <div class="orr-krs">
- <div v-for="(kr, i) in o.keyResults" :key="kr.id" class="orr-kr-card">
- <!-- KR header -->
- <div class="orr-kr-header">
- <span class="orr-kr-num">KR{{ i + 1 }}</span>
- <span class="orr-kr-title">{{ kr.title }}</span>
- <el-tag :type="krStatusTag(kr.status)" size="small" effect="plain" round>
- {{ krStatusLabel(kr.status) }}
- </el-tag>
- </div>
- <!-- KR metrics -->
- <div class="orr-kr-metrics">
- <div class="orkm-item">
- <span class="orkm-label">进度</span>
- <span class="orkm-value">{{ kr.currentValue }} / {{ kr.targetValue }}</span>
- </div>
- <div class="orkm-item">
- <span class="orkm-label">完成率</span>
- <span class="orkm-value" :style="{ color: krColor(kr.status) }">{{ krPercent(kr).toFixed(0) }}%</span>
- </div>
- <div class="orkm-item" v-if="kr.weight">
- <span class="orkm-label">权重</span>
- <span class="orkm-value">{{ kr.weight }}</span>
- </div>
- </div>
- <!-- Progress bar -->
- <el-progress :percentage="krPercent(kr)" :color="krColor(kr.status)" :stroke-width="8" />
- <!-- Progress logs -->
- <div v-if="kr.progressLogs?.length" class="orr-kr-logs">
- <div class="orkl-head">更新记录</div>
- <div v-for="log in kr.progressLogs.slice(0, 5)" :key="log.id" class="orkl-line">
- <span class="orkl-time">{{ fmtTime(log.createdAt) }}</span>
- <span class="orkl-change">{{ log.oldValue || 0 }} → {{ log.newValue }}</span>
- <span class="orkl-status">{{ krStatusLabel(log.newStatus) }}</span>
- <span class="orkl-remark" v-if="log.remark">{{ log.remark }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- Employee Self Assessment (mirrors right-side scoring structure) -->
- <div class="panel-divider"></div>
- <div class="panel-head">员工自评</div>
- <div v-if="!data.selfScore" class="panel-empty">员工尚未提交自评</div>
- <template v-else>
- <!-- 1. OKR self scores -->
- <div class="self-block">
- <div class="self-block-title">OKR 目标达成自评 <span class="sb-sub">(满分 {{ computedOkrMaxScore }})</span></div>
- <div v-if="selfKrScores.length" class="self-kr-list">
- <div v-for="(sk, i) in selfKrScores" :key="i" class="self-kr-item">
- <span class="ski-num">KR{{ i+1 }}</span>
- <span class="ski-title">{{ sk.title }}</span>
- <span class="ski-score">{{ sk.score }}</span>
- </div>
- </div>
- <div class="self-subtotal">OKR 自评 = <strong>{{ selfOkrTotal.toFixed(1) }} 分</strong></div>
- </div>
- <!-- 2. Work result self scores -->
- <div class="self-block">
- <div class="self-block-title">工作成果自评 <span class="sb-sub">(满分 40)</span></div>
- <div v-if="selfDimensions.length" class="self-dim-list">
- <div v-for="d in selfDimensions" :key="d.name" class="self-dim-item">
- <div class="sdi-head">
- <span class="sdi-name">{{ d.name }}</span>
- <span class="sdi-score">{{ d.score }} / {{ d.maxScore }}</span>
- </div>
- <div class="sdi-desc" v-if="d.description">{{ d.description }}</div>
- </div>
- </div>
- <div class="self-subtotal">工作成果自评 = <strong>{{ selfDimTotal.toFixed(1) }} 分</strong></div>
- </div>
- <!-- 3. Bonus/Penalty -->
- <div class="self-block">
- <div class="self-block-title">⚡ 加减分</div>
- <div class="self-bp">
- <span>加分 <strong>+{{ data.selfScore.bonus }}</strong></span>
- <span>扣分 <strong>-{{ data.selfScore.penalty }}</strong></span>
- <span v-if="selfRemark">说明:{{ selfRemark }}</span>
- </div>
- </div>
- <!-- Total -->
- <div class="self-total-bar">
- 员工自评总分 <strong>{{ data.selfScore.total }} / 100</strong>
- <span class="stb-grade">(自评 {{ selfGrade }})</span>
- </div>
- </template>
- </div>
- <!-- Right: Superior Scoring -->
- <div class="score-panel">
- <div class="panel-head">考评打分</div>
- <!-- KR Scores -->
- <div class="score-card">
- <div class="sc-header">OKR 目标达成 <span class="sc-sub">逐条 KR 打分,满分 60</span></div>
- <div class="kr-score-list">
- <div v-for="(ks, i) in krScores" :key="i" class="kr-score-row">
- <div class="ksr-label">
- <span class="ksr-num">KR{{ i+1 }}</span>
- <span class="ksr-title">{{ ks.title || '(未配置)' }}</span>
- <span v-if="selfKrScore(i) !== null" class="ksr-self-ref">自评 {{ selfKrScore(i) }}</span>
- </div>
- <div class="ksr-inputs">
- <el-select v-model="ks.score" size="default" style="width:120px">
- <el-option label="1.0 · 卓越" :value="1.0" />
- <el-option label="0.8 · 良好" :value="0.8" />
- <el-option label="0.6 · 达标" :value="0.6" />
- <el-option label="0.3 · 少量推进" :value="0.3" />
- <el-option label="0.0 · 未做" :value="0.0" />
- </el-select>
- <span class="ksr-eq">= {{ (ks.score * 60 / (okrRecords[0]?.keyResults?.length || 3)).toFixed(1) }} 分</span>
- </div>
- </div>
- </div>
- <div class="okr-subtotal">
- OKR 得分 = 平均 {{ krAvg.toFixed(2) }} × 60 = <strong>{{ computedOkrScore.toFixed(1) }} / 60</strong>
- </div>
- </div>
- <!-- Work Result (Dimensions) -->
- <div class="score-card">
- <div class="sc-header">工作成果评价 <span class="sc-sub">满分 40</span></div>
- <div v-for="(dim, i) in dimScores" :key="i" class="dim-row">
- <div class="dim-head">
- <span class="dim-num">{{ i+1 }}</span>
- <span class="dim-name">{{ dim.name }}</span>
- <span class="dim-max">满分 {{ dim.maxScore }} 分</span>
- <span v-if="selfDimScore(dim.name) !== null" class="dim-self-ref">自评 {{ selfDimScore(dim.name) }}</span>
- </div>
- <div v-if="selfDimDesc(dim.name)" class="dim-self-desc">{{ selfDimDesc(dim.name) }}</div>
- <el-input-number v-model="dim.score" :min="0" :max="dim.maxScore" :precision="1" size="small" controls-position="right" style="width:130px" />
- <span class="dim-score-unit">/ {{ dim.maxScore }} 分</span>
- </div>
- <div class="okr-subtotal">
- 工作成果得分 = <strong>{{ dimTotal.toFixed(1) }} / 40</strong>
- </div>
- </div>
- <!-- Bonus/Penalty -->
- <div class="score-card">
- <div class="sc-header">⚡ 专项加减分</div>
- <div class="sc-body">
- <div class="sc-inline">
- <span class="sc-label">加分(上限5)</span>
- <el-input-number v-model="form.bonus" :min="0" :max="5" :precision="1" size="large" controls-position="right" style="width:120px" />
- </div>
- <div class="sc-inline">
- <span class="sc-label">扣分(无下限)</span>
- <el-input-number v-model="form.penalty" :min="0" :precision="1" size="large" controls-position="right" style="width:120px" />
- </div>
- <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="加减分说明(必填)" style="margin-top:8px" />
- </div>
- </div>
- <!-- Result -->
- <div class="result-preview">
- <div class="rp-formula">OKR×0.6 + 工作成果×0.4 + 加分 − 扣分</div>
- <div class="rp-score">{{ computedTotal }} <small>/ 100</small></div>
- <div class="rp-grade">评级 <strong>{{ computedGrade }}</strong></div>
- </div>
- <el-button type="primary" size="large" class="submit-btn" @click="handleSubmit" :loading="submitting" round>
- 提交绩效考评
- </el-button>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, reactive, computed, onMounted } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import { scoreApi, dimensionApi } from '../../api'
- import { ElMessage } from 'element-plus'
- const route = useRoute()
- const router = useRouter()
- const data = ref({})
- const submitting = ref(false)
- const form = reactive({ bonus: 0, penalty: 0, remark: '' })
- const krScores = ref([])
- const dimensions = ref([])
- const dimScores = ref([])
- const okrRecords = computed(() => data.value.okrRecords || [])
- const krAvg = computed(() => {
- if (!krScores.value.length) return 0
- const sum = krScores.value.reduce((a, b) => a + b.score, 0)
- return sum / krScores.value.length
- })
- const computedOkrScore = computed(() => {
- const okrs = okrRecords.value
- if (okrs.length && okrs[0].keyResults?.length) {
- const n = okrs[0].keyResults.length
- const sum = krScores.value.reduce((a, b) => a + b.score, 0)
- return Math.round(sum / n * 60 * 10) / 10
- }
- return 0
- })
- const dimTotal = computed(() => Math.round(dimScores.value.reduce((s, d) => s + (d.score || 0), 0) * 10) / 10)
- const computedTotal = computed(() => {
- return Math.round((computedOkrScore.value + dimTotal.value + form.bonus - form.penalty) * 10) / 10
- })
- const computedGrade = computed(() => {
- const t = computedTotal.value
- if (t >= 90) return 'S'
- if (t >= 75) return 'A'
- if (t >= 60) return 'B'
- return 'C'
- })
- // Parse self-assessment from commentsJson
- const selfData = computed(() => {
- if (!data.value.selfScore?.commentsJson) return null
- try { return JSON.parse(data.value.selfScore.commentsJson) } catch { return null }
- })
- const selfKrScores = computed(() => selfData.value?.krScores || [])
- const selfDimensions = computed(() => selfData.value?.dimensions || [])
- const selfRemark = computed(() => selfData.value?.remark || '')
- const selfOkrTotal = computed(() => {
- if (!selfKrScores.value.length) return 0
- const n = okrRecords.value[0]?.keyResults?.length || selfKrScores.value.length
- const sum = selfKrScores.value.reduce((a, b) => a + (b.score || 0), 0)
- return Math.round(sum / n * 60 * 10) / 10
- })
- const selfDimTotal = computed(() => {
- return Math.round(selfDimensions.value.reduce((s, d) => s + (d.score || 0), 0) * 10) / 10
- })
- const selfGrade = computed(() => {
- const t = (data.value.selfScore?.total) || 0
- if (t >= 90) return 'S'
- if (t >= 75) return 'A'
- if (t >= 60) return 'B'
- return 'C'
- })
- const computedOkrMaxScore = computed(() => {
- const okrs = okrRecords.value
- if (okrs.length && okrs[0]?.keyResults?.length) return 60
- return 60
- })
- function selfKrScore(idx) {
- return selfKrScores.value[idx]?.score ?? null
- }
- function selfDimScore(name) {
- const found = selfDimensions.value.find(d => d.name === name)
- return found ? found.score : null
- }
- function selfDimDesc(name) {
- const found = selfDimensions.value.find(d => d.name === name)
- return found?.description || ''
- }
- onMounted(async () => {
- data.value = await scoreApi.getPage(route.query.periodId, route.params.userId)
- const okrs = okrRecords.value
- if (okrs.length && okrs[0].keyResults?.length) {
- krScores.value = okrs[0].keyResults.map(kr => ({ title: kr.title, score: 0.6 }))
- }
- // Load dimensions for the scoring form
- dimensions.value = await dimensionApi.list()
- dimScores.value = dimensions.value.map(d => ({
- id: d.id, name: d.name, maxScore: d.maxScore,
- score: selfDimScore(d.name) ?? Math.round(d.maxScore * 0.7 * 10) / 10
- }))
- })
- async function handleSubmit() {
- if (!form.remark) { ElMessage.warning('请填写加减分备注'); return }
- submitting.value = true
- try {
- const dimItems = dimScores.value.map(d => ({
- dimensionId: d.id, name: d.name, maxScore: d.maxScore,
- score: d.score
- }))
- await scoreApi.submitSuperior({
- periodId: route.query.periodId,
- userId: Number(route.params.userId),
- okrScore: computedOkrScore.value,
- kpiScore: dimTotal.value,
- bonus: form.bonus,
- penalty: form.penalty,
- commentsJson: JSON.stringify({
- krScores: krScores.value,
- dimensions: dimItems,
- remark: form.remark
- })
- })
- ElMessage.success('考评已提交')
- router.push('/scoring')
- } finally { submitting.value = false }
- }
- function krPercent(kr) { if (!kr.targetValue || kr.targetValue === 0) return 0; return Math.min((kr.currentValue / kr.targetValue) * 100, 100) }
- function krColor(s) { return { COMPLETED: '#27ae60', BLOCKED: '#e67e22', IN_PROGRESS: '#3ad4d8', NOT_STARTED: '#c0c4cc' }[s] || '#c0c4cc' }
- function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进行中', COMPLETED: '已完成', BLOCKED: '阻塞' }[s] || s }
- function krStatusTag(s) { return { COMPLETED: 'success', BLOCKED: 'danger', IN_PROGRESS: '', NOT_STARTED: 'info' }[s] || 'info' }
- function fmtTime(t) { if (!t) return ''; return t.slice(0, 16).replace('T', ' ') }
- </script>
- <style scoped>
- .page { max-width: 1300px; }
- .page-hero { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
- .hero-info h2 { font-size: 28px; font-weight: 700; color: #1a1a2e; margin: 0; }
- .hero-info p { font-size: 14px; color: #888; margin: 4px 0 0; }
- .same-screen { display: flex; gap: 20px; align-items: flex-start; }
- .left-panel {
- flex: 1; background: #fff; border-radius: 12px; padding: 20px 24px;
- box-shadow: 0 1px 4px rgba(0,0,0,0.04); border: 1px solid #f0f0f0;
- max-height: calc(100vh - 160px); overflow-y: auto;
- }
- .score-panel { width: 480px; flex-shrink: 0; max-height: calc(100vh - 160px); overflow-y: auto; }
- .panel-head { font-size: 15px; font-weight: 700; color: #1a1a2e; margin-bottom: 16px; }
- .panel-empty { color: #ccc; text-align: center; padding: 20px; font-size: 13px; }
- /* OKR Record Cards */
- .okr-record-card { background: #fff; border-radius: 10px; margin-bottom: 14px; border: 1px solid #e8eaed; overflow: hidden; }
- .orr-o-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; background: #fafbfc; }
- .orr-o-badge { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 6px; background: #2b1f99; color: #fff; font-size: 13px; font-weight: 700; flex-shrink: 0; }
- .orr-o-title { font-size: 14px; font-weight: 700; color: #1a1a2e; flex: 1; }
- .orr-o-meta { font-size: 11px; color: #27ae60; padding: 0 16px 6px; background: #fafbfc; }
- .orr-krs { padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
- .orr-kr-card { padding: 12px 14px; background: #f8f9fb; border-radius: 8px; border: 1px solid #eef0f2; }
- .orr-kr-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
- .orr-kr-num { font-size: 12px; font-weight: 700; color: #3ad4d8; width: 30px; flex-shrink: 0; }
- .orr-kr-title { flex: 1; font-size: 13px; color: #333; font-weight: 500; }
- .orr-kr-metrics { display: flex; gap: 20px; margin-bottom: 8px; }
- .orkm-item { display: flex; flex-direction: column; }
- .orkm-label { font-size: 10px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }
- .orkm-value { font-size: 14px; font-weight: 600; color: #333; }
- .orr-kr-logs { margin-top: 10px; padding-top: 8px; border-top: 1px dashed #ddd; }
- .orkl-head { font-size: 11px; font-weight: 600; color: #888; margin-bottom: 4px; }
- .orkl-line { display: flex; align-items: center; gap: 8px; font-size: 11px; padding: 2px 0; flex-wrap: nowrap; }
- .orkl-time { color: #aaa; font-family: monospace; width: 120px; flex-shrink: 0; }
- .orkl-change { color: #555; font-family: monospace; width: 55px; flex-shrink: 0; }
- .orkl-status { color: #888; width: 42px; flex-shrink: 0; text-align: center; }
- .orkl-remark { color: #999; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
- .panel-divider { border-top: 2px solid #e8eaed; margin: 20px 0 16px; }
- /* Self-assessment blocks */
- .self-block { padding: 10px 0; border-top: 1px solid #f0f0f0; }
- .self-block:first-of-type { border-top: none; padding-top: 0; }
- .self-block-title { font-size: 13px; font-weight: 600; color: #1a1a2e; margin-bottom: 8px; }
- .sb-sub { font-size: 11px; color: #aaa; font-weight: 400; }
- .self-kr-list { display: flex; flex-direction: column; gap: 4px; }
- .self-kr-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; background: #f5f3ff; border-radius: 6px; font-size: 12px; }
- .ski-num { font-weight: 700; color: #3ad4d8; width: 28px; flex-shrink: 0; }
- .ski-title { flex: 1; color: #555; }
- .ski-score { font-weight: 600; color: #2b1f99; flex-shrink: 0; }
- .self-subtotal { text-align: right; font-size: 12px; color: #2b1f99; margin-top: 6px; padding-top: 4px; border-top: 1px dashed #e8eaed; }
- .self-dim-list { display: flex; flex-direction: column; gap: 6px; }
- .self-dim-item { padding: 6px 10px; background: #f5f3ff; border-radius: 6px; }
- .sdi-head { display: flex; justify-content: space-between; align-items: center; }
- .sdi-name { font-size: 12px; font-weight: 600; color: #333; }
- .sdi-score { font-size: 13px; font-weight: 600; color: #2b1f99; }
- .sdi-desc { font-size: 11px; color: #888; margin-top: 4px; line-height: 1.5; }
- .self-bp { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: #666; }
- .self-bp strong { color: #1a1a2e; }
- .self-total-bar {
- margin-top: 12px; padding: 12px 16px;
- background: #f4f2f9; border-radius: 10px;
- text-align: center; font-size: 14px; color: #555;
- border: 1px solid #dcd4f0;
- }
- .self-total-bar strong { font-size: 20px; color: #2b1f99; }
- .stb-grade { font-size: 13px; color: #888; }
- .score-card { background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.04); border: 1px solid #f0f0f0; }
- .sc-header { font-weight: 600; color: #1a1a2e; font-size: 14px; margin-bottom: 10px; }
- .sc-sub { font-size: 12px; color: #aaa; font-weight: 400; }
- .sc-body { }
- .sc-label { font-size: 13px; color: #888; margin-right: 12px; }
- .sc-inline { display: flex; align-items: center; margin-bottom: 8px; }
- .sc-unit { font-size: 14px; color: #999; margin-left: 8px; }
- .kr-score-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-top: 1px solid #f5f5f5; }
- .kr-score-row:first-child { border-top: none; }
- .ksr-label { flex: 1; min-width: 0; }
- .ksr-num { font-size: 12px; font-weight: 700; color: #3ad4d8; margin-right: 6px; }
- .ksr-title { font-size: 12px; color: #555; }
- .ksr-self-ref { font-size: 10px; color: #3498db; display: block; }
- .ksr-inputs { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
- .ksr-eq { font-size: 12px; color: #999; white-space: nowrap; }
- .okr-subtotal { text-align: right; font-size: 13px; color: #2b1f99; padding-top: 8px; border-top: 1px dashed #e0e0e0; }
- .dim-row { padding: 10px 0; border-top: 1px solid #f5f5f5; }
- .dim-row:first-child { border-top: none; }
- .dim-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
- .dim-num { width: 20px; height: 20px; border-radius: 50%; background: #2b1f99; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
- .dim-name { font-size: 13px; font-weight: 600; color: #333; }
- .dim-max { font-size: 11px; color: #999; }
- .dim-self-ref { font-size: 11px; color: #3498db; margin-left: auto; }
- .dim-self-desc { font-size: 11px; color: #999; margin-bottom: 4px; padding: 4px 6px; background: #f5f3ff; border-radius: 4px; }
- .dim-score-unit { font-size: 13px; color: #999; margin-left: 6px; }
- .result-preview {
- background: #f4f2f9; border-radius: 12px;
- padding: 20px; text-align: center; border: 1px solid #dcd4f0; margin-bottom: 16px;
- }
- .rp-formula { font-size: 12px; color: #888; }
- .rp-score { font-size: 40px; font-weight: 700; color: #2b1f99; margin: 6px 0; line-height: 1; }
- .rp-score small { font-size: 18px; color: #999; font-weight: 400; }
- .rp-grade { font-size: 14px; color: #666; }
- .submit-btn { width: 100%; background: #2b1f99; border: none; height: 48px; font-size: 15px; }
- @media (max-width: 1100px) {
- .same-screen { flex-direction: column; }
- .left-panel, .score-panel { width: 100%; max-height: none; }
- }
- </style>
|