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
+6
View File
@@ -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
+468 -80
View File
@@ -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>