Переглянути джерело

feat: 添加360环评评价人工作台

wangkangyjy 1 день тому
батько
коміт
7d4824d1cb

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

@@ -66,6 +66,7 @@ public final class PeerReviewDto {
     public static class ReviewerWorkspace {
         private Long invitationId;
         private Long subjectUserId;
+        private String subjectName;
         private String status;
         private List<ObjectiveSummary> objectives = new ArrayList<>();
         private List<WorkResultSummary> workResults = new ArrayList<>();

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

@@ -247,13 +247,27 @@ public class PeerReviewService {
     }
 
     public PeerReviewDto.ReviewerWorkspace getReviewerWorkspace(Long invitationId, Long reviewerId) {
-        PeerReviewInvitation invitation = requireReviewerInvitation(invitationId, reviewerId, true);
+        PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
+        if (invitation == null || !Objects.equals(invitation.getReviewerId(), reviewerId)) {
+            throw new BusinessException(403, "无权查看或填写该环评");
+        }
+        boolean visible = INVITATION_PENDING.equals(invitation.getStatus())
+                || INVITATION_ACCEPTED.equals(invitation.getStatus())
+                || INVITATION_SUBMITTED.equals(invitation.getStatus());
+        if (!visible) {
+            throw new BusinessException("当前环评邀请不可查看");
+        }
         PeerReviewRequest request = requireRequest(invitation.getRequestId());
 
         PeerReviewDto.ReviewerWorkspace workspace = new PeerReviewDto.ReviewerWorkspace();
         workspace.setInvitationId(invitation.getId());
         workspace.setSubjectUserId(request.getSubjectUserId());
+        SysUser subject = userMapper.selectById(request.getSubjectUserId());
+        workspace.setSubjectName(subject == null ? null : subject.getRealName());
         workspace.setStatus(invitation.getStatus());
+        if (INVITATION_PENDING.equals(invitation.getStatus())) {
+            return workspace;
+        }
         workspace.setObjectives(okrService.getMyOkrSet(request.getPeriodId(), request.getSubjectUserId())
                 .stream().map(this::objectiveSummary).toList());
 

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

@@ -41,6 +41,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -312,6 +313,21 @@ class PeerReviewServiceTest {
         assertTrue(!json.has("selfScore") && !json.has("bonus") && !json.has("penalty"));
     }
 
+    @Test
+    void pendingReviewerCanReadOnlyTheInvitationIdentityBeforeAccepting() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+        when(userMapper.selectById(2L)).thenReturn(user(2L, "待评价员工"));
+
+        PeerReviewDto.ReviewerWorkspace workspace = service.getReviewerWorkspace(30L, 3L);
+
+        assertEquals("PENDING", workspace.getStatus());
+        assertEquals("待评价员工", workspace.getSubjectName());
+        assertTrue(workspace.getObjectives().isEmpty());
+        assertTrue(workspace.getWorkResults().isEmpty());
+        verifyNoInteractions(okrService, scoreItemMapper);
+    }
+
     @Test
     void draftAllowsIncompleteResponseWithoutChangingInvitationStatus() {
         when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "ACCEPTED"));

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

@@ -34,4 +34,22 @@ describe('360 peer review contracts', () => {
     expect(edit).toContain(':disabled="!leaderSummary.canFinalize"')
     expect(edit).toContain('至少收到 2 份有效评价后才可终评')
   })
+
+  it('routes invitations to a limited reviewer workspace with draft and submit actions', () => {
+    const page = source('src/views/scoring/PeerReviewInvitationView.vue')
+    const router = source('src/router/index.js')
+    const notifications = source('src/views/notification/NotificationView.vue')
+
+    expect(router).toContain("path: 'scores/peer-review/:invitationId'")
+    expect(notifications).toContain("n.refType === 'peer_review_invitation'")
+    expect(page).toContain('peerReviewApi.acceptInvitation')
+    expect(page).toContain('peerReviewApi.declineInvitation')
+    expect(page).toContain('peerReviewApi.saveDraft')
+    expect(page).toContain('peerReviewApi.submitResponse')
+    expect(page).toContain('OKR / KR 进展')
+    expect(page).toContain('工作成果说明')
+    expect(page).not.toContain('selfScore')
+    expect(page).not.toContain('bonus')
+    expect(page).not.toContain('penalty')
+  })
 })

+ 2 - 0
frontend/src/components/MainLayout.vue

@@ -189,6 +189,8 @@ function handleNotifyClick(n) {
       router.push('/periods')
     } else if (n.refType === 'score_appeal') {
       router.push('/score-appeals')
+    } else if (n.refType === 'peer_review_invitation' && n.refId) {
+      router.push(`/scores/peer-review/${n.refId}`)
     }
   }, 100)
 }

+ 1 - 0
frontend/src/router/index.js

@@ -25,6 +25,7 @@ const routes = [
       { path: 'scores/history', name: 'ScoreHistory', component: () => import('../views/scoring/HistoryView.vue'), meta: { title: '历史绩效' } },
       { path: 'scores/review', name: 'ScoringList', component: () => import('../views/scoring/ScoringListView.vue'), meta: { title: '绩效打分' } },
       { path: 'scores/review/:userId', name: 'ScoringDetail', component: () => import('../views/scoring/ScoringEditView.vue'), meta: { title: '绩效考评' } },
+      { path: 'scores/peer-review/:invitationId', name: 'PeerReviewInvitation', component: () => import('../views/scoring/PeerReviewInvitationView.vue'), meta: { title: '360 环评' } },
       { path: 'scores/appeal', name: 'ScoreAppeals', component: () => import('../views/scoring/ScoreAppealView.vue'), meta: { title: '绩效申诉' } },
       { path: 'feedback', name: 'Feedback', component: () => import('../views/feedback/FeedbackView.vue'), meta: { title: '绩效沟通' } },
       { path: 'org', name: 'Org', component: () => import('../views/org/OrgView.vue'), meta: { title: '组织架构' } },

+ 12 - 1
frontend/src/views/notification/NotificationView.vue

@@ -27,6 +27,7 @@
         </div>
         <div v-for="n in notifications" :key="n.id" class="data-table__row data-table__row--clickable"
              :class="{ 'is-unread': n.isRead === 0 }"
+             @click="openNotification(n)"
              style="grid-template-columns: 64px minmax(0,1fr) 2fr 100px 90px">
           <StatusLabel :tone="n.isRead === 0 ? 'active' : 'info'" :label="n.isRead === 0 ? '未读' : '已读'" />
           <span :style="{ fontWeight: n.isRead === 0 ? 600 : 400 }">{{ n.title }}</span>
@@ -47,10 +48,12 @@
 
 <script setup>
 import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { notificationApi } from '../../api'
 import StatusLabel from '@/components/common/StatusLabel.vue'
 
 const notifications = ref([])
+const router = useRouter()
 const loading = ref(false)
 const page = ref(1)
 const total = ref(0)
@@ -67,10 +70,18 @@ function typeLabel(type) {
     PERIOD_OKR_ALIGN: '周期发布', PERIOD_EXECUTING: '开始执行',
     PERIOD_ASSESSING: '考评开始', PERIOD_ARCHIVED: '周期归档',
     KR_LAGGING: '进度滞后',
-    FEEDBACK_NOTIFY: '面谈通知'
+    FEEDBACK_NOTIFY: '面谈通知',
+    PEER_REVIEW_INVITED: '360 环评邀请',
+    PEER_REVIEW_REMINDER: '360 环评提醒'
   }
   return map[type] || type
 }
+async function openNotification(n) {
+  if (n.isRead === 0) await notificationApi.markRead(n.id)
+  if (n.refType === 'peer_review_invitation' && n.refId) {
+    router.push(`/scores/peer-review/${n.refId}`)
+  }
+}
 async function loadData() {
   loading.value = true
   try {

+ 216 - 0
frontend/src/views/scoring/PeerReviewInvitationView.vue

@@ -0,0 +1,216 @@
+<template>
+  <div class="peer-review-workbench">
+    <div class="data-toolbar">
+      <span class="data-toolbar__title">360 环评</span>
+      <span class="data-toolbar__sep">|</span>
+      <span class="data-toolbar__meta">评价 {{ workspace.subjectName || '同事' }}</span>
+      <span class="data-toolbar__spacer"></span>
+      <el-tag v-if="workspace.status" effect="plain" :type="workspace.status === 'SUBMITTED' ? 'success' : 'info'">
+        {{ statusLabel }}
+      </el-tag>
+    </div>
+
+    <section v-if="workspace.status === 'PENDING'" class="invitation-card">
+      <div class="invitation-card__eyebrow">来自同事的邀请</div>
+      <h1>是否接受这份 360 环评邀请?</h1>
+      <p>接受后可查看对方本周期的 OKR / KR 进展与工作成果说明。你的评分和简评仅其本周期 Leader 可见。</p>
+      <div class="invitation-actions">
+        <el-button type="primary" size="large" :loading="saving" @click="acceptInvitation">接受并开始评价</el-button>
+        <el-button size="large" :loading="saving" @click="declineInvitation">婉拒邀请</el-button>
+      </div>
+    </section>
+
+    <div v-else-if="workspace.status" class="review-layout">
+      <div class="review-evidence">
+        <section class="evidence-card">
+          <div class="section-title">OKR / KR 进展</div>
+          <div v-if="workspace.objectives?.length">
+            <div v-for="objective in workspace.objectives" :key="objective.title" class="objective-block">
+              <div class="objective-block__header">
+                <strong>{{ objective.title }}</strong>
+                <span>{{ formatProgress(objective.progress) }}%</span>
+              </div>
+              <div v-for="kr in objective.keyResults" :key="kr.title" class="kr-row">
+                <span>{{ kr.title }}</span>
+                <span>{{ kr.currentValue }} / {{ kr.targetValue }}</span>
+              </div>
+            </div>
+          </div>
+          <div v-else class="section-empty">暂无 OKR 记录</div>
+        </section>
+
+        <section class="evidence-card">
+          <div class="section-title">工作成果说明</div>
+          <div v-if="workspace.workResults?.length">
+            <div v-for="item in workspace.workResults" :key="item.name" class="work-result">
+              <strong>{{ item.name }}</strong>
+              <p>{{ item.description }}</p>
+            </div>
+          </div>
+          <div v-else class="section-empty">暂无工作成果说明</div>
+        </section>
+      </div>
+
+      <section class="review-form-card">
+        <div class="section-title">评分与简评</div>
+        <p class="privacy-note">请基于实际协作经历作答。结果不会向被评价人直接展示。</p>
+
+        <label v-for="dimension in scoreDimensions" :key="dimension.key" class="rating-row">
+          <span>{{ dimension.label }}</span>
+          <el-rate v-model="form[dimension.key]" :disabled="submitted" show-score score-template="{value} 分" />
+        </label>
+
+        <label class="comment-field">
+          <span>做得好的地方 <small>10–300 字</small></span>
+          <el-input v-model="form.strengths" type="textarea" :rows="4" maxlength="300" show-word-limit :disabled="submitted" placeholder="请结合具体工作表现说明" />
+        </label>
+        <label class="comment-field">
+          <span>改进建议 <small>10–300 字</small></span>
+          <el-input v-model="form.improvementSuggestion" type="textarea" :rows="4" maxlength="300" show-word-limit :disabled="submitted" placeholder="请给出具体、可行动的建议" />
+        </label>
+
+        <div v-if="!submitted" class="form-actions">
+          <el-button :loading="saving" @click="saveDraft">保存草稿</el-button>
+          <el-button type="primary" :loading="saving" @click="submitResponse">提交评价</el-button>
+        </div>
+        <el-alert v-else title="评价已提交,无法再次修改。" type="success" :closable="false" show-icon />
+      </section>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { peerReviewApi } from '../../api'
+
+const route = useRoute()
+const router = useRouter()
+const invitationId = Number(route.params.invitationId)
+const workspace = ref({})
+const saving = ref(false)
+const form = reactive({
+  goalContributionScore: 0,
+  collaborationScore: 0,
+  accountabilityScore: 0,
+  strengths: '',
+  improvementSuggestion: ''
+})
+const scoreDimensions = [
+  { key: 'goalContributionScore', label: '目标贡献' },
+  { key: 'collaborationScore', label: '协作表现' },
+  { key: 'accountabilityScore', label: '担当意识' }
+]
+const submitted = computed(() => workspace.value.status === 'SUBMITTED')
+const statusLabel = computed(() => ({ PENDING: '待接受', ACCEPTED: '填写中', SUBMITTED: '已提交' }[workspace.value.status] || workspace.value.status))
+
+onMounted(loadWorkspace)
+
+async function loadWorkspace() {
+  workspace.value = await peerReviewApi.getWorkspace(invitationId)
+  const response = workspace.value.response || {}
+  for (const key of Object.keys(form)) form[key] = response[key] || (key.endsWith('Score') ? 0 : '')
+}
+
+async function acceptInvitation() {
+  saving.value = true
+  try {
+    await peerReviewApi.acceptInvitation(invitationId)
+    await loadWorkspace()
+    ElMessage.success('已接受邀请,请在截止时间前完成评价')
+  } finally { saving.value = false }
+}
+
+async function declineInvitation() {
+  const { value } = await ElMessageBox.prompt('请简要说明婉拒原因', '婉拒 360 环评邀请', {
+    inputType: 'textarea',
+    inputPlaceholder: '例如:与该同事本周期工作接触较少',
+    confirmButtonText: '确认婉拒',
+    cancelButtonText: '取消'
+  })
+  saving.value = true
+  try {
+    await peerReviewApi.declineInvitation(invitationId, { reason: value })
+    ElMessage.success('已婉拒邀请')
+    router.push('/notifications')
+  } finally { saving.value = false }
+}
+
+function responsePayload() {
+  return {
+    goalContributionScore: form.goalContributionScore || null,
+    collaborationScore: form.collaborationScore || null,
+    accountabilityScore: form.accountabilityScore || null,
+    strengths: form.strengths,
+    improvementSuggestion: form.improvementSuggestion
+  }
+}
+
+async function saveDraft() {
+  saving.value = true
+  try {
+    await peerReviewApi.saveDraft(invitationId, responsePayload())
+    ElMessage.success('草稿已保存')
+  } finally { saving.value = false }
+}
+
+async function submitResponse() {
+  if (scoreDimensions.some(item => !form[item.key])) {
+    ElMessage.warning('请完成三项评分')
+    return
+  }
+  if (form.strengths.trim().length < 10 || form.improvementSuggestion.trim().length < 10) {
+    ElMessage.warning('两项简评均需至少填写 10 个字')
+    return
+  }
+  saving.value = true
+  try {
+    await peerReviewApi.submitResponse(invitationId, responsePayload())
+    await loadWorkspace()
+    ElMessage.success('评价已提交')
+  } finally { saving.value = false }
+}
+
+function formatProgress(value) {
+  return Math.round(Number(value || 0))
+}
+</script>
+
+<style scoped>
+.peer-review-workbench { width: 100%; color: var(--color-text); }
+.invitation-card { max-width: 720px; margin: 48px auto; padding: 36px; background: var(--color-surface); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); box-shadow: var(--shadow-panel); }
+.invitation-card__eyebrow { color: var(--color-brand); font-size: var(--font-size-sm); font-weight: 700; }
+.invitation-card h1 { margin: 8px 0 12px; color: var(--color-text); font-size: 28px; }
+.invitation-card p, .privacy-note { color: var(--color-text-secondary); line-height: 1.7; }
+.invitation-actions { display: flex; gap: 10px; margin-top: 24px; }
+.review-layout { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(380px, .9fr); gap: 18px; align-items: start; }
+.review-evidence { display: grid; gap: 18px; }
+.evidence-card, .review-form-card { padding: 20px; background: var(--color-surface); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); box-shadow: var(--shadow-panel); }
+.review-form-card { position: sticky; top: 16px; }
+.section-title { margin-bottom: 14px; color: var(--color-text); font-size: 16px; font-weight: 700; }
+.objective-block { padding: 12px 0; border-top: 1px solid var(--color-border-soft); }
+.objective-block:first-of-type { border-top: 0; }
+.objective-block__header, .kr-row { display: flex; justify-content: space-between; gap: 16px; color: var(--color-text); }
+.objective-block__header span { color: var(--color-brand); font-weight: 700; }
+.kr-row { margin-top: 8px; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
+.work-result { padding: 12px 0; border-top: 1px solid var(--color-border-soft); }
+.work-result:first-of-type { border-top: 0; }
+.work-result strong { color: var(--color-text); }
+.work-result p { margin: 5px 0 0; color: var(--color-text-secondary); line-height: 1.7; }
+.section-empty { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
+.privacy-note { margin: -6px 0 16px; font-size: var(--font-size-sm); }
+.rating-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-top: 1px solid var(--color-border-soft); color: var(--color-text); font-weight: 600; }
+.comment-field { display: block; margin-top: 16px; color: var(--color-text); font-weight: 600; }
+.comment-field > span { display: flex; justify-content: space-between; margin-bottom: 7px; }
+.comment-field small { color: var(--color-text-secondary); font-weight: 400; }
+.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
+@media (max-width: 1050px) {
+  .review-layout { grid-template-columns: 1fr; }
+  .review-form-card { position: static; }
+}
+@media (max-width: 640px) {
+  .invitation-card { margin: 20px 0; padding: 24px; }
+  .invitation-actions { align-items: stretch; flex-direction: column; }
+}
+</style>