370 lines
12 KiB
Vue
370 lines
12 KiB
Vue
<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>
|