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:
@@ -145,6 +145,12 @@
|
||||
"navigationBarTitleText": "复盘详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/review/done",
|
||||
"style": {
|
||||
"navigationBarTitleText": "复盘已完成"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/profile/index",
|
||||
"style": {
|
||||
|
||||
@@ -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
@@ -6,8 +6,8 @@
|
||||
<text class="page-title">待复盘</text>
|
||||
<text class="page-subtitle">{{ reviewCountText }}</text>
|
||||
</view>
|
||||
<button v-if="loggedIn && !accessDenied" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">
|
||||
{{ loading ? '刷新中' : '刷新' }}
|
||||
<button v-if="loggedIn && !accessDenied" class="refresh-icon" :disabled="loading" @click="loadPage">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -30,31 +30,63 @@
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="panel review-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">复盘队列</text>
|
||||
<text class="muted">低分优先</text>
|
||||
</view>
|
||||
<view v-if="reviews.length" class="review-list">
|
||||
<button
|
||||
v-for="item in reviews"
|
||||
:key="item.id || item.sessionId || item.time"
|
||||
class="review-row"
|
||||
@click="openReview(item)"
|
||||
>
|
||||
<view class="review-copy">
|
||||
<text class="review-title">{{ item.trainee }} · {{ item.scene }}</text>
|
||||
<text class="review-desc">{{ item.time }} · {{ item.summary }}</text>
|
||||
<text class="review-status">{{ item.status }}</text>
|
||||
</view>
|
||||
<view class="review-score">
|
||||
<text>{{ item.score }}</text>
|
||||
<text>查看</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="filter-bar">
|
||||
<view class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === tab.key }"
|
||||
@click="activeFilter = tab.key"
|
||||
>{{ tab.label }}</button>
|
||||
</view>
|
||||
<button class="sort-toggle" @click="toggleSort">
|
||||
{{ sortLabel }}
|
||||
<text class="sort-caret">▾</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!loading" class="empty">暂无待复盘记录</view>
|
||||
</view>
|
||||
|
||||
<view v-if="displayedReviews.length" class="review-cards">
|
||||
<view
|
||||
v-for="item in displayedReviews"
|
||||
:key="item.record.id || item.record.sessionId || item.record.time"
|
||||
class="review-card"
|
||||
@click="openReview(item.record)"
|
||||
>
|
||||
<view class="card-main">
|
||||
<view class="card-title-row">
|
||||
<text class="priority-badge" :class="item.priority">{{ item.badge }}</text>
|
||||
<text class="card-title">{{ item.displayName }} · {{ item.record.scene }}</text>
|
||||
<text class="status-tag">待复盘</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
<text class="meta-time">{{ item.record.time }}</text>
|
||||
<text v-if="item.lowCount >= 2" class="meta-warning">连续{{ item.lowCount }}次低于60分</text>
|
||||
<text v-else class="meta-normal">待主管复盘</text>
|
||||
</view>
|
||||
<text class="card-summary">{{ item.record.summary }}</text>
|
||||
</view>
|
||||
<view class="card-side">
|
||||
<text class="big-score">{{ item.record.score }}</text>
|
||||
<button
|
||||
class="card-action"
|
||||
:class="item.priority"
|
||||
@click.stop="openReview(item.record)"
|
||||
>{{ item.action }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loading" class="state-panel">
|
||||
<view class="state-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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="m9 14 2 2 4-4"/></svg>
|
||||
</view>
|
||||
<text class="state-title">暂无待复盘记录</text>
|
||||
<text class="state-desc">团队对练完成后会进入复盘队列</text>
|
||||
<button class="state-button" @click="goHome">返回今日</button>
|
||||
</view>
|
||||
</template>
|
||||
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -65,29 +97,100 @@ import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAlert, PracticeRecord } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeAlerts, getPracticeReviews } from '@/services/practice';
|
||||
import { getPracticeAlerts, getPracticeReviews, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import { buildSupervisorMetrics, isSupervisorAccessDenied } from '@/services/supervisor-view';
|
||||
|
||||
type FilterKey = 'all' | 'below60' | 'repeatLow';
|
||||
type Priority = 'high' | 'medium' | 'low';
|
||||
|
||||
interface ReviewRow {
|
||||
record: PracticeRecord;
|
||||
priority: Priority;
|
||||
badge: string;
|
||||
action: string;
|
||||
lowCount: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const currentPath = '/pages/supervisor/reviews/index';
|
||||
const reviews = ref<PracticeRecord[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const memberNames = ref<Record<string, string>>({});
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const activeFilter = ref<FilterKey>('all');
|
||||
const sortMode = ref<'score' | 'time'>('score');
|
||||
|
||||
const filterTabs: Array<{ key: FilterKey; label: string }> = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'below60', label: '低于60分' },
|
||||
{ key: 'repeatLow', label: '连续低分' }
|
||||
];
|
||||
|
||||
const pendingReviewTotal = computed(() =>
|
||||
buildSupervisorMetrics(reviews.value, [], alerts.value)
|
||||
.find((item) => item.key === 'pendingReviews')?.value || 0);
|
||||
const reviewCountText = computed(() =>
|
||||
pendingReviewTotal.value > reviews.value.length
|
||||
? `${reviews.value.length}/${pendingReviewTotal.value} 条`
|
||||
: `${reviews.value.length} 条`);
|
||||
? `${reviews.value.length}/${pendingReviewTotal.value} 条 · 低分优先`
|
||||
: `${reviews.value.length} 条 · 低分优先`);
|
||||
const sortLabel = computed(() => (sortMode.value === 'score' ? '低分优先' : '最新优先'));
|
||||
|
||||
const identitySuffix = (value: string) => {
|
||||
const text = value.trim();
|
||||
if (/^1[3-9]\d{9}$/.test(text)) return `尾号${text.slice(-4)}`;
|
||||
if (text.length > 8) return `末尾${text.slice(-6)}`;
|
||||
return text;
|
||||
};
|
||||
|
||||
const displayNameOf = (record: PracticeRecord) => {
|
||||
const identity = (record.extPartyId || '').trim();
|
||||
const mapped = identity ? memberNames.value[identity] : '';
|
||||
if (mapped) return mapped;
|
||||
const name = record.trainee?.trim();
|
||||
if (name && !/^[??]{3,}/.test(name) && !/^手机用户\d+$/.test(name)) return name;
|
||||
return identity ? `员工·${identitySuffix(identity)}` : '员工';
|
||||
};
|
||||
|
||||
const lowCountOf = (record: PracticeRecord) => {
|
||||
const identity = (record.extPartyId || record.trainee || '').trim();
|
||||
if (!identity) return record.score < 60 ? 1 : 0;
|
||||
return reviews.value.filter((item) =>
|
||||
(item.extPartyId || item.trainee || '').trim() === identity && item.score < 60).length;
|
||||
};
|
||||
|
||||
const rows = computed<ReviewRow[]>(() =>
|
||||
reviews.value.map((record) => {
|
||||
const priority: Priority = record.score < 60 ? 'high' : record.score < 80 ? 'medium' : 'low';
|
||||
return {
|
||||
record,
|
||||
priority,
|
||||
badge: priority === 'high' ? '高' : priority === 'medium' ? '中' : '低',
|
||||
action: priority === 'high' ? '立即复盘' : priority === 'medium' ? '去复盘' : '查看 ›',
|
||||
lowCount: lowCountOf(record),
|
||||
displayName: displayNameOf(record)
|
||||
};
|
||||
}));
|
||||
|
||||
const displayedReviews = computed<ReviewRow[]>(() => {
|
||||
let list = rows.value;
|
||||
if (activeFilter.value === 'below60') {
|
||||
list = list.filter((item) => item.record.score < 60);
|
||||
} else if (activeFilter.value === 'repeatLow') {
|
||||
list = list.filter((item) => item.lowCount >= 2);
|
||||
}
|
||||
return [...list].sort((a, b) => sortMode.value === 'score'
|
||||
? a.record.score - b.record.score
|
||||
: b.record.time.localeCompare(a.record.time));
|
||||
});
|
||||
|
||||
const clearData = () => {
|
||||
reviews.value = [];
|
||||
alerts.value = [];
|
||||
memberNames.value = {};
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
@@ -122,13 +225,19 @@ const loadPage = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [nextReviews, nextAlerts] = await Promise.all([
|
||||
const [nextReviews, nextAlerts, snapshot] = await Promise.all([
|
||||
getPracticeReviews(),
|
||||
getPracticeAlerts()
|
||||
getPracticeAlerts(),
|
||||
getPracticeTeamSnapshot().catch(() => ({ members: [], records: [], assignments: [], mistakes: [] }))
|
||||
]);
|
||||
accessDenied.value = false;
|
||||
reviews.value = nextReviews;
|
||||
alerts.value = nextAlerts;
|
||||
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) {
|
||||
handleRequestError(error, '待复盘列表加载失败');
|
||||
} finally {
|
||||
@@ -136,6 +245,10 @@ const loadPage = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSort = () => {
|
||||
sortMode.value = sortMode.value === 'score' ? 'time' : 'score';
|
||||
};
|
||||
|
||||
const openReview = (item: PracticeRecord) => {
|
||||
if (!item.id) {
|
||||
message.value = '该记录缺少复盘ID';
|
||||
@@ -153,6 +266,10 @@ const goToEmployee = () => {
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
};
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(currentPath);
|
||||
@@ -178,32 +295,62 @@ onShow(() => {
|
||||
|
||||
<style scoped>
|
||||
.reviews-page {
|
||||
padding-bottom: 112px;
|
||||
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
|
||||
padding: 22px 20px 112px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.section-head {
|
||||
.refresh-icon {
|
||||
display: flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
color: #666a72;
|
||||
box-shadow: 0 2px 8px rgba(24, 28, 36, 0.06);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.refresh-icon::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.refresh-icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.refresh-icon[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reviews-page .panel {
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-width: 78px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
@@ -211,76 +358,317 @@ onShow(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
margin-top: 8px;
|
||||
.supervisor-access-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.review-row {
|
||||
.supervisor-access-copy {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.supervisor-access-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 筛选与排序 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 17px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
color: #666a72;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.review-row:last-child {
|
||||
border-bottom: 1px solid #e5edf7;
|
||||
.filter-tab::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.review-copy {
|
||||
.filter-tab.active {
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sort-toggle {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666a72;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sort-toggle::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sort-caret {
|
||||
color: #a0a3aa;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 复盘卡片 */
|
||||
.review-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.review-title,
|
||||
.review-desc,
|
||||
.review-status {
|
||||
display: block;
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-title {
|
||||
color: #0f172a;
|
||||
.priority-badge {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.priority-badge.high {
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.priority-badge.medium {
|
||||
background: #fef0c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.priority-badge.low {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #25282d;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-desc {
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-status {
|
||||
margin-top: 6px;
|
||||
color: #c2410c;
|
||||
.status-tag {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 8px;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #e60012;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta-time {
|
||||
color: #8a8d93;
|
||||
}
|
||||
|
||||
.meta-warning {
|
||||
color: #e60012;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-normal {
|
||||
color: #8a8d93;
|
||||
}
|
||||
|
||||
.meta-time + .meta-warning::before,
|
||||
.meta-time + .meta-normal::before {
|
||||
content: '· ';
|
||||
color: #c0c3c9;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 7px;
|
||||
color: #666a72;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-side {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.big-score {
|
||||
color: #e60012;
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-score {
|
||||
.card-action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.card-action.high {
|
||||
border: 0;
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.card-action.medium {
|
||||
border: 1px solid #e3e5e9;
|
||||
background: #ffffff;
|
||||
color: #3a3d44;
|
||||
}
|
||||
|
||||
.card-action.low {
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666a72;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.state-panel {
|
||||
display: flex;
|
||||
min-width: 52px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
padding: 44px 24px 36px;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.review-score text:first-child {
|
||||
color: var(--employee-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
.state-icon {
|
||||
display: flex;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f8f8f9;
|
||||
color: #c0c3c9;
|
||||
}
|
||||
|
||||
.state-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
margin-top: 14px;
|
||||
color: #25282d;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
margin-top: 6px;
|
||||
color: #8a8d93;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.state-button {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 18px 0 0;
|
||||
padding: 0 22px;
|
||||
border: 1px solid #e60012;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
color: #e60012;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.state-button::after {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user