gwy-exam/client/src/views/mock/MockView.vue

370 lines
12 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, ref } from 'vue'
import { useRouter } from 'vue-router'
import { mockApi, type MockExamAnalysis, type MockExamListItem } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { formatDateShort } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppModal from '../../components/base/AppModal.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppLineChart from '../../components/charts/AppLineChart.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import StatCard from '../../components/dashboard/StatCard.vue'
const router = useRouter()
const { isMobile } = useResponsive()
const app = useAppStore()
const MODULES = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const
const analysis = useRequest<MockExamAnalysis>(() => mockApi.analysis())
const items = computed(() => analysis.data.value?.records ?? [])
const targetScore = computed(() => analysis.data.value?.targetScore ?? 0)
const trend = computed(() =>
(analysis.data.value?.trend ?? []).map((t) => ({ date: t.date, value: t.total }))
)
const moduleAverages = computed(() => analysis.data.value?.moduleAverages ?? [])
const latest = computed(() => analysis.data.value?.latest ?? null)
const targetGap = computed(() => analysis.data.value?.targetGap ?? null)
const changeVsPrevious = computed(() => analysis.data.value?.changeVsPrevious ?? null)
const hasData = computed(() => items.value.length > 0)
const gapText = computed(() => {
if (targetGap.value === null || latest.value === null) return '—'
const gap = targetGap.value
return gap <= 0 ? '已达目标' : `还差 ${gap}`
})
const gapTone = computed(() => {
if (targetGap.value === null) return 'default' as const
return targetGap.value <= 0 ? 'success' as const : 'warning' as const
})
const deltaText = computed(() => {
if (changeVsPrevious.value === null) return '—'
const delta = changeVsPrevious.value
if (delta > 0) return `+${delta}`
return `${delta}`
})
const deltaTone = computed(() => {
if (changeVsPrevious.value === null) return 'default' as const
return changeVsPrevious.value >= 0 ? 'success' as const : 'danger' as const
})
function goEntry() {
router.push('/mock/new')
}
function goEdit(item: MockExamListItem) {
router.push(`/mock/${item.id}/edit`)
}
// ---- 删除确认 ----
const removing = ref<MockExamListItem | null>(null)
const deleting = ref(false)
async function confirmRemove() {
if (!removing.value || deleting.value) return
deleting.value = true
try {
await mockApi.remove(removing.value.id)
app.toast('模考记录已删除', 'success')
removing.value = null
await analysis.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '删除失败', 'error')
} finally {
deleting.value = false
}
}
</script>
<template>
<div class="mock-page">
<AppLoading v-if="analysis.loading.value && !hasData" fullscreen />
<AppError
v-else-if="analysis.error.value"
fullscreen
title="模考数据加载失败"
:message="analysis.error.value"
retry
@retry="analysis.refresh"
/>
<template v-else>
<AppPageHeader title="模考分析" subtitle="行测成绩趋势 · 目标分差 · 模块对比" :stack-mobile="isMobile" sticky-mobile>
<AppButton variant="primary" @click="goEntry">
<AppIcon name="plus" :size="16" />录入成绩
</AppButton>
</AppPageHeader>
<!-- 空状态 -->
<AppCard v-if="!hasData" :padded="true">
<AppEmpty
icon="chart"
title="还没有模考成绩"
description="录入第一场行测模考后,这里会展示成绩趋势与模块分析。"
>
<AppButton variant="primary" @click="goEntry">
<AppIcon name="plus" :size="16" />录入第一场模考
</AppButton>
</AppEmpty>
</AppCard>
<template v-else>
<!-- 最近成绩 / 目标分差 / 较上次变化 -->
<section class="mock-page__stats">
<StatCard
label="最近成绩"
:value="`${latest?.total ?? 0} 分`"
:sub="latest ? `${latest.name} · ${formatDateShort(latest.date)}` : ''"
icon="chart"
/>
<StatCard
label="目标分差"
:value="gapText"
:sub="`目标 ${targetScore} 分`"
icon="target"
:tone="gapTone"
:sub-tone="gapTone"
/>
<StatCard
label="较上次变化"
:value="deltaText"
:sub="items.length >= 2 ? `共 ${items.length} 场` : '不足两场记录'"
icon="line"
:tone="deltaTone"
:sub-tone="deltaTone"
/>
</section>
<!-- 成绩趋势 -->
<AppCard title="行测成绩趋势" icon="chart">
<template #header>
<span v-if="trend.length >= 2" class="mock-page__tag"> {{ trend.length }} </span>
</template>
<p class="mock-page__sub">总分趋势 · 目标 {{ targetScore }} 虚线</p>
<AppLineChart :points="trend" :target="targetScore" />
</AppCard>
<!-- 模块对比 -->
<AppCard title="模块均分对比" icon="chart">
<ul class="mock-page__modules">
<li v-for="m in moduleAverages" :key="m.module" class="mock-page__module">
<div class="mock-page__module-head">
<span class="mock-page__module-name">{{ m.module }}</span>
<strong class="mock-page__module-value">{{ m.average }}<small> </small></strong>
</div>
<div class="mock-page__module-track">
<span class="mock-page__module-fill" :style="{ width: `${Math.min(100, m.average)}%` }" />
</div>
</li>
</ul>
</AppCard>
<!-- 模考记录 -->
<AppCard title="模考记录" icon="calendar">
<ul class="mock-page__records">
<li v-for="r in items" :key="r.id" class="mock-page__record">
<div class="mock-page__record-main">
<div class="mock-page__record-head">
<strong class="mock-page__record-name">{{ r.name }}</strong>
<span class="mock-page__record-date">{{ formatDateShort(r.date) }}</span>
</div>
<div class="mock-page__record-meta">
<AppBadge variant="primary">{{ r.total }} </AppBadge>
<span v-for="m in MODULES" :key="m" class="mock-page__record-module">
{{ m.slice(0, 2) }} {{ r.modules[m] }}
</span>
</div>
<p v-if="r.note" class="mock-page__record-note">{{ r.note }}</p>
</div>
<div class="mock-page__record-actions">
<AppButton variant="ghost" size="md" @click="goEdit(r)">
<AppIcon name="pen" :size="15" />编辑
</AppButton>
<AppButton variant="ghost" size="md" class="is-danger" @click="removing = r">
<AppIcon name="close" :size="15" />删除
</AppButton>
</div>
</li>
</ul>
</AppCard>
</template>
</template>
<!-- 删除确认 -->
<AppModal v-if="removing" title="删除模考记录" :max-width="440" @close="removing = null">
<p class="mock-page__confirm">
确定要删除{{ removing.name }}{{ formatDateShort(removing.date) }}{{ removing.total }} 删除后趋势与模块分析将同步更新且不可恢复
</p>
<template #footer>
<div class="mock-page__confirm-actions">
<AppButton variant="ghost" @click="removing = null">取消</AppButton>
<AppButton variant="danger" :loading="deleting" @click="confirmRemove">
{{ deleting ? '删除中' : '确认删除' }}
</AppButton>
</div>
</template>
</AppModal>
</div>
</template>
<style scoped>
.mock-page {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.mock-page__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.mock-page__sub {
margin: 0 0 var(--space-3);
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.mock-page__tag {
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--success);
background: var(--success-soft);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
}
.mock-page__modules {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.mock-page__module-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.mock-page__module-name {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.mock-page__module-value {
font-family: var(--font-num);
font-size: var(--fs-card-title);
color: var(--text-primary);
}
.mock-page__module-value small {
font-size: var(--fs-label);
color: var(--text-muted);
font-weight: normal;
}
.mock-page__module-track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.mock-page__module-fill {
display: block;
height: 100%;
border-radius: var(--radius-pill);
background: var(--primary);
}
.mock-page__records {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.mock-page__record {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) 0;
border-bottom: 1px solid var(--border-subtle);
}
.mock-page__record:last-child {
border-bottom: none;
padding-bottom: 0;
}
.mock-page__record:first-child {
padding-top: 0;
}
.mock-page__record-main {
min-width: 0;
flex: 1;
}
.mock-page__record-head {
display: flex;
align-items: baseline;
gap: var(--space-2);
flex-wrap: wrap;
}
.mock-page__record-name {
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.mock-page__record-date {
font-size: var(--fs-label);
color: var(--text-muted);
}
.mock-page__record-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.mock-page__record-module {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.mock-page__record-note {
margin: var(--space-2) 0 0;
font-size: var(--fs-label);
color: var(--text-muted);
}
.mock-page__record-actions {
display: flex;
gap: var(--space-1);
flex: none;
}
.mock-page__record-actions .is-danger {
color: var(--danger);
}
.mock-page__confirm {
margin: 0;
font-size: var(--fs-subtitle);
line-height: var(--lh-subtitle);
color: var(--text-primary);
}
.mock-page__confirm-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
@media (max-width: 767px) {
.mock-page__stats {
grid-template-columns: 1fr;
}
.mock-page__record {
flex-direction: column;
}
}
</style>