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