277 lines
7.7 KiB
Vue
277 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { plansApi } from '../../api'
|
||
import { useRequest } from '../../composables/useRequest'
|
||
import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format'
|
||
import AppCard from '../../components/base/AppCard.vue'
|
||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||
import AppStack from '../../components/base/AppStack.vue'
|
||
import AppError from '../../components/feedback/AppError.vue'
|
||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||
|
||
const review = useRequest(() => plansApi.review())
|
||
|
||
const data = computed(() => review.data.value)
|
||
|
||
// 本周总览(顶部淡去的大字)
|
||
const answered = computed(() => data.value?.answered ?? 0)
|
||
const accuracy = computed(() => data.value?.accuracy ?? 0)
|
||
|
||
// 错因分布色(对应设计语义色)
|
||
const reasonTone = ['danger', 'warning', 'primary']
|
||
const reasonBarColor = ['var(--danger)', 'var(--warning)', 'var(--primary)', 'var(--text-muted)']
|
||
const reasonColor = (i: number) => reasonBarColor[i % reasonBarColor.length]
|
||
|
||
const totalWeakCount = computed(() =>
|
||
(data.value?.weakPoints ?? []).reduce((sum, w) => sum + w.count, 0)
|
||
)
|
||
const weakCount = (w: { count: number }) => (totalWeakCount.value === 0 ? 0 : Math.round((w.count / totalWeakCount.value) * 100))
|
||
|
||
const weakTone = ['danger', 'warning', 'primary']
|
||
</script>
|
||
|
||
<template>
|
||
<AppStack class="review">
|
||
<AppLoading v-if="review.loading.value" fullscreen />
|
||
<AppError
|
||
v-else-if="review.error.value"
|
||
fullscreen
|
||
title="周报加载失败"
|
||
:message="review.error.value"
|
||
retry
|
||
@retry="review.refresh"
|
||
/>
|
||
|
||
<template v-else-if="data">
|
||
<!-- 页头 -->
|
||
<AppPageHeader back sticky-mobile>
|
||
<template #title><h1 class="review__title">周报复盘</h1></template>
|
||
<span class="review__export">本周小结</span>
|
||
</AppPageHeader>
|
||
|
||
<!-- 周区间 + 汇总 -->
|
||
<section class="review-hero">
|
||
<p class="review-hero__range">
|
||
第 {{ data.weekNumber }} 周复盘({{ formatDateShort(data.weekStart) }} - {{ formatDateShort(data.weekEnd) }})
|
||
</p>
|
||
<h2 class="review-hero__stats">
|
||
本周刷题 {{ answered }} 题 · 正确率 {{ accuracy }}%
|
||
</h2>
|
||
<p class="review-hero__sub">
|
||
学习{{ formatMinutes(data.studyMinutes) }} · 完成任务 {{ data.taskDone }}/{{ data.taskTotal }} · 连续打卡 {{ data.streakDays }} 天
|
||
</p>
|
||
</section>
|
||
|
||
<!-- 本周薄弱考点 Top 3 -->
|
||
<AppCard title="本周薄弱考点 Top 3" icon="chart">
|
||
<AppEmpty
|
||
v-if="(data.weakPoints ?? []).length === 0"
|
||
icon="chart"
|
||
title="本周没有明显薄弱点"
|
||
description="继续保持每日刷题节奏,稳步提升正确率。"
|
||
/>
|
||
<div v-else class="weak-list">
|
||
<AppStack direction="row" gap="space-3" class="weak-item" v-for="(w, i) in data.weakPoints" :key="`${w.module}-${w.point}`">
|
||
<span class="weak-item__rank" :class="`is-${weakTone[i % weakTone.length]}`">{{ i + 1 }}</span>
|
||
<div class="weak-item__body">
|
||
<p class="weak-item__title">{{ w.module }} · {{ w.point }}</p>
|
||
<p class="weak-item__meta">错 {{ w.count }} 次 · 正确率 {{ w.accuracy }}%</p>
|
||
<div class="progress">
|
||
<div class="progress__bar" :style="{ width: `${weakCount(w)}%` }" />
|
||
</div>
|
||
</div>
|
||
<span class="weak-item__tag">{{ w.reasons[0] || '待改进' }}</span>
|
||
</AppStack>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<!-- 错因分布 -->
|
||
<AppCard title="错因分布" icon="book">
|
||
<AppEmpty v-if="(data.wrongReasons ?? []).length === 0" icon="book" title="本周暂无错因记录" />
|
||
<ul v-else class="reason-list">
|
||
<li v-for="(r, i) in data.wrongReasons" :key="r.reason" class="reason-item">
|
||
<span class="reason-item__label">{{ r.reason }}</span>
|
||
<div class="reason-item__track">
|
||
<div class="reason-item__bar" :style="{ width: `${Math.min(100, r.count * 12)}%`, background: reasonColor(i) }" />
|
||
</div>
|
||
<span class="reason-item__value">{{ r.count }}</span>
|
||
</li>
|
||
</ul>
|
||
</AppCard>
|
||
|
||
<!-- 下周建议 -->
|
||
<AppCard title="下周建议" icon="target">
|
||
<AppEmpty v-if="(data.suggestions ?? []).length === 0" icon="target" title="暂无专项建议" />
|
||
<ul v-else class="suggest-list">
|
||
<li v-for="(s, i) in data.suggestions" :key="i">{{ s }}</li>
|
||
</ul>
|
||
</AppCard>
|
||
</template>
|
||
</AppStack>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.review__title {
|
||
margin: 0;
|
||
font-size: var(--fs-h1);
|
||
font-weight: var(--fw-h1);
|
||
color: var(--text-primary);
|
||
}
|
||
.review__export {
|
||
font-size: var(--fs-label);
|
||
color: var(--text-muted);
|
||
}
|
||
.review-hero {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-1);
|
||
}
|
||
.review-hero__range {
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-secondary);
|
||
}
|
||
.review-hero__stats {
|
||
margin: var(--space-2) 0 0;
|
||
font-size: 22px;
|
||
font-weight: var(--fw-h1);
|
||
color: var(--text-primary);
|
||
}
|
||
.review-hero__sub {
|
||
margin: var(--space-1) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.weak-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
}
|
||
.weak-item {
|
||
--stack-align: flex-start;
|
||
}
|
||
.weak-item__rank {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
flex: none;
|
||
border-radius: 50%;
|
||
color: #fff;
|
||
font-family: var(--font-num);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.weak-item__rank.is-danger {
|
||
background: var(--danger);
|
||
}
|
||
.weak-item__rank.is-warning {
|
||
background: var(--warning);
|
||
}
|
||
.weak-item__rank.is-primary {
|
||
background: var(--primary-bright);
|
||
}
|
||
.weak-item__body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.weak-item__title {
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--text-primary);
|
||
}
|
||
.weak-item__meta {
|
||
margin: 2px 0 var(--space-2);
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
.weak-item__tag {
|
||
flex: none;
|
||
padding: 4px var(--space-2);
|
||
border-radius: var(--radius-pill);
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-label-bold);
|
||
}
|
||
.progress {
|
||
height: 6px;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--bg-soft);
|
||
overflow: hidden;
|
||
}
|
||
.progress__bar {
|
||
height: 100%;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--primary);
|
||
}
|
||
|
||
.reason-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-4);
|
||
}
|
||
.reason-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
}
|
||
.reason-item__label {
|
||
width: 96px;
|
||
flex: none;
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
}
|
||
.reason-item__track {
|
||
flex: 1;
|
||
height: 8px;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--bg-soft);
|
||
overflow: hidden;
|
||
}
|
||
.reason-item__bar {
|
||
height: 100%;
|
||
border-radius: var(--radius-pill);
|
||
}
|
||
.reason-item__value {
|
||
width: 24px;
|
||
flex: none;
|
||
text-align: right;
|
||
font-family: var(--font-num);
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.suggest-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
.suggest-list li {
|
||
position: relative;
|
||
padding-left: var(--space-5);
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
.suggest-list li::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 8px;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--primary);
|
||
}
|
||
</style>
|