Jelajahi Sumber

feat: 完成环评管理员配置与统计

wangkangyjy 2 hari lalu
induk
melakukan
95789c57fb

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

@@ -160,6 +160,7 @@ public final class PeerReviewDto {
     @Data
     public static class AdminStatistics {
         private int requestedEmployeeCount;
+        private int effectiveInvitationCount;
         private int submittedResponseCount;
         private int blockedEmployeeCount;
         private List<Long> blockedSubjectUserIds = new ArrayList<>();

+ 2 - 0
backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.java

@@ -620,6 +620,8 @@ public class AssessmentPeriodService {
             p.setPeriodType(type);
             p.setStartDate(cursor);
             p.setEndDate(type.calculateEndDate(cursor));
+            p.setPeerInvitationDeadline(p.getEndDate().plusDays(1).atTime(18, 0));
+            p.setPeerReviewDeadline(p.getEndDate().plusDays(3).atTime(18, 0));
             p.setCompanyOwnerUserId(companyOwnerUserId);
             p.setStatus(PeriodStatus.DRAFT);
             periodMapper.insert(p);

+ 3 - 0
backend/src/main/java/com/emoon/okr/service/PeerReviewService.java

@@ -382,6 +382,9 @@ public class PeerReviewService {
 
         PeerReviewDto.AdminStatistics statistics = new PeerReviewDto.AdminStatistics();
         statistics.setRequestedEmployeeCount(requests.size());
+        statistics.setEffectiveInvitationCount((int) invitations.stream()
+                .filter(item -> !INVITATION_DECLINED.equals(item.getStatus())
+                        && !INVITATION_INVALIDATED.equals(item.getStatus())).count());
         statistics.setSubmittedResponseCount((int) invitations.stream()
                 .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
         statistics.setBlockedEmployeeCount(blocked.size());

+ 5 - 1
backend/src/test/java/com/emoon/okr/service/AssessmentPeriodServiceTest.java

@@ -375,8 +375,12 @@ class AssessmentPeriodServiceTest {
 
         assertEquals(6, periods.size());
         assertTrue(periods.stream().allMatch(period -> Long.valueOf(99L).equals(period.getCompanyOwnerUserId())));
+        assertTrue(periods.stream().allMatch(period -> period.getPeerInvitationDeadline() != null
+                && period.getPeerReviewDeadline() != null
+                && period.getPeerInvitationDeadline().isBefore(period.getPeerReviewDeadline())));
         verify(periodMapper, times(6)).insert(argThat(period ->
-                Long.valueOf(99L).equals(((AssessmentPeriod) period).getCompanyOwnerUserId())));
+                Long.valueOf(99L).equals(((AssessmentPeriod) period).getCompanyOwnerUserId())
+                        && ((AssessmentPeriod) period).getPeerInvitationDeadline() != null));
     }
 
     @Test

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

@@ -473,6 +473,7 @@ class PeerReviewServiceTest {
         PeerReviewDto.AdminStatistics statistics = service.getAdminStatistics(1L, 99L);
 
         assertEquals(2, statistics.getSubmittedResponseCount());
+        assertEquals(2, statistics.getEffectiveInvitationCount());
         assertEquals(List.of(21L), statistics.getBlockedSubjectUserIds());
         JsonNode json = new ObjectMapper().valueToTree(statistics);
         assertTrue(!json.has("reviewerNames") && !json.has("comments") && !json.has("responses"));

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

@@ -52,4 +52,18 @@ describe('360 peer review contracts', () => {
     expect(page).not.toContain('bonus')
     expect(page).not.toContain('penalty')
   })
+
+  it('lets administrators configure both deadlines and view aggregate-only statistics', () => {
+    const periods = source('src/views/period/PeriodListView.vue')
+
+    expect(periods).toContain('peerInvitationDeadline')
+    expect(periods).toContain('peerReviewDeadline')
+    expect(periods).toContain('邀请名单截止')
+    expect(periods).toContain('评价提交截止')
+    expect(periods).toContain('peerReviewApi.getAdminStatistics')
+    expect(periods).toContain('邀请覆盖率')
+    expect(periods).toContain('有效邀请提交率')
+    expect(periods).not.toContain('peerStatistics.comments')
+    expect(periods).not.toContain('peerStatistics.reviewerNames')
+  })
 })

+ 141 - 3
frontend/src/views/period/PeriodListView.vue

@@ -103,11 +103,16 @@
                   <el-tag size="small" effect="plain" type="info" round>{{ typeLabel(p.periodType) }}</el-tag>
                 </div>
                 <div class="card-date">{{ p.startDate }} ~ {{ p.endDate }}</div>
+                <div v-if="p.status === 'ASSESSING'" class="peer-deadline-summary">
+                  环评邀请截止 {{ formatDeadline(p.peerInvitationDeadline) }} · 评价截止 {{ formatDeadline(p.peerReviewDeadline) }}
+                </div>
               </div>
               <div class="card-actions">
                 <template v-if="auth.isHrAdminOrAbove">
                   <el-button v-if="p.status === 'EXECUTING'" type="warning" size="small" plain :loading="transitioning === p.id" @click="transition(p.id, 'OKR_ALIGN')" round>回退</el-button>
                   <el-button size="small" plain @click="openParticipants(p)" round>参与人</el-button>
+                  <el-button v-if="p.status === 'ASSESSING'" size="small" plain @click="openPeerDeadlineDialog(p)" round>环评设置</el-button>
+                  <el-button v-if="p.status === 'ASSESSING'" size="small" plain @click="openPeerStatistics(p)" round>环评统计</el-button>
                   <el-button v-if="p.status === 'EXECUTING'" type="primary" size="small" :loading="transitioning === p.id" @click="transition(p.id, 'ASSESSING')" round>进入考评</el-button>
                   <el-button v-if="p.status === 'ASSESSING'" type="warning" :loading="transitioning === p.id" @click="transition(p.id, 'ARCHIVED')" round>结束并归档</el-button>
                 </template>
@@ -159,6 +164,7 @@
               <div class="card-actions" v-if="auth.isHrAdminOrAbove">
                 <el-button type="primary" size="small" :loading="transitioning === p.id" @click="transition(p.id, 'OKR_ALIGN')" round>发布 OKR 对齐</el-button>
                 <el-button size="small" plain @click="openParticipants(p)" round>参与人</el-button>
+                <el-button size="small" plain @click="openPeerDeadlineDialog(p)" round>环评设置</el-button>
                 <el-button type="danger" size="small" plain @click="handleDelete(p.id)" round>删除</el-button>
               </div>
             </div>
@@ -245,6 +251,14 @@
         <el-form-item label="结束日期">
           <el-date-picker v-model="newPeriod.endDate" type="date" placeholder="自动计算或手动选择" style="width:100%" />
         </el-form-item>
+        <template v-if="!batchMode">
+          <el-form-item label="邀请名单截止" label-width="110px" required>
+            <el-date-picker v-model="newPeriod.peerInvitationDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择邀请名单截止时间" style="width:100%" />
+          </el-form-item>
+          <el-form-item label="评价提交截止" label-width="110px" required>
+            <el-date-picker v-model="newPeriod.peerReviewDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择评价提交截止时间" style="width:100%" />
+          </el-form-item>
+        </template>
         <el-divider />
         <el-form-item label="批量创建">
           <el-checkbox v-model="batchMode" @change="onTypeChange">
@@ -268,6 +282,35 @@
       </template>
     </el-dialog>
 
+    <el-dialog v-model="showPeerDeadlines" :title="`环评设置 · ${peerDeadlinePeriod?.name || ''}`" width="520px">
+      <el-form label-width="110px">
+        <el-form-item label="邀请名单截止" required>
+          <el-date-picker v-model="peerDeadlineForm.peerInvitationDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="评价提交截止" required>
+          <el-date-picker v-model="peerDeadlineForm.peerReviewDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" />
+        </el-form-item>
+        <div class="form-hint">邀请名单截止必须早于评价提交截止;考评开始后只能延长时间。</div>
+      </el-form>
+      <template #footer>
+        <el-button @click="showPeerDeadlines = false">取消</el-button>
+        <el-button type="primary" @click="savePeerDeadlines">保存</el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="showPeerStatistics" :title="`360 环评统计 · ${peerStatisticsPeriod?.name || ''}`" width="620px">
+      <div v-loading="peerStatisticsLoading" class="peer-statistics">
+        <div class="peer-stat-card"><span>邀请覆盖率</span><strong>{{ invitationCoverage }}</strong><small>{{ peerStatistics.requestedEmployeeCount }} / {{ peerActiveParticipantCount }} 人</small></div>
+        <div class="peer-stat-card"><span>有效邀请提交率</span><strong>{{ effectiveSubmissionRate }}</strong><small>{{ peerStatistics.submittedResponseCount }} / {{ peerStatistics.effectiveInvitationCount }} 份</small></div>
+        <div class="peer-stat-card"><span>阻塞终评</span><strong>{{ peerStatistics.blockedEmployeeCount }}</strong><small>人</small></div>
+        <div v-if="peerBlockedNames.length" class="peer-blocked-list">
+          <strong>尚不足 2 份有效评价</strong>
+          <span v-for="name in peerBlockedNames" :key="name">{{ name }}</span>
+        </div>
+        <p class="peer-statistics__privacy">管理员仅查看聚合进度,不展示评价人身份、评分或简评内容。</p>
+      </div>
+    </el-dialog>
+
     <el-dialog v-model="showReadiness" title="暂不能切换周期状态" width="680px" class="readiness-dialog">
       <div class="readiness-summary">
         <span class="readiness-summary__mark">!</span>
@@ -385,7 +428,7 @@
 <script setup>
 import { ref, computed, nextTick, onMounted } from 'vue'
 import { useRoute } from 'vue-router'
-import { periodApi, orgApi, kpiApi } from '../../api'
+import { periodApi, orgApi, kpiApi, peerReviewApi } from '../../api'
 import { useAuthStore } from '../../stores/auth'
 import { ElMessage, ElMessageBox } from 'element-plus'
 
@@ -412,7 +455,16 @@ const participantTreeRef = ref(null)
 const participantActionLoading = ref(false)
 const syncingParticipantTree = ref(false)
 const participantCheckKeys = ref([])
-const emptyPeriod = () => ({ name: '', periodType: 'MONTHLY', startDate: '', endDate: '', companyOwnerUserId: null })
+const showPeerDeadlines = ref(false)
+const peerDeadlinePeriod = ref(null)
+const peerDeadlineForm = ref({ peerInvitationDeadline: '', peerReviewDeadline: '' })
+const showPeerStatistics = ref(false)
+const peerStatisticsLoading = ref(false)
+const peerStatisticsPeriod = ref(null)
+const peerStatistics = ref({ requestedEmployeeCount: 0, effectiveInvitationCount: 0, submittedResponseCount: 0, blockedEmployeeCount: 0, blockedSubjectUserIds: [] })
+const peerActiveParticipantCount = ref(0)
+const peerBlockedNames = ref([])
+const emptyPeriod = () => ({ name: '', periodType: 'MONTHLY', startDate: '', endDate: '', companyOwnerUserId: null, peerInvitationDeadline: '', peerReviewDeadline: '' })
 const newPeriod = ref(emptyPeriod())
 const typeDays = { WEEKLY: 6, BI_WEEKLY: 13 }
 const typeMonths = { MONTHLY: 1, BI_MONTHLY: 2, QUARTERLY: 3 }
@@ -475,6 +527,8 @@ const currentPeriod = computed(() => active.value.find(p => p.id === requestedPe
   || active.value.find(p => p.status !== 'DRAFT')
   || active.value[0]
   || null)
+const invitationCoverage = computed(() => percent(peerStatistics.value.requestedEmployeeCount, peerActiveParticipantCount.value))
+const effectiveSubmissionRate = computed(() => percent(peerStatistics.value.submittedResponseCount, peerStatistics.value.effectiveInvitationCount))
 const hasKpiReadinessBlocker = computed(() => readinessBlockers.value.some(isKpiBlocker))
 const periodStages = [
   { status: 'DRAFT', label: '制定' }, { status: 'OKR_ALIGN', label: '对齐' },
@@ -506,9 +560,33 @@ function onTypeChange() {
   if (start && !batchMode.value) {
     const end = calcEndDate(start, newPeriod.value.periodType)
     newPeriod.value.endDate = end.toISOString().split('T')[0]
+    const defaults = defaultPeerDeadlines(newPeriod.value.endDate)
+    newPeriod.value.peerInvitationDeadline = defaults.peerInvitationDeadline
+    newPeriod.value.peerReviewDeadline = defaults.peerReviewDeadline
   }
 }
 
+function defaultPeerDeadlines(endDate) {
+  if (!endDate) return { peerInvitationDeadline: '', peerReviewDeadline: '' }
+  const end = new Date(`${fmtDate(endDate)}T00:00:00`)
+  const invitation = new Date(end); invitation.setDate(invitation.getDate() + 1); invitation.setHours(18, 0, 0, 0)
+  const review = new Date(end); review.setDate(review.getDate() + 3); review.setHours(18, 0, 0, 0)
+  return { peerInvitationDeadline: localDateTime(invitation), peerReviewDeadline: localDateTime(review) }
+}
+
+function localDateTime(date) {
+  const pad = value => String(value).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`
+}
+
+function formatDeadline(value) {
+  return value ? String(value).replace('T', ' ').slice(0, 16) : '未设置'
+}
+
+function percent(value, total) {
+  return total ? `${Math.round(Number(value || 0) / total * 100)}%` : '0%'
+}
+
 function fmtDate(d) { return d ? (typeof d === 'string' ? d : d.toISOString().split('T')[0]) : '' }
 
 onMounted(() => load())
@@ -680,6 +758,7 @@ async function handleCreate() {
     ElMessage.warning('请选择公司负责人')
     return
   }
+  if (!batchMode.value && !validPeerDeadlines(newPeriod.value)) return
   if (batchMode.value) {
     await periodApi.batchCreate({
       namePrefix: (newPeriod.value.name || '周期') + '',
@@ -695,7 +774,9 @@ async function handleCreate() {
       periodType: d.periodType,
       startDate: fmtDate(d.startDate),
       endDate: fmtDate(d.endDate),
-      companyOwnerUserId: d.companyOwnerUserId
+      companyOwnerUserId: d.companyOwnerUserId,
+      peerInvitationDeadline: d.peerInvitationDeadline,
+      peerReviewDeadline: d.peerReviewDeadline
     })
     ElMessage.success('周期已创建')
   }
@@ -704,6 +785,52 @@ async function handleCreate() {
   load()
 }
 
+function validPeerDeadlines(value) {
+  if (!value.peerInvitationDeadline || !value.peerReviewDeadline) {
+    ElMessage.warning('请设置邀请名单截止和评价提交截止时间')
+    return false
+  }
+  if (new Date(value.peerInvitationDeadline) >= new Date(value.peerReviewDeadline)) {
+    ElMessage.warning('邀请名单截止时间必须早于评价提交截止时间')
+    return false
+  }
+  return true
+}
+
+function openPeerDeadlineDialog(period) {
+  peerDeadlinePeriod.value = period
+  peerDeadlineForm.value = {
+    peerInvitationDeadline: period.peerInvitationDeadline || '',
+    peerReviewDeadline: period.peerReviewDeadline || ''
+  }
+  showPeerDeadlines.value = true
+}
+
+async function savePeerDeadlines() {
+  if (!validPeerDeadlines(peerDeadlineForm.value)) return
+  await periodApi.update(peerDeadlinePeriod.value.id, peerDeadlineForm.value)
+  ElMessage.success('环评截止时间已更新')
+  showPeerDeadlines.value = false
+  await load()
+}
+
+async function openPeerStatistics(period) {
+  peerStatisticsPeriod.value = period
+  showPeerStatistics.value = true
+  peerStatisticsLoading.value = true
+  try {
+    const [statistics, participantData, userData] = await Promise.all([
+      peerReviewApi.getAdminStatistics(period.id),
+      periodApi.getParticipants(period.id),
+      orgApi.getUsers()
+    ])
+    peerStatistics.value = statistics
+    peerActiveParticipantCount.value = participantData.filter(item => item.status === 'ACTIVE').length
+    const names = new Map(userData.map(user => [user.id, user.realName || user.username]))
+    peerBlockedNames.value = (statistics.blockedSubjectUserIds || []).map(id => names.get(id) || `用户 ${id}`)
+  } finally { peerStatisticsLoading.value = false }
+}
+
 async function transition(id, status) {
   if (transitioning.value) return
   transitioning.value = id
@@ -880,6 +1007,7 @@ function statusGuide(p) {
 .card-info { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
 .card-name { font-size: var(--font-size-lg); font-weight: 700; color: var(--color-text); }
 .card-date { font-size: var(--font-size-xs); color: var(--color-text-muted); }
+.peer-deadline-summary { color: var(--color-text-secondary); font-size: var(--font-size-xs); }
 .card-actions { display: flex; gap: var(--spacing-xs); flex-shrink: 0; align-items: center; }
 .card-actions .el-button { white-space: nowrap; }
 .card-guide { font-size: var(--font-size-xs); color: var(--color-text-secondary); padding: var(--spacing-sm) var(--spacing-lg) 10px; background: var(--color-surface-subtle); border-top: 1px solid var(--color-border-soft); }
@@ -887,6 +1015,15 @@ function statusGuide(p) {
 /* Dialog */
 .batch-preview { background: var(--color-surface-subtle); border-radius: var(--radius-sm); padding: var(--spacing-sm) var(--spacing-md); margin-top: var(--spacing-sm); border: 1px solid var(--color-border-soft); }
 .form-hint { margin-top: var(--spacing-xs); color: var(--color-text-muted); font-size: var(--font-size-xs); line-height: 1.5; }
+.peer-statistics { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-md); color: var(--color-text); }
+.peer-stat-card { display: grid; gap: 4px; padding: var(--spacing-lg); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); background: var(--color-surface-tint-brand); }
+.peer-stat-card span { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
+.peer-stat-card strong { color: var(--color-brand); font-size: 26px; }
+.peer-stat-card small { color: var(--color-text-secondary); }
+.peer-blocked-list { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: var(--spacing-md); border: 1px solid var(--color-border-soft); border-radius: var(--radius-md); }
+.peer-blocked-list strong { width: 100%; color: var(--color-text); }
+.peer-blocked-list span { padding: 4px 8px; border-radius: var(--radius-sm); background: var(--color-surface-subtle); color: var(--color-text); font-size: var(--font-size-sm); }
+.peer-statistics__privacy { grid-column: 1 / -1; margin: 0; color: var(--color-text-secondary); font-size: var(--font-size-sm); }
 .bp-title { font-size: var(--font-size-xs); font-weight: 600; color: var(--color-brand); margin-bottom: var(--spacing-sm); }
 .bp-item { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-xs) 0; font-size: var(--font-size-xs); }
 .bp-num { width: var(--font-size-xl); height: var(--font-size-xl); line-height: var(--font-size-xl); text-align: center; border-radius: 50%; background: var(--color-accent); color: var(--color-white); font-size: var(--font-size-xs); font-weight: 700; }
@@ -1007,6 +1144,7 @@ function statusGuide(p) {
 }
 @media (max-width: 640px) { .period-stage-track { overflow-x: auto; }.period-stage-track__steps { min-width: 500px; } }
 @media (max-width: 640px) {
+   .peer-statistics { grid-template-columns: 1fr; }
   .kpi-incomplete-row { grid-template-columns: 1fr auto; }
   .kpi-incomplete-row :deep(.el-button) { grid-column: 1 / -1; width: 100%; }
 }