|
|
@@ -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%; }
|
|
|
}
|