gwy-exam/client/src/views/practice/PracticeView.vue

739 lines
23 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, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dashboardApi, practiceApi, reviewApi, type ReviewListItem } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { usePracticeStore } from '../../stores/practice'
import { useAppStore } from '../../stores/app'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import EssayView from './EssayView.vue'
import { formatPercent, formatDateShort } from '../../utils/format'
import type { IconName } from '../../components/base/icons'
const route = useRoute()
const router = useRouter()
const { isMobile } = useResponsive()
const practice = usePracticeStore()
const app = useAppStore()
/** 当前子 tab刷题 / 错题本 / 申论 */
const tab = computed(() => {
if (route.name === 'practice-wrong') return 'wrong'
if (route.name === 'practice-essay') return 'essay'
return 'practice'
})
const DURATIONS = [5, 10, 15] as const
const modules = useRequest(() => practiceApi.modules())
const mastery = useRequest(() => dashboardApi.mastery())
const wrongReasons = useRequest(() => practiceApi.wrongReasons())
const overview = useRequest(() => dashboardApi.overview())
// 错题本:页码加 1 表示当前选中的状态/模块筛选已生效
const wrongQuery = reactive<{ status: WrongStatus; module: WrongModule | '' }>({ status: '', module: '' })
const reviewList = useRequest(() =>
reviewApi.list({
status: wrongQuery.status || undefined,
module: wrongQuery.module || undefined,
page: 1,
pageSize: 50
})
)
onMounted(() => {
// 错题本页默认拉取待复习 + 全部
if (tab.value === 'wrong') void reviewList.refresh()
})
const selectModule = ref<'' | '言语理解' | '数量关系' | '判断推理' | '资料分析' | '常识判断'>('')
const selectDuration = ref<5 | 10 | 15>(15)
const starting = ref(false)
const masteryByModule = computed(() => {
const map = new Map<string, number>()
for (const item of mastery.data.value?.mastery ?? []) map.set(item.module, item.accuracy)
return map
})
const moduleTotal = computed(() => {
const map = new Map<string, number>()
for (const item of modules.data.value?.modules ?? []) map.set(item.name, item.total)
return map
})
const activeModule = computed(() => {
const m = selectModule.value || (modules.data.value?.modules[0]?.name ?? '')
return m
})
const recommendText = computed(() => {
const m = activeModule.value
return `开始刷题 · ${selectDuration.value}分钟 · ${m || '请选择模块'}`
})
const reasons = computed(() => wrongReasons.data.value?.reasons ?? [])
const pendingReview = computed(() => reviewList.data.value?.items ?? [])
// ---- 错题本筛选 ----
type WrongModule = '言语理解' | '数量关系' | '判断推理' | '资料分析' | '常识判断'
type WrongStatus = '' | 'pending' | 'reviewing' | 'mastered'
const wrongModuleList: WrongModule[] = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断']
const wrongStatusTabs: { key: WrongStatus; label: string }[] = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待复习' },
{ key: 'reviewing', label: '复习中' },
{ key: 'mastered', label: '已掌握' }
]
const wrongModuleTabs: { key: WrongModule | ''; label: string }[] = [
{ key: '', label: '全部模块' },
...wrongModuleList.map((m) => ({ key: m, label: m }))
]
const wrongEmptyTitle = computed(() => {
if (wrongQuery.status) return '没有该状态的错题'
if (wrongQuery.module) return '该模块暂无错题'
return '暂无错题'
})
function setWrongStatus(key: WrongStatus) {
wrongQuery.status = key
void reviewList.refresh()
}
function setWrongModule(key: WrongModule | '') {
wrongQuery.module = key
void reviewList.refresh()
}
function wrongStatusLabel(status: ReviewListItem['status']) {
return status === 'mastered' ? '已掌握' : status === 'reviewing' ? '复习中' : '待复习'
}
function wrongStatusVariant(status: ReviewListItem['status']) {
return status === 'mastered' ? 'success' : status === 'reviewing' ? 'warning' : 'danger'
}
const startDisabled = computed(() => starting.value || !activeModule.value)
async function startPractice() {
if (!activeModule.value || starting.value) return
starting.value = true
try {
const session = await practiceApi.start({ module: activeModule.value, duration: selectDuration.value })
practice.start({
sessionId: session.sessionId,
module: session.module,
durationMinutes: session.durationMinutes,
total: session.total
})
router.push({ name: 'practice-session', params: { id: session.sessionId } })
} catch (err) {
app.toast(err instanceof Error ? err.message : '组卷失败,请稍后重试', 'error')
} finally {
starting.value = false
}
}
function goCustom() {
router.push({ name: 'practice-custom' })
}
const tabs = [
{ key: 'practice', label: '刷题', to: '/practice' },
{ key: 'wrong', label: '错题本', to: '/practice/wrong' },
{ key: 'essay', label: '申论', to: '/practice/essay' }
] as const
const backAction: Record<string, string> = { practice: '', wrong: '', essay: '' }
const subtitle = computed(() => {
if (tab.value === 'wrong') return '答错自动沉淀,按间隔复习'
if (tab.value === 'essay') return '申论功能即将上线'
return '选择模块,碎片时间刷题'
})
</script>
<template>
<div class="practice">
<AppPageHeader title="刷题中心" :subtitle="subtitle" :stack-mobile="true">
<template v-if="tab === 'practice'">
<AppButton variant="secondary" icon="plus" @click="goCustom">自定义组卷</AppButton>
<AppButton icon="pen" :loading="starting" :disabled="startDisabled" @click="startPractice">
开始刷题
</AppButton>
</template>
</AppPageHeader>
<!-- 子 tab -->
<div class="practice-tabs">
<RouterLink
v-for="t in tabs"
:key="t.key"
:to="t.to"
class="practice-tab"
:class="{ 'practice-tab--active': tab === t.key }"
>
{{ t.label }}
</RouterLink>
</div>
<!-- ============ 刷题 tab ============ -->
<template v-if="tab === 'practice'">
<AppLoading v-if="modules.loading.value" fullscreen />
<AppError
v-else-if="modules.error.value"
fullscreen
title="加载失败"
:message="modules.error.value"
retry
@retry="modules.refresh"
/>
<template v-else>
<!-- 选择模块 -->
<section class="practice-section">
<h2 class="practice-section__title">选择模块</h2>
<div class="module-grid">
<button
v-for="m in modules.data.value?.modules ?? []"
:key="m.name"
type="button"
class="module-card"
:class="{ 'module-card--active': activeModule === m.name }"
@click="selectModule = m.name"
>
<div class="module-card__row">
<span class="module-card__name">{{ m.name }}</span>
<span v-if="activeModule === m.name" class="module-card__check">
<AppIcon name="check" :size="14" />
</span>
</div>
<div class="module-card__meta">
<span class="module-card__count">{{ m.total }} 题</span>
<span class="module-card__dot">·</span>
<span
class="module-card__mastery"
:class="{ 'module-card__mastery--low': (masteryByModule.get(m.name) ?? 0) < 60 }"
>
掌握度 {{ formatPercent(masteryByModule.get(m.name) ?? 0) }}
</span>
</div>
</button>
</div>
<AppEmpty v-if="(modules.data.value?.modules.length ?? 0) === 0" title="还没有题目" description="请先到题库管理导入题目,再开始刷题。">
<AppButton variant="secondary" @click="router.push('/questions')">去导入题目</AppButton>
</AppEmpty>
</section>
<!-- 碎片组卷时长 -->
<section class="practice-section">
<h2 class="practice-section__title">碎片组卷时长</h2>
<div class="duration-grid">
<button
v-for="d in DURATIONS"
:key="d"
type="button"
class="duration-card"
:class="{ 'duration-card--active': selectDuration === d }"
@click="selectDuration = d"
>
<span class="duration-card__value">{{ d }}分钟</span>
<span class="duration-card__sub">约 {{ d }} 题</span>
</button>
</div>
<!-- 开始刷题大按钮 -->
<AppButton block size="lg" icon="pen" :loading="starting" :disabled="startDisabled" @click="startPractice">
{{ recommendText }}
</AppButton>
</section>
<!-- 错因分布 -->
<AppCard title="错因分布 · 最近 30 天" icon="chart">
<AppEmpty v-if="reasons.length === 0" icon="chart" title="暂无错因数据" description="完成刷题后根据错题统计错因分布。" />
<div v-else class="reason-bars">
<div v-for="r in reasons" :key="r.reason" class="reason-bar">
<div class="reason-bar__row">
<span class="reason-bar__label">{{ r.reason }}</span>
<span class="reason-bar__pct">{{ formatPercent(r.percent) }}</span>
</div>
<div class="reason-bar__track">
<div class="reason-bar__fill" :style="{ width: `${r.percent}%` }" />
</div>
</div>
</div>
</AppCard>
<!-- 待复习错题 + 推荐动作 -->
<div class="practice-bottom">
<AppCard title="待复习错题" icon="alert">
<AppEmpty v-if="pendingReview.length === 0" icon="alert" title="没有待复习的错题" description="完成刷题后答错的题会自动进入错题本。" />
<div v-else class="pending-list">
<RouterLink
v-for="item in pendingReview"
:key="item.id"
class="pending-item"
:to="{ name: 'practice-wrong' }"
>
<div class="pending-item__meta">
<span class="pending-item__module">{{ item.module }}</span>
<span class="pending-item__sub">{{ item.subModule }}</span>
</div>
<div class="pending-item__text">{{ item.stem }}</div>
<div class="pending-item__foot">
<span class="pending-item__reason">{{ item.wrongReason }}</span>
<span class="pending-item__count">已复习 {{ item.reviewCount }} 次</span>
</div>
</RouterLink>
</div>
</AppCard>
<AppCard title="推荐动作" icon="target">
<div class="recommend-list">
<div class="recommend-item">
<span class="recommend-item__label">抽做 {{ moduleTotal.get('判断推理') ?? 0 }} 道错题</span>
<span class="recommend-item__desc">复盘思路 · 建议 15 分钟</span>
</div>
<div class="recommend-item">
<span class="recommend-item__label">专项突破 数量关系</span>
<span class="recommend-item__desc">薄弱考点 · 建议 15 分钟</span>
</div>
<div class="recommend-item">
<span class="recommend-item__label">练习 言语理解</span>
<span class="recommend-item__desc">保持手感 · 建议 10 分钟</span>
</div>
</div>
</AppCard>
</div>
</template>
</template>
<!-- ============ 错题本 tab ============ -->
<template v-else-if="tab === 'wrong'">
<AppLoading v-if="reviewList.loading.value" fullscreen />
<AppError
v-else-if="reviewList.error.value"
fullscreen
title="加载失败"
:message="reviewList.error.value"
retry
@retry="reviewList.refresh"
/>
<template v-else>
<!-- 筛选行 -->
<div class="wrong-filter">
<div class="wrong-filter__group">
<span class="wrong-filter__label">状态</span>
<button
v-for="s in wrongStatusTabs"
:key="s.key"
type="button"
class="wrong-filter__chip"
:class="{ 'wrong-filter__chip--active': wrongQuery.status === s.key }"
@click="setWrongStatus(s.key)"
>
{{ s.label }}
</button>
</div>
<div class="wrong-filter__group">
<span class="wrong-filter__label">模块</span>
<button
v-for="m in wrongModuleTabs"
:key="m.key"
type="button"
class="wrong-filter__chip"
:class="{ 'wrong-filter__chip--active': wrongQuery.module === m.key }"
@click="setWrongModule(m.key)"
>
{{ m.label }}
</button>
</div>
</div>
<!-- 列表 -->
<AppCard v-if="reviewList.data.value?.items.length === 0" :padded="true">
<AppEmpty
icon="book-open"
:title="wrongEmptyTitle"
description="答错的题会自动收藏到错题本,按 +1/+3/+7 天安排复习。"
/>
</AppCard>
<div v-else class="wrong-book-list">
<RouterLink
v-for="item in reviewList.data.value?.items ?? []"
:key="item.id"
class="wrong-book-item"
:to="{ name: 'practice-wrong-detail', params: { id: item.id } }"
>
<div class="wrong-book-item__head">
<div class="wrong-book-item__tags">
<AppBadge variant="primary">{{ item.module }}</AppBadge>
<AppBadge v-if="item.subModule" variant="soft">{{ item.subModule }}</AppBadge>
</div>
<AppBadge :variant="wrongStatusVariant(item.status)">{{ wrongStatusLabel(item.status) }}</AppBadge>
</div>
<p class="wrong-book-item__stem">{{ item.stem }}</p>
<div class="wrong-book-item__foot">
<span v-if="item.wrongReason" class="wrong-book-item__reason">错因 · {{ item.wrongReason }}</span>
<span class="wrong-book-item__count">已复习 {{ item.reviewCount }} </span>
<span class="wrong-book-item__date">{{ formatDateShort(item.nextReviewAt) }} 复习</span>
</div>
</RouterLink>
</div>
</template>
</template>
<!-- ============ 申论 tab ============ -->
<template v-else>
<EssayView />
</template>
</div>
</template>
<style scoped>
.practice {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 子 tab */
.practice-tabs {
display: flex;
gap: var(--space-2);
background: var(--bg-soft);
border-radius: var(--radius-md);
padding: 4px;
}
.practice-tab {
flex: 1;
text-align: center;
padding: var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--fs-button);
font-weight: var(--fw-button);
color: var(--text-secondary);
text-decoration: none;
transition: background var(--motion-tab-fade) var(--ease-default), color var(--motion-tab-fade) var(--ease-default);
}
.practice-tab--active {
background: var(--primary);
color: var(--on-accent);
}
/* section */
.practice-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.practice-section__title {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
/* 模块卡 */
.module-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--card-gap-desktop);
}
.module-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--card-padding-desktop);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color var(--motion-hover-fast) var(--ease-default), box-shadow var(--motion-card-hover) var(--ease-default);
text-align: left;
}
.module-card:hover {
box-shadow: var(--shadow-level-1);
}
.module-card--active {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 14%, transparent);
}
.module-card__row {
display: flex;
align-items: center;
justify-content: space-between;
}
.module-card__name {
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.module-card__check {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary);
color: var(--on-accent);
}
.module-card__meta {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--fs-label);
color: var(--text-secondary);
}
.module-card__mastery--low {
color: var(--danger);
}
/* 时长卡 */
.duration-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--card-gap-desktop);
}
.duration-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-4) var(--space-3);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--motion-hover-fast) var(--ease-default), background var(--motion-hover-fast) var(--ease-default);
}
.duration-card--active {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, #fff);
}
.duration-card__value {
font-family: var(--font-num);
font-size: var(--fs-card-title);
font-weight: 700;
color: var(--text-primary);
}
.duration-card__sub {
font-size: var(--fs-label);
color: var(--text-secondary);
}
/* 错因分布条 */
.reason-bars {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.reason-bar__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-1);
font-size: var(--fs-subtitle);
}
.reason-bar__label {
color: var(--text-secondary);
}
.reason-bar__pct {
font-family: var(--font-num);
font-weight: 700;
color: var(--text-primary);
}
.reason-bar__track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.reason-bar__fill {
height: 100%;
border-radius: var(--radius-pill);
background: linear-gradient(90deg, var(--primary-bright), var(--primary));
transition: width var(--motion-card-hover) var(--ease-default);
}
/* 底部两栏 */
.practice-bottom {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--card-gap-desktop);
align-items: start;
}
.pending-list,
.recommend-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.pending-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
text-decoration: none;
padding-block: var(--space-1);
}
.pending-item__meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-label);
}
.pending-item__module {
color: var(--primary);
font-weight: var(--fw-label-bold);
}
.pending-item__sub {
color: var(--text-muted);
}
.pending-item__text {
font-size: var(--fs-body);
color: var(--text-primary);
line-height: var(--lh-body);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pending-item__foot {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.recommend-item {
display: flex;
flex-direction: column;
gap: 2px;
padding-block: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
}
.recommend-item__label {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.recommend-item__desc {
font-size: var(--fs-label);
color: var(--text-secondary);
}
/* 错题本 tab */
.wrong-filter {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.wrong-filter__group {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.wrong-filter__label {
font-size: var(--fs-label);
color: var(--text-secondary);
flex: none;
min-width: 30px;
}
.wrong-filter__chip {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
background: var(--bg-card);
color: var(--text-secondary);
font-size: var(--fs-label);
cursor: pointer;
transition: all var(--motion-hover-fast) var(--ease-default);
}
.wrong-filter__chip--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-accent);
}
.wrong-book-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.wrong-book-item {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--card-padding-desktop);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
text-decoration: none;
transition: border-color var(--motion-hover-fast) var(--ease-default), box-shadow var(--motion-card-hover) var(--ease-default);
}
.wrong-book-item:hover {
border-color: var(--primary);
box-shadow: var(--shadow-level-1);
}
.wrong-book-item__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.wrong-book-item__tags {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.wrong-book-item__stem {
margin: 0;
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wrong-book-item__foot {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--fs-label);
color: var(--text-secondary);
flex-wrap: wrap;
}
.wrong-book-item__reason {
color: var(--warning);
}
.wrong-book-item__count {
margin-left: auto;
}
/* 桌面响应 */
@media (max-width: 1023px) {
.module-grid {
grid-template-columns: repeat(2, 1fr);
}
.practice-bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.module-grid {
grid-template-columns: repeat(2, 1fr);
}
.duration-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>