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:
@@ -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
Reference in New Issue
Block a user