Ver Fonte

feat: 添加员工360环评邀请流程

wangkangyjy há 2 dias atrás
pai
commit
58a936f58d

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

@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const source = path => readFileSync(resolve(process.cwd(), path), 'utf8')
+
+describe('360 peer review contracts', () => {
+  it('adds the employee invitation flow without exposing review content', () => {
+    const page = source('src/views/scoring/MyResultView.vue')
+    const api = source('src/api/index.js')
+
+    expect(page).toContain('360 环评邀请')
+    expect(page).toContain('peerReviewApi.getMyProgress')
+    expect(page).toContain('已收到 {{ peerProgress.submittedCount }} 份有效评价')
+    expect(page).toContain('至少选择 3 位、最多 5 位同事')
+    expect(page).toContain('同部门和跨部门同事各至少 1 位')
+    expect(page).not.toContain('peerProgress.responses')
+    expect(page).not.toContain('peerProgress.averageScore')
+
+    expect(api).toContain("getMyCandidates: periodId => api.get('/peer-reviews/my/candidates'")
+    expect(api).toContain("submitMyRequest: data => api.post('/peer-reviews/my/requests'")
+    expect(api).toContain("supplementMyRequest: data => api.post('/peer-reviews/my/supplements'")
+  })
+})

+ 17 - 0
frontend/src/api/index.js

@@ -138,6 +138,23 @@ export const scoreApi = {
   getTrend: (limit = 5) => api.get('/scores/trend', { params: { limit } }).then(r => r.data),
 }
 
+// 360 Peer Review API
+export const peerReviewApi = {
+  getMyCandidates: periodId => api.get('/peer-reviews/my/candidates', { params: { periodId } }).then(r => r.data),
+  getMyProgress: periodId => api.get('/peer-reviews/my/progress', { params: { periodId } }).then(r => r.data),
+  submitMyRequest: data => api.post('/peer-reviews/my/requests', data).then(r => r.data),
+  supplementMyRequest: data => api.post('/peer-reviews/my/supplements', data).then(r => r.data),
+  getLeaderQueue: periodId => api.get('/peer-reviews/leader/queue', { params: { periodId } }).then(r => r.data),
+  getLeaderSummary: (periodId, userId) => api.get('/peer-reviews/leader/summary', { params: { periodId, userId } }).then(r => r.data),
+  reviewRequest: (id, data) => api.put(`/peer-reviews/leader/requests/${id}/review`, data).then(r => r.data),
+  acceptInvitation: id => api.post(`/peer-reviews/invitations/${id}/accept`).then(r => r.data),
+  declineInvitation: (id, data) => api.post(`/peer-reviews/invitations/${id}/decline`, data).then(r => r.data),
+  getWorkspace: id => api.get(`/peer-reviews/invitations/${id}`).then(r => r.data),
+  saveDraft: (id, data) => api.put(`/peer-reviews/invitations/${id}/draft`, data).then(r => r.data),
+  submitResponse: (id, data) => api.put(`/peer-reviews/invitations/${id}/submit`, data).then(r => r.data),
+  getAdminStatistics: periodId => api.get('/peer-reviews/admin/statistics', { params: { periodId } }).then(r => r.data)
+}
+
 export const scoreAppealApi = {
   list: params => api.get('/score-appeals', { params }).then(r => r.data),
   listMy: () => api.get('/score-appeals/my').then(r => r.data),

+ 134 - 2
frontend/src/views/scoring/MyResultView.vue

@@ -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>