feat(mobile): redesign supervisor review flow with completion page

Rework the pending-review list with priority badges, filters and score
ordering, rebuild the review detail around a large-score header, score
bars, dialogue bubbles and a guided review form, and add a completion
page that summarizes the review outcome and the dispatched follow-up
assignment. All three pages resolve trainee display names through the
team snapshot so organization names replace raw app account labels.
This commit is contained in:
2026-07-20 13:16:37 +08:00
parent b6a7af9e51
commit 6f2159db73
4 changed files with 1672 additions and 330 deletions
@@ -0,0 +1,438 @@
<template>
<view class="page review-done-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回待复盘" title="返回待复盘" @click="goToReviews"><uni-icons type="back" size="20" color="#25282d" /></button>
<view class="subpage-heading">
<text class="page-title">复盘已完成</text>
<text class="page-subtitle">{{ detail ? `${displayName} · ${detail.scene}` : '训练记录' }}</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<template v-if="detail">
<view class="panel success-panel">
<view class="success-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</view>
<view class="success-copy">
<text class="success-title">已标记复盘</text>
<text class="success-time">{{ doneTimeLabel }}</text>
</view>
</view>
<view class="panel stats-panel">
<view class="stat-cell">
<text class="stat-label">原训练</text>
<text class="stat-value accent">{{ detail.score }}<text class="stat-unit">分</text></text>
</view>
<view class="stat-cell">
<text class="stat-label">人工贡献分</text>
<text class="stat-value">{{ detail.incentivePoint != null ? detail.incentivePoint : '—' }}<text v-if="detail.incentivePoint != null" class="stat-unit">分</text></text>
</view>
<view class="stat-cell">
<text class="stat-label">下一专项</text>
<text class="stat-value plain">{{ assignedScenario ? '已派发' : '未派发' }}</text>
</view>
</view>
<view v-if="detail.reviewAdvice" class="panel">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/></svg>
</view>
<text class="panel-title">主管复盘建议</text>
</view>
<text class="advice-text">{{ detail.reviewAdvice }}</text>
</view>
<view v-if="assignedScenario" class="panel assignment-panel">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg>
</view>
<text class="panel-title">已派发专项</text>
</view>
<view class="assignment-row">
<view class="assignment-copy">
<text class="assignment-title">{{ assignedScenario }}专项</text>
<text class="assignment-meta">{{ assignedDueDate ? `截止 ${assignedDueDate}` : '已生成训练任务' }}</text>
</view>
<text class="assignment-badge">待训练</text>
</view>
</view>
<view class="evidence-note">
<view class="evidence-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</view>
<text class="evidence-text">复盘依据:AI评分明细、对话录音、SOP要点均已保留,可再次查看。</text>
</view>
<view class="done-actions">
<button class="done-primary" @click="goToReviews">返回复盘队列</button>
<button class="done-secondary" @click="goToTeam">查看成员画像</button>
</view>
</template>
<view v-else-if="!loading" class="panel empty-panel">
<view class="empty">复盘结果暂不可用</view>
<button class="secondary-button back-list-button" @click="goToReviews">返回待复盘</button>
</view>
<SupervisorTabBar active="review" />
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment, ReviewDetail } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeAssignments, getPracticeReviewDetail, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
const basePath = '/pages/supervisor/review/done';
const reviewId = ref(0);
const detail = ref<ReviewDetail | null>(null);
const assignedScenario = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const loading = ref(false);
const message = ref('');
const memberNames = ref<Record<string, string>>({});
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const now = new Date();
const doneTimeLabel = `今天 ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')} · ${now.getMonth() + 1}月${now.getDate()}日 ${weekNames[now.getDay()]}`;
const displayName = computed(() => {
const identity = (detail.value?.extPartyId || '').trim();
const mapped = identity ? memberNames.value[identity] : '';
if (mapped) return mapped;
const name = detail.value?.trainee?.trim();
if (name && !/^[??]{3,}/.test(name) && !/^手机用户\d+$/.test(name)) return name;
if (/^1[3-9]\d{9}$/.test(identity)) return `员工·尾号${identity.slice(-4)}`;
return identity ? `员工·${identity.length > 8 ? `末尾${identity.slice(-6)}` : identity}` : '员工';
});
const assignedDueDate = computed(() => {
if (!assignedScenario.value || !detail.value) return '';
const identity = (detail.value.extPartyId || '').trim();
const matched = assignments.value
.filter((item) => item.status !== '已完成'
&& item.scenarioName === assignedScenario.value
&& (!identity || item.extPartyId === identity || item.traineeName === detail.value?.trainee))
.sort((a, b) => (b.dueDate || '').localeCompare(a.dueDate || ''));
return matched[0]?.dueDate || '';
});
const loadPage = async () => {
if (!isLoggedIn()) {
rememberLoginRedirect(reviewId.value ? `${basePath}?id=${reviewId.value}` : basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
return;
}
if (!reviewId.value || loading.value) return;
loading.value = true;
message.value = '';
try {
const [nextDetail, nextAssignments, snapshot] = await Promise.all([
getPracticeReviewDetail(reviewId.value),
assignedScenario.value
? getPracticeAssignments().catch((): PracticeAssignment[] => [])
: Promise.resolve<PracticeAssignment[]>([]),
getPracticeTeamSnapshot().catch(() => ({ members: [], records: [], assignments: [], mistakes: [] }))
]);
detail.value = nextDetail;
assignments.value = nextAssignments;
memberNames.value = Object.fromEntries(
snapshot.members
.map((member) => [member.extPartyId, member.name] as const)
.filter(([id, name]) => Boolean(id && name) && !/^[??]{3,}/.test(name))
);
} catch (error) {
message.value = error instanceof Error ? error.message : '复盘结果加载失败';
} finally {
loading.value = false;
}
};
const goToReviews = () => {
uni.redirectTo({ url: '/pages/supervisor/reviews/index' });
};
const goToTeam = () => {
uni.redirectTo({ url: '/pages/supervisor/team/index' });
};
onLoad((query) => {
const value = Number(query?.id);
reviewId.value = Number.isInteger(value) && value > 0 ? value : 0;
assignedScenario.value = typeof query?.assigned === 'string' ? query.assigned.trim() : '';
});
onShow(() => {
resetPageScroll();
if (!detail.value) void loadPage();
});
</script>
<style scoped>
.review-done-page {
padding: 22px 20px 112px;
background: #f7f7f7;
}
.subpage-header {
align-items: center;
}
.review-done-page .panel {
border: 0;
border-radius: 20px;
background: #ffffff;
box-shadow: none;
padding: 18px;
}
.panel-head {
display: flex;
align-items: center;
gap: 8px;
}
.panel-icon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
color: #e60012;
}
.panel-icon svg {
width: 20px;
height: 20px;
}
.panel-title {
color: #17181c;
font-size: 16px;
font-weight: 900;
line-height: 1.3;
}
/* 成功条 */
.success-panel {
display: flex;
align-items: center;
gap: 12px;
}
.success-icon {
display: flex;
width: 44px;
height: 44px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #16a34a;
color: #ffffff;
}
.success-icon svg {
width: 24px;
height: 24px;
}
.success-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.success-title {
color: #166534;
font-size: 17px;
font-weight: 900;
line-height: 1.3;
}
.success-time {
color: #8a8d93;
font-size: 12px;
line-height: 1.4;
}
/* 三格统计 */
.stats-panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.stat-cell {
display: flex;
align-items: center;
flex-direction: column;
gap: 8px;
}
.stat-label {
color: #8a8d93;
font-size: 12px;
line-height: 1.3;
}
.stat-value {
color: #25282d;
font-size: 24px;
font-weight: 900;
line-height: 1;
}
.stat-value.accent {
color: #e60012;
}
.stat-value.plain {
font-size: 18px;
}
.stat-unit {
margin-left: 2px;
font-size: 13px;
font-weight: 800;
}
/* 建议与专项 */
.advice-text {
display: block;
margin-top: 12px;
color: #3a3d44;
font-size: 14px;
line-height: 1.6;
}
.assignment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 12px;
border-radius: 12px;
background: #f8f8f9;
}
.assignment-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.assignment-title {
color: #25282d;
font-size: 14px;
font-weight: 800;
line-height: 1.35;
}
.assignment-meta {
color: #8a8d93;
font-size: 12px;
line-height: 1.4;
}
.assignment-badge {
flex: 0 0 auto;
padding: 4px 10px;
border-radius: 10px;
background: #fff1f2;
color: #e60012;
font-size: 11px;
font-weight: 900;
line-height: 1.3;
}
/* 依据说明 */
.evidence-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 4px 0;
}
.evidence-icon {
display: flex;
width: 18px;
height: 18px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f0fdf4;
color: #16a34a;
}
.evidence-icon svg {
width: 12px;
height: 12px;
}
.evidence-text {
min-width: 0;
flex: 1;
color: #8a8d93;
font-size: 12px;
line-height: 1.55;
}
/* 底部按钮 */
.done-actions {
margin-top: 20px;
}
.done-primary,
.done-secondary {
display: flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border-radius: 24px;
font-size: 16px;
font-weight: 900;
line-height: 1;
}
.done-primary::after,
.done-secondary::after {
border: 0;
}
.done-primary {
border: 0;
background: #e60012;
color: #ffffff;
letter-spacing: 1px;
}
.done-secondary {
margin-top: 10px;
border: 1px solid #e3e5e9;
background: #ffffff;
color: #3a3d44;
}
.empty-panel {
padding-bottom: 18px;
}
.back-list-button {
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff