Просмотр исходного кода

feat: 添加Leader环评审批与终评参考

wangkangyjy 4 дней назад
Родитель
Сommit
7c71fd8245

+ 1 - 0
backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java

@@ -132,6 +132,7 @@ public final class PeerReviewDto {
         private Double averageGoalContributionScore;
         private Double averageCollaborationScore;
         private Double averageAccountabilityScore;
+        private List<InvitationSummary> invitations = new ArrayList<>();
         private List<LeaderResponse> responses = new ArrayList<>();
     }
 

+ 8 - 1
backend/src/main/java/com/emoon/okr/service/PeerReviewService.java

@@ -313,7 +313,6 @@ public class PeerReviewService {
         PeerReviewRequest request = requireLeaderRequest(periodId, subjectUserId, leaderId);
         List<PeerReviewInvitation> invitations = requestInvitations(request.getId());
         Map<Long, SysUser> users = usersById(invitations.stream()
-                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus()))
                 .map(PeerReviewInvitation::getReviewerId).toList());
 
         PeerReviewDto.LeaderSummary summary = leaderSummary(request);
@@ -321,6 +320,8 @@ public class PeerReviewService {
         summary.setSubmittedCount((int) invitations.stream()
                 .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
         summary.setCanFinalize(canFinalize(periodId, invitations));
+        summary.setInvitations(invitations.stream()
+                .map(item -> leaderInvitation(item, users.get(item.getReviewerId()))).toList());
         List<PeerReviewInvitation> submitted = invitations.stream()
                 .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).toList();
         summary.setResponses(submitted.stream().map(item -> leaderResponse(item, users.get(item.getReviewerId())))
@@ -643,6 +644,12 @@ public class PeerReviewService {
         return response;
     }
 
+    private PeerReviewDto.InvitationSummary leaderInvitation(PeerReviewInvitation invitation, SysUser reviewer) {
+        PeerReviewDto.InvitationSummary summary = reviewerInvitationSummary(invitation);
+        summary.setReviewerName(reviewer == null ? null : reviewer.getRealName());
+        return summary;
+    }
+
     private Double average(List<PeerReviewInvitation> invitations,
                            Function<PeerReviewInvitation, Integer> score) {
         return invitations.stream().map(score).filter(Objects::nonNull)

+ 18 - 0
backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

@@ -421,6 +421,24 @@ class PeerReviewServiceTest {
         assertEquals(4.0, summary.getAverageGoalContributionScore());
     }
 
+    @Test
+    void leaderSummaryContainsTheFullIdentifiedInvitationListForApproval() {
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "PENDING_APPROVAL"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "PROPOSED"),
+                invitation(31L, 4L, "PROPOSED"),
+                invitation(32L, 5L, "PROPOSED")));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(3L, "评价人甲"), user(4L, "评价人乙"), user(5L, "评价人丙")));
+
+        PeerReviewDto.LeaderSummary summary = service.getLeaderSummary(1L, 2L, 9L);
+
+        assertEquals(List.of("评价人甲", "评价人乙", "评价人丙"),
+                summary.getInvitations().stream().map(PeerReviewDto.InvitationSummary::getReviewerName).toList());
+        assertEquals(List.of("PROPOSED", "PROPOSED", "PROPOSED"),
+                summary.getInvitations().stream().map(PeerReviewDto.InvitationSummary::getStatus).toList());
+    }
+
     @Test
     void adminStatisticsContainCountsButNoReviewerIdentityOrContent() {
         when(authorizationService.isHrOrAbove(99L)).thenReturn(true);

+ 13 - 0
frontend/src/__tests__/peer-review-contract.test.js

@@ -21,4 +21,17 @@ describe('360 peer review contracts', () => {
     expect(api).toContain("submitMyRequest: data => api.post('/peer-reviews/my/requests'")
     expect(api).toContain("supplementMyRequest: data => api.post('/peer-reviews/my/supplements'")
   })
+
+  it('gates Leader final scoring and shows identified review evidence only to the Leader', () => {
+    const list = source('src/views/scoring/ScoringListView.vue')
+    const edit = source('src/views/scoring/ScoringEditView.vue')
+
+    expect(list).toContain('peerReviewApi.getLeaderQueue')
+    expect(list).toContain('360 环评')
+    expect(edit).toContain('peerReviewApi.getLeaderSummary')
+    expect(edit).toContain('peerReviewApi.reviewRequest')
+    expect(edit).toContain('leaderSummary.responses')
+    expect(edit).toContain(':disabled="!leaderSummary.canFinalize"')
+    expect(edit).toContain('至少收到 2 份有效评价后才可终评')
+  })
 })

+ 94 - 2
frontend/src/views/scoring/ScoringEditView.vue

@@ -11,6 +11,47 @@
     </div>
 
     <div v-if="data.targetUser" class="scoring-body">
+      <section class="leader-peer-card">
+        <div class="leader-peer-card__header">
+          <div>
+            <div class="leader-peer-card__title">360 环评参考</div>
+            <div class="leader-peer-card__meta">评价信息仅本周期冻结 Leader 可见,不计入绩效总分。</div>
+          </div>
+          <el-tag effect="plain" :type="leaderSummary.canFinalize ? 'success' : 'warning'">
+            {{ leaderSummary.submittedCount }} 份有效评价
+          </el-tag>
+        </div>
+
+        <template v-if="leaderSummary.status === 'PENDING_APPROVAL'">
+          <div class="leader-invitation-list">
+            <span v-for="item in leaderSummary.invitations" :key="item.invitationId">
+              {{ item.reviewerName }} · {{ relationLabel(item.relationType) }}
+            </span>
+          </div>
+          <div class="leader-peer-actions">
+            <el-button type="primary" :loading="peerReviewSaving" @click="reviewPeerList(true)">批准并发送邀请</el-button>
+            <el-button :loading="peerReviewSaving" @click="reviewPeerList(false)">退回调整</el-button>
+          </div>
+        </template>
+
+        <template v-else-if="leaderSummary.responses?.length">
+          <div class="peer-average-grid">
+            <div><span>目标贡献</span><strong>{{ formatPeerAverage(leaderSummary.averageGoalContributionScore) }}</strong></div>
+            <div><span>协作表现</span><strong>{{ formatPeerAverage(leaderSummary.averageCollaborationScore) }}</strong></div>
+            <div><span>担当意识</span><strong>{{ formatPeerAverage(leaderSummary.averageAccountabilityScore) }}</strong></div>
+          </div>
+          <div v-for="item in leaderSummary.responses" :key="item.reviewerId" class="leader-peer-response">
+            <div class="leader-peer-response__name">{{ item.reviewerName }}</div>
+            <div class="leader-peer-response__scores">
+              目标贡献 {{ item.goalContributionScore }} · 协作表现 {{ item.collaborationScore }} · 担当意识 {{ item.accountabilityScore }}
+            </div>
+            <div><strong>做得好的:</strong>{{ item.strengths }}</div>
+            <div><strong>改进建议:</strong>{{ item.improvementSuggestion }}</div>
+          </div>
+        </template>
+        <div v-else class="leader-peer-empty">尚未收到有效评价。</div>
+      </section>
+
       <div class="compare-grid-3__header">
         <span>证据 · 员工自评</span>
         <span class="compare-grid-3__self">自评分</span>
@@ -132,9 +173,11 @@
           type="primary"
           @click="handleSubmit"
           :loading="submitting"
+          :disabled="!leaderSummary.canFinalize"
         >
           提交绩效考评
         </el-button>
+        <span v-if="!data.superiorScore && !leaderSummary.canFinalize" class="finalize-gate-hint">至少收到 2 份有效评价后才可终评</span>
         <template v-else>
           <span class="status-label">
             <span class="status-dot" :class="data.finalScore?.status === 'PUBLISHED' ? 'status-dot--active' : 'status-dot--warn'"></span>
@@ -157,8 +200,8 @@
 <script setup>
 import { ref, reactive, computed, onMounted } from 'vue'
 import { useRoute } from 'vue-router'
-import { scoreApi, dimensionApi } from '../../api'
-import { ElMessage } from 'element-plus'
+import { scoreApi, dimensionApi, peerReviewApi } from '../../api'
+import { ElMessage, ElMessageBox } from 'element-plus'
 
 const route = useRoute()
 const data = ref({})
@@ -168,6 +211,8 @@ const form = reactive({ bonus: 0, penalty: 0, remark: '' })
 const krScores = ref([])
 const dimensions = ref([])
 const dimScores = ref([])
+const leaderSummary = ref({ status: 'NOT_STARTED', submittedCount: 0, canFinalize: false, invitations: [], responses: [] })
+const peerReviewSaving = ref(false)
 
 const okrRecords = computed(() => data.value.okrRecords || [])
 const allKrs = computed(() => okrRecords.value.flatMap(objective =>
@@ -312,6 +357,10 @@ function selfKrScoreColor(i) {
 
 async function loadPage() {
   data.value = await scoreApi.getPage(route.query.periodId, route.params.userId)
+  const queue = await peerReviewApi.getLeaderQueue(route.query.periodId)
+  if (queue.some(item => item.subjectUserId === Number(route.params.userId))) {
+    leaderSummary.value = await peerReviewApi.getLeaderSummary(route.query.periodId, Number(route.params.userId))
+  }
   const okrs = okrRecords.value
   krScores.value = allKrs.value.map(kr => ({ title: kr.title, score: 0.6 }))
 
@@ -323,6 +372,33 @@ async function loadPage() {
   }))
 }
 
+async function reviewPeerList(approved) {
+  let reason = ''
+  if (!approved) {
+    const result = await ElMessageBox.prompt('请说明需要员工调整名单的原因', '退回评价人名单', {
+      inputType: 'textarea',
+      inputPlaceholder: '例如:请补充一位熟悉项目交付过程的跨部门同事',
+      confirmButtonText: '确认退回',
+      cancelButtonText: '取消'
+    })
+    reason = result.value
+  }
+  peerReviewSaving.value = true
+  try {
+    await peerReviewApi.reviewRequest(leaderSummary.value.requestId, { approved, reason })
+    ElMessage.success(approved ? '名单已批准,邀请已发送' : '名单已退回员工调整')
+    await loadPage()
+  } finally { peerReviewSaving.value = false }
+}
+
+function relationLabel(type) {
+  return type === 'SAME_DEPARTMENT' ? '同部门' : '跨部门'
+}
+
+function formatPeerAverage(value) {
+  return Number(value || 0).toFixed(1)
+}
+
 onMounted(async () => {
   await loadPage()
 })
@@ -372,6 +448,20 @@ function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进
 .page { width: 100%; }
 .review-workbench { background: var(--color-surface); }
 .scoring-body { padding-bottom: 72px; }
+.leader-peer-card { padding: 18px 20px; border-bottom: 1px solid var(--table-border); background: var(--color-surface); color: var(--color-text); }
+.leader-peer-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
+.leader-peer-card__title { color: var(--color-text); font-size: 16px; font-weight: 700; }
+.leader-peer-card__meta { margin-top: 4px; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
+.leader-invitation-list { display: flex; flex-wrap: wrap; gap: 8px; }
+.leader-invitation-list span { padding: 7px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); color: var(--color-text); background: var(--color-surface-subtle); font-size: var(--font-size-sm); }
+.leader-peer-actions { margin-top: 14px; }
+.peer-average-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px; }
+.peer-average-grid div { display: flex; justify-content: space-between; padding: 10px 12px; background: var(--color-surface-tint-brand); border: 1px solid var(--color-border-soft); border-radius: var(--radius-sm); color: var(--color-text-secondary); }
+.peer-average-grid strong { color: var(--color-brand); }
+.leader-peer-response { padding: 12px 0; border-top: 1px solid var(--color-border-soft); color: var(--color-text-secondary); font-size: var(--font-size-sm); line-height: 1.7; }
+.leader-peer-response__name { color: var(--color-text); font-weight: 700; }
+.leader-peer-response__scores { color: var(--color-brand); font-weight: 600; }
+.leader-peer-empty { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
 .section-band--spaced { margin-top: 8px; border-top: 1px solid var(--table-border); }
 .panel-empty { color: var(--color-text-muted); text-align: center; padding: 24px; font-size: var(--font-size-sm); }
 .evidence-title { color: var(--color-text); font-weight: 500; }
@@ -407,6 +497,7 @@ function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进
   padding: 12px 14px;
   border-top: 1px solid var(--toolbar-border);
 }
+.finalize-gate-hint { color: var(--color-warning); font-size: var(--font-size-sm); font-weight: 600; }
 
 @media (max-width: 1279px) {
   .compare-grid-3,
@@ -418,5 +509,6 @@ function krStatusLabel(s) { return { NOT_STARTED: '未开始', IN_PROGRESS: '进
   .remark-row { grid-template-columns: 1fr; }
   .review-summary-bar { position: static; flex-wrap: wrap; }
   .summary-diff { margin-left: 0; width: 100%; }
+  .peer-average-grid { grid-template-columns: 1fr; }
 }
 </style>

+ 29 - 2
frontend/src/views/scoring/ScoringListView.vue

@@ -21,6 +21,20 @@
           <span v-else class="text-muted">未评</span>
         </template>
       </el-table-column>
+      <el-table-column label="360 环评" min-width="150" align="center">
+        <template #default="{ row }">
+          <template v-if="peerQueueItem(row)">
+            <el-tag v-if="peerQueueItem(row).status === 'PENDING_APPROVAL'" type="warning" effect="plain">待审批名单</el-tag>
+            <span v-else class="peer-review-count">
+              {{ peerQueueItem(row).submittedCount }} 份
+              <span :class="peerQueueItem(row).canFinalize ? 'peer-ready' : 'peer-waiting'">
+                {{ peerQueueItem(row).canFinalize ? '可终评' : '待收集' }}
+              </span>
+            </span>
+          </template>
+          <span v-else class="text-muted">未发起</span>
+        </template>
+      </el-table-column>
       <el-table-column label="终审结果" min-width="180" align="center">
         <template #default="{ row }">
           <template v-if="row.superiorScore">
@@ -55,13 +69,14 @@
 <script setup>
 import { ref, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
-import { periodApi, scoreApi } from '../../api'
+import { periodApi, scoreApi, peerReviewApi } from '../../api'
 import PeriodBanner from '../../components/PeriodBanner.vue'
 
 const router = useRouter()
 const periods = ref([])
 const periodId = ref(null)
 const subordinates = ref([])
+const peerQueue = ref([])
 
 onMounted(async () => {
   periods.value = await periodApi.list()
@@ -71,7 +86,16 @@ onMounted(async () => {
 
 async function load() {
   if (!periodId.value) return
-  subordinates.value = await scoreApi.getSubordinates(periodId.value)
+  const [scoreRows, queue] = await Promise.all([
+    scoreApi.getSubordinates(periodId.value),
+    peerReviewApi.getLeaderQueue(periodId.value)
+  ])
+  subordinates.value = scoreRows
+  peerQueue.value = queue
+}
+
+function peerQueueItem(row) {
+  return peerQueue.value.find(item => item.subjectUserId === row.targetUser.id)
 }
 
 function goScore(row) {
@@ -96,4 +120,7 @@ function gradeType(g) { return { S: '', A: 'success', B: 'primary', C: 'danger'
 .grade-C { background: var(--color-danger); }
 .final-score { font-weight: 600; color: var(--color-text); }
 .breakdown { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: var(--color-text-secondary); }
+.peer-review-count { color: var(--color-text); font-size: 13px; }
+.peer-ready { margin-left: 5px; color: var(--color-success); font-weight: 600; }
+.peer-waiting { margin-left: 5px; color: var(--color-warning); font-weight: 600; }
 </style>