|
|
@@ -175,7 +175,69 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div v-else class="score-pending">
|
|
|
+ <section v-if="data.selfScore && !data.finalScore" class="peer-review-card">
|
|
|
+ <div class="peer-review-card__header">
|
|
|
+ <div>
|
|
|
+ <div class="form-section-title">360 环评邀请</div>
|
|
|
+ <p>邀请熟悉你工作的同事,为 Leader 终评提供参考。评价内容仅你本周期的 Leader 可见。</p>
|
|
|
+ </div>
|
|
|
+ <el-tag v-if="peerProgress.status !== 'NOT_STARTED'" effect="plain">
|
|
|
+ {{ peerStatusLabel }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-if="peerProgress.status === 'NOT_STARTED' || peerProgress.status === 'REJECTED'">
|
|
|
+ <el-alert
|
|
|
+ v-if="peerProgress.rejectionReason"
|
|
|
+ :title="`Leader 退回:${peerProgress.rejectionReason}`"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ />
|
|
|
+ <div class="peer-review-rules">
|
|
|
+ <span>至少选择 3 位、最多 5 位同事</span>
|
|
|
+ <span>同部门和跨部门同事各至少 1 位</span>
|
|
|
+ </div>
|
|
|
+ <el-select
|
|
|
+ v-model="selectedReviewerIds"
|
|
|
+ multiple
|
|
|
+ collapse-tags
|
|
|
+ collapse-tags-tooltip
|
|
|
+ :max-collapse-tags="3"
|
|
|
+ placeholder="选择评价人"
|
|
|
+ class="peer-review-select"
|
|
|
+ >
|
|
|
+ <el-option-group label="同部门同事">
|
|
|
+ <el-option v-for="item in sameDepartmentCandidates" :key="item.userId" :label="item.realName" :value="item.userId" />
|
|
|
+ </el-option-group>
|
|
|
+ <el-option-group label="跨部门同事">
|
|
|
+ <el-option v-for="item in crossDepartmentCandidates" :key="item.userId" :label="item.realName" :value="item.userId" />
|
|
|
+ </el-option-group>
|
|
|
+ </el-select>
|
|
|
+ <el-button type="primary" :loading="peerSubmitting" @click="submitPeerRequest">提交 Leader 审批</el-button>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div class="peer-review-progress">
|
|
|
+ <strong>已收到 {{ peerProgress.submittedCount }} 份有效评价</strong>
|
|
|
+ <span>仅展示总数,不展示评价人提交状态和评价内容。</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="declinedInvitations.length" class="peer-review-declines">
|
|
|
+ <div v-for="item in declinedInvitations" :key="item.invitationId" class="peer-review-decline">
|
|
|
+ <span>{{ item.reviewerName }} 已婉拒</span>
|
|
|
+ <span>{{ item.declineReason }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="peerProgress.invitedCount < 5" class="peer-review-supplement">
|
|
|
+ <el-select v-model="supplementReviewerId" placeholder="补充 1 位评价人" clearable>
|
|
|
+ <el-option v-for="item in supplementCandidates" :key="item.userId" :label="item.realName" :value="item.userId" />
|
|
|
+ </el-select>
|
|
|
+ <el-button :disabled="!supplementReviewerId" :loading="peerSubmitting" @click="supplementPeerRequest">发送补充邀请</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div v-if="!data.selfScore && !data.finalScore" class="score-pending">
|
|
|
<div class="pending-icon"></div>
|
|
|
<div class="pending-text">绩效考核尚未完成</div>
|
|
|
</div>
|
|
|
@@ -287,7 +349,7 @@
|
|
|
|
|
|
<script setup>
|
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
|
-import { periodApi, scoreApi, dimensionApi, scoreAppealApi } from '../../api'
|
|
|
+import { periodApi, scoreApi, dimensionApi, scoreAppealApi, peerReviewApi } from '../../api'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import PeriodBanner from '../../components/PeriodBanner.vue'
|
|
|
|
|
|
@@ -301,12 +363,29 @@ const dimensions = ref([])
|
|
|
const krScores = ref([])
|
|
|
const dimScores = ref([])
|
|
|
const historyList = ref([])
|
|
|
+const peerCandidates = ref([])
|
|
|
+const peerProgress = ref({ status: 'NOT_STARTED', submittedCount: 0, invitedCount: 0, invitations: [] })
|
|
|
+const selectedReviewerIds = ref([])
|
|
|
+const supplementReviewerId = ref(null)
|
|
|
+const peerSubmitting = ref(false)
|
|
|
const form = reactive({ bonus: 0, penalty: 0, remark: '' })
|
|
|
|
|
|
const okrRecords = computed(() => data.value.okrRecords || [])
|
|
|
const allKrs = computed(() => okrRecords.value.flatMap((objective, objectiveIndex) =>
|
|
|
(objective.keyResults || []).map((kr, krIndex) => ({ ...kr, objective, objectiveIndex, krIndex }))
|
|
|
))
|
|
|
+const sameDepartmentCandidates = computed(() => peerCandidates.value.filter(item => item.relationType === 'SAME_DEPARTMENT'))
|
|
|
+const crossDepartmentCandidates = computed(() => peerCandidates.value.filter(item => item.relationType === 'CROSS_DEPARTMENT'))
|
|
|
+const declinedInvitations = computed(() => (peerProgress.value.invitations || []).filter(item => item.status === 'DECLINED'))
|
|
|
+const supplementCandidates = computed(() => {
|
|
|
+ const invitedIds = new Set((peerProgress.value.invitations || []).map(item => item.reviewerId))
|
|
|
+ return peerCandidates.value.filter(item => !invitedIds.has(item.userId))
|
|
|
+})
|
|
|
+const peerStatusLabel = computed(() => ({
|
|
|
+ PENDING_APPROVAL: '等待 Leader 审批',
|
|
|
+ APPROVED: '环评进行中',
|
|
|
+ REJECTED: '名单已退回'
|
|
|
+}[peerProgress.value.status] || '环评进行中'))
|
|
|
|
|
|
// Show self-assessment form when ASSESSING and no self score submitted
|
|
|
const showSelfForm = computed(() => {
|
|
|
@@ -399,6 +478,41 @@ async function load() {
|
|
|
})))
|
|
|
|
|
|
await loadHistory()
|
|
|
+ if (data.value.selfScore && !data.value.finalScore) await loadPeerReview()
|
|
|
+}
|
|
|
+
|
|
|
+async function loadPeerReview() {
|
|
|
+ peerProgress.value = await peerReviewApi.getMyProgress(periodId.value)
|
|
|
+ peerCandidates.value = await peerReviewApi.getMyCandidates(periodId.value)
|
|
|
+ selectedReviewerIds.value = peerProgress.value.status === 'REJECTED'
|
|
|
+ ? (peerProgress.value.invitations || []).map(item => item.reviewerId)
|
|
|
+ : []
|
|
|
+}
|
|
|
+
|
|
|
+async function submitPeerRequest() {
|
|
|
+ const selected = peerCandidates.value.filter(item => selectedReviewerIds.value.includes(item.userId))
|
|
|
+ if (selected.length < 3 || selected.length > 5) {
|
|
|
+ ElMessage.warning('请选择 3 至 5 位评价人')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!selected.some(item => item.relationType === 'SAME_DEPARTMENT') || !selected.some(item => item.relationType === 'CROSS_DEPARTMENT')) {
|
|
|
+ ElMessage.warning('同部门和跨部门同事各至少选择 1 位')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ peerSubmitting.value = true
|
|
|
+ try {
|
|
|
+ peerProgress.value = await peerReviewApi.submitMyRequest({ periodId: periodId.value, reviewerIds: selectedReviewerIds.value })
|
|
|
+ ElMessage.success('评价人名单已提交 Leader 审批')
|
|
|
+ } finally { peerSubmitting.value = false }
|
|
|
+}
|
|
|
+
|
|
|
+async function supplementPeerRequest() {
|
|
|
+ peerSubmitting.value = true
|
|
|
+ try {
|
|
|
+ peerProgress.value = await peerReviewApi.supplementMyRequest({ periodId: periodId.value, reviewerId: supplementReviewerId.value })
|
|
|
+ supplementReviewerId.value = null
|
|
|
+ ElMessage.success('补充邀请已发送')
|
|
|
+ } finally { peerSubmitting.value = false }
|
|
|
}
|
|
|
|
|
|
async function loadHistory() {
|
|
|
@@ -598,6 +712,22 @@ function barHeight(score) {
|
|
|
.result-pending-card .pending-icon { margin: 0; flex-shrink: 0; }
|
|
|
.pending-text { font-size: 15px; color: var(--color-text-muted); }
|
|
|
|
|
|
+.peer-review-card { padding: 20px 24px; border-bottom: 1px solid var(--toolbar-border); background: var(--color-surface); color: var(--color-text); }
|
|
|
+.peer-review-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
|
+.peer-review-card__header .form-section-title { margin-bottom: 4px; }
|
|
|
+.peer-review-card__header p { margin: 0; color: var(--color-text-secondary); font-size: var(--font-size-sm); line-height: 1.6; }
|
|
|
+.peer-review-rules { display: flex; gap: 18px; margin: 12px 0; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
|
|
|
+.peer-review-rules span::before { content: '✓'; margin-right: 5px; color: var(--color-brand); font-weight: 700; }
|
|
|
+.peer-review-select { width: min(560px, 100%); margin-right: 10px; }
|
|
|
+.peer-review-progress { display: flex; align-items: baseline; gap: 12px; padding: 14px 16px; background: var(--color-surface-tint-brand); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); }
|
|
|
+.peer-review-progress strong { color: var(--color-brand); font-size: 16px; }
|
|
|
+.peer-review-progress span { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
|
|
|
+.peer-review-declines { margin-top: 14px; }
|
|
|
+.peer-review-decline { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--color-border-soft); color: var(--color-text); font-size: var(--font-size-sm); }
|
|
|
+.peer-review-decline span:last-child { color: var(--color-text-secondary); }
|
|
|
+.peer-review-supplement { display: flex; gap: 10px; margin-top: 14px; }
|
|
|
+.peer-review-supplement .el-select { width: 260px; }
|
|
|
+
|
|
|
.result-compare {
|
|
|
padding: 12px 18px;
|
|
|
border-bottom: 1px solid var(--table-border);
|
|
|
@@ -649,5 +779,7 @@ function barHeight(score) {
|
|
|
.result-compare-grid { grid-template-columns: 1fr; }
|
|
|
.result-actions { align-items: flex-start; flex-direction: column; }
|
|
|
.two-col { flex-direction: column; }
|
|
|
+ .peer-review-rules, .peer-review-progress, .peer-review-supplement { align-items: stretch; flex-direction: column; }
|
|
|
+ .peer-review-select { margin: 0 0 10px; }
|
|
|
}
|
|
|
</style>
|