gwy-exam/client/src/views/plan/PlanReviewView.vue

277 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>