feat: 完成模考分析闭环

This commit is contained in:
liyy 2026-09-01 16:57:33 +08:00
parent 09b0045d3f
commit 32b5355ab3
10 changed files with 921 additions and 22 deletions

View File

@ -117,9 +117,16 @@ export const plansApi = {
export const mockApi = {
analysis: () => unwrap(apiClient.GET('/api/mock-exams/analysis')),
record: (body: BodyOf<'/api/mock-exams/record', 'post'>) =>
unwrap(apiClient.POST('/api/mock-exams/record', { body }))
unwrap(apiClient.POST('/api/mock-exams/record', { body })),
update: (id: string, body: BodyOf<'/api/mock-exams/{id}/update', 'post'>) =>
unwrap(apiClient.POST('/api/mock-exams/{id}/update', { params: { path: { id } }, body })),
remove: (id: string) => unwrap(apiClient.DELETE('/api/mock-exams/{id}', { params: { path: { id } } }))
}
export type MockExamRecord = SuccessBody<paths['/api/mock-exams/record']['post']>
export type MockExamAnalysis = SuccessBody<paths['/api/mock-exams/analysis']['get']>
export type MockExamListItem = MockExamAnalysis['records'][number]
// ---- 要闻 ----
export const newsApi = {
list: (query?: QueryOf<'/api/news/list', 'get'>) =>

View File

@ -24,6 +24,7 @@ export type IconName =
| 'refresh'
| 'book-open'
| 'filter'
| 'line'
interface IconNode {
tag: 'path' | 'circle' | 'rect' | 'line' | 'polyline'
@ -52,6 +53,10 @@ export const icons: Record<IconName, IconNode[]> = {
{ tag: 'path', attrs: { d: 'M13 17V5' } },
{ tag: 'path', attrs: { d: 'M8 17v-3' } }
],
line: [
{ tag: 'polyline', attrs: { points: '3 17 8 12 12 15 21 6' } },
{ tag: 'path', attrs: { d: 'M16 6h5v5' } }
],
calendar: [
{ tag: 'rect', attrs: { x: 3, y: 4, width: 18, height: 18, rx: 2 } },
{ tag: 'line', attrs: { x1: 16, y1: 2, x2: 16, y2: 6 } },

View File

@ -6,6 +6,7 @@ import AnswerView from '../views/practice/AnswerView.vue'
import CustomView from '../views/practice/CustomView.vue'
import WrongDetailView from '../views/practice/WrongDetailView.vue'
import MockView from '../views/mock/MockView.vue'
import MockFormView from '../views/mock/MockFormView.vue'
import PlanView from '../views/plan/PlanView.vue'
import PlanReviewView from '../views/plan/PlanReviewView.vue'
import NewsView from '../views/news/NewsView.vue'
@ -23,6 +24,8 @@ export const routes: RouteRecordRaw[] = [
{ path: '/practice/wrong/:id', name: 'practice-wrong-detail', component: WrongDetailView, meta: { title: '错题详情' } },
{ path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } },
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
{ path: '/mock/new', name: 'mock-new', component: MockFormView, meta: { title: '录入模考成绩' } },
{ path: '/mock/:id/edit', name: 'mock-edit', component: MockFormView, meta: { title: '修改模考成绩' } },
{ path: '/plan', name: 'plan', component: PlanView, meta: { title: '备考计划' } },
{ path: '/plan/review', name: 'plan-review', component: PlanReviewView, meta: { title: '周报复盘' } },
{ path: '/news', name: 'news', component: NewsView, meta: { title: '要闻' } },

View File

@ -0,0 +1,315 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { mockApi, type MockExamAnalysis } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useAppStore } from '../../stores/app'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const MODULES = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const
type ModuleKey = (typeof MODULES)[number]
const editId = computed(() => (route.params.id as string | undefined) ?? '')
const isEdit = computed(() => Boolean(editId.value))
const title = computed(() => (isEdit.value ? '修改模考成绩' : '录入模考成绩'))
const analysis = useRequest<MockExamAnalysis>(() => mockApi.analysis(), isEdit.value)
const form = reactive<{
name: string
date: string
modules: Record<ModuleKey, number>
note: string
}>({
name: '',
date: '',
modules: {
言语理解: 0,
数量关系: 0,
判断推理: 0,
资料分析: 0,
常识判断: 0
},
note: ''
})
const saving = ref(false)
const submitted = ref(false)
onMounted(() => {
if (!isEdit.value) {
//
if (!form.date) form.date = new Date().toISOString().slice(0, 10)
}
})
const sourceError = computed(() => analysis.error.value ?? '')
const total = computed(() => MODULES.reduce((sum, m) => sum + (Number(form.modules[m]) || 0), 0))
const totalOverflow = computed(() => total.value > 100)
const nameError = computed(() => submitted.value && !form.name.trim() ? '请填写模考名称' : '')
const dateError = computed(() => submitted.value && !form.date ? '请选择模考日期' : '')
function patchModule(m: ModuleKey, value: string) {
const n = Number(value)
form.modules[m] = Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
}
async function submit() {
submitted.value = true
if (nameError.value || dateError.value || totalOverflow.value) return
if (saving.value) return
saving.value = true
const body = {
name: form.name.trim(),
date: form.date,
total: total.value,
言语理解: form.modules['言语理解'],
数量关系: form.modules['数量关系'],
判断推理: form.modules['判断推理'],
资料分析: form.modules['资料分析'],
常识判断: form.modules['常识判断'],
note: form.note.trim() || undefined
}
try {
if (isEdit.value) {
await mockApi.update(editId.value, body)
app.toast('模考成绩已更新', 'success')
} else {
await mockApi.record(body)
app.toast('模考成绩已录入', 'success')
}
router.push('/mock')
} catch (err) {
app.toast(err instanceof Error ? err.message : '保存失败', 'error')
} finally {
saving.value = false
}
}
function back() {
router.push('/mock')
}
//
const loaded = computed(() => {
if (!isEdit.value || !analysis.data.value) return null
return analysis.data.value.records.find((r) => r.id === editId.value) ?? null
})
const formReady = computed(() => {
if (!isEdit.value) return true
return !analysis.loading.value
})
//
watch(
() => analysis.data.value,
() => {
const record = loaded.value
if (!isEdit.value || !record) return
form.name = record.name
form.date = record.date
for (const m of MODULES) form.modules[m] = record.modules[m]
form.note = record.note ?? ''
},
{ immediate: true }
)
</script>
<template>
<div class="mock-form">
<AppPageHeader :title="title" subtitle="仅支持行测五个模块分数,总分自动汇总">
<AppButton variant="ghost" @click="back">
<AppIcon name="chevron-left" :size="16" />返回模考分析
</AppButton>
</AppPageHeader>
<AppLoading v-if="isEdit && analysis.loading.value" fullscreen text="正在加载模考记录…" />
<AppError
v-else-if="sourceError"
fullscreen
title="加载失败"
:message="sourceError"
retry
@retry="analysis.refresh"
/>
<AppError
v-else-if="isEdit && !loaded"
fullscreen
title="未找到模考记录"
message="该记录可能已被删除"
/>
<template v-else-if="formReady">
<AppCard title="成绩信息" :padded="true" class="mock-form__card">
<div class="form-field">
<label class="form-field__label">模考名称</label>
<input
v-model="form.name"
class="form-control"
type="text"
placeholder="如:行测全真模考(四)"
/>
<p v-if="nameError" class="form-field__error">{{ nameError }}</p>
</div>
<div class="form-field">
<label class="form-field__label">模考日期</label>
<input v-model="form.date" class="form-control form-control--date" type="date" />
<p v-if="dateError" class="form-field__error">{{ dateError }}</p>
</div>
<div class="form-field">
<label class="form-field__label">五个模块分数每题满分合计 100 </label>
<div class="mock-form__modules">
<label v-for="m in MODULES" :key="m" class="mock-form__score">
<span class="mock-form__score-name">{{ m }}</span>
<input
:value="form.modules[m]"
class="form-control mock-form__score-input"
type="number"
min="0"
max="100"
inputmode="numeric"
@input="patchModule(m, ($event.target as HTMLInputElement).value)"
/>
</label>
</div>
<p v-if="totalOverflow" class="form-field__error">
模块分数合计 {{ total }} 不能超过 100
</p>
</div>
<div class="mock-form__total">
<span class="mock-form__total-label">总分自动汇总</span>
<strong class="mock-form__total-value">{{ total }}<small> </small></strong>
</div>
<div class="form-field">
<label class="form-field__label">备注可选</label>
<textarea
v-model="form.note"
class="form-control mock-form__note"
placeholder="如:资料分析时间紧张,常识失分较多"
/>
</div>
<AppButton variant="primary" size="lg" block :disabled="saving" @click="submit">
{{ saving ? '保存中…' : isEdit ? '保存修改' : '保存成绩' }}
</AppButton>
</AppCard>
</template>
</div>
</template>
<style scoped>
.mock-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.mock-form__card {
max-width: 640px;
}
.mock-form__modules {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.mock-form__score {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-soft);
}
.mock-form__score-name {
flex: 1;
min-width: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.mock-form__score-input {
width: 84px;
text-align: right;
}
.mock-form__total {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--primary-soft, var(--bg-soft));
}
.mock-form__total-label {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.mock-form__total-value {
font-family: var(--font-num);
font-size: 22px;
color: var(--primary);
}
.mock-form__total-value small {
font-size: var(--fs-label);
font-weight: normal;
color: var(--text-muted);
}
.mock-form__note {
min-height: 84px;
resize: vertical;
}
/* 表单通用字段样式(与题库录入保持一致) */
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-field__label {
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--text-secondary);
}
.form-field__error {
margin: 0;
font-size: var(--fs-label);
color: var(--danger);
}
.form-control {
width: 100%;
box-sizing: border-box;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
@media (max-width: 767px) {
.mock-form__modules {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,7 +1,369 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
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>
<ComingSoon />
<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">
<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>

80
docs/checks/task-10.md Normal file
View File

@ -0,0 +1,80 @@
# 任务 10 检查记录:模考分析
日期2026-09-01
## 交付内容
### 后端Handler + Schema + Routes
- `server/src/handlers/mock.ts`(重写):
- `analysis`:返回记录列表(新→旧)、目标分、最近成绩 `latest`、目标分差 `targetGap`(目标分 最近成绩)、较上次变化 `changeVsPrevious`(最近 上一场)、总分趋势 `trend` 与五个行测模块均分 `moduleAverages`
- `record`:录入持久化到 `mock-exams.json``updateData` 原子写,生成 UUID + createdAt不再返回未落库的临时对象。
- `update`(新增):按 id 修改记录,保留 id/createdAt其余字段覆盖。
- `remove`(新增):按 id 删除记录,分析随数据文件同步更新。
- `server/src/schemas/api.ts`:新增 `MockExamIdParamsSchema``MockExamUpdateBodySchema``MockExamRecordResponseSchema``MockExamDeleteResponseSchema``MockExamAnalysisResponseSchema` 增加 `latest` / `targetGap` / `changeVsPrevious`
- `server/src/routes.ts`:新增 `POST /api/mock-exams/:id/update``DELETE /api/mock-exams/:id`(模考分析 tag
- `server/src/server.ts`:修复 CORS 预检方法白名单(原默认仅 GET/HEAD/POST导致浏览器端 DELETE/PATCH 被拦;现显式允许 GET/HEAD/POST/PATCH/DELETE同时修复题库删除与计划切换的浏览器调用
### 前端View + API
- `client/src/views/mock/MockView.vue`(替换 ComingSoon 占位):
- 顶部「录入成绩」入口;加载/错误/空数据三态。
- 空态:标题「还没有模考成绩」+ 描述 + 「录入第一场模考」CTA。
- 统计卡:最近成绩(名称 + 日期)、目标分差(达标显示「已达目标」)、较上次变化(±分)。
- 行测成绩趋势(`AppLineChart`,虚线为目标分)。
- 模块均分对比(五模块横向进度条)。
- 模考记录列表(名称、日期、总分徽章、五模块小分、备注)+ 编辑 / 删除(删除弹确认框,二次确认)。
- `client/src/views/mock/MockFormView.vue`(新增):成绩录入/修改共用表单。
- 字段模考名称、日期、五个模块分数0100、备注总分由五模块自动汇总展示。
- 校验:名称必填、日期必填、模块合计 ≤ 100超限给出中文提示
- 创建走 `POST /api/mock-exams/record`,编辑走 `POST /api/mock-exams/:id/update`,保存成功 toast 后返回分析页。
- `client/src/api/index.ts``mockApi.update` / `mockApi.remove`;导出 `MockExamRecord` / `MockExamAnalysis` / `MockExamListItem` 类型。
- `client/src/router/index.ts`:新增 `/mock/new`(录入)、`/mock/:id/edit`(修改)。
- `client/src/components/base/icons.ts`:新增 `line`(趋势线)图标。
## 接口实测curl演示数据
| 接口 | 结果 |
|---|---|
| `GET /api/mock-exams/analysis` | 5 条记录latest=67targetGap=5目标 72changeVsPrevious=+3trend 5 点;五模块均分正确 |
| `POST /api/mock-exams/record` | 新增记录持久化analysis 变为 6 条、latest=70、gap=2 |
| `POST /api/mock-exams/:id/update` | 名称/分数/备注覆盖,保留 id/createdAt |
| `DELETE /api/mock-exams/:id` | 删除后 analysis 回到 5 条、latest 回 67 |
| 空数据 analysis | `records:[]`、latest/targetGap/changeVsPrevious 为 null、trend 空 |
| 非法输入 | 缺 name / 分数 120 → 统一 `VALIDATION_ERROR`;不存在记录 update/delete → `NOT_FOUND` |
| 重启持久化 | 新增记录重启后仍存在分析正确6 条 / latest=69 / gap=3 / +2 |
## 浏览器检查Chrome headless + CDP
| 场景 | 结果 |
|---|---|
| 桌面 1440 模考分析 | 统计卡(最近 67 / 还差 5 分 / +3 分、趋势图、模块对比、5 条记录、录入成绩按钮 |
| 录入流程 | 填名称/日期/五模块 → 总分自动汇总 70 → 保存 → 回分析页显示 6 条、最近 70 分 |
| 编辑流程 | 点击「编辑」→ 表单回填 → 改名称/分数 → 保存 → 列表同步更新 |
| 删除流程 | 点击「删除」→ 确认弹窗 → 「确认删除」→ 列表回 5 条、最近成绩回 67 |
| 空态 | 「还没有模考成绩」+「录入第一场模考」CTA 正常展示与跳转 |
| 表单校验 | 空名称提示「请填写模考名称」;五模块合计超 100 提示不能超过 100 分 |
| 移动 390 | 模考分析页与录入页均无横向溢出390/390 |
| 首页回归 | 数据中枢「最近模考」卡与趋势联动正常67 分 / +3 / 还差 5 分) |
截图:`deliverables/checks/task10-mock-desktop.png``task10-entry-desktop.png``task10-mock-mobile.png``task10-entry-mobile.png``task10-mock-empty-mobile.png`
## 完成标准核对开发计划任务10
- ✅ 行测模考录入record 持久化 + 录入页)。
- ✅ 列表(模考记录,含编辑/删除)。
- ✅ 趋势≥2 条记录生成折线趋势,含目标参考线)。
- ✅ 模块分数(五模块录入与均分对比,仅行测字段)。
- ✅ 目标分差targetGap与较上次变化changeVsPrevious
- ✅ 无数据空状态(后端 null 字段 + 前端空态 CTA
- ✅ 删除或修改记录后分析同步更新实测录入→6 条、删除→5 条、编辑同步)。
- ✅ 类型检查vue-tsc / tsc与生产构建135 模块)通过;`api:generate` 与 OpenAPI 一致。
## 边界说明
- 总分由前端按五模块自动汇总(演示数据 total = 模块和,保持一致);后端仅做 0100 范围校验,不强制模块和等于 total。
- CORS 方法白名单修复属于本任务联调发现的前置缺陷(浏览器端 DELETE/PATCH 预检被拒),一并修复并回归题库删除、计划切换。
## 当前结论
任务 10 完成:行测模考录入、列表、趋势、模块分数、目标分差、较上次变化、空状态与删除/修改联动全部实测通过;桌面与移动双端可操作;测试数据已恢复演示基线。

View File

@ -1,6 +1,7 @@
import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js'
import { readData, updateData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { ApiError } from '../errors.js'
import { todayISO } from '../utils/dates.js'
import { newId } from '../utils/ids.js'
import { MODULES } from '../schemas/entities.js'
@ -8,19 +9,45 @@ import type { MockExam, Profile } from '../schemas/entities.js'
import type { z } from 'zod'
import { MockExamRecordBodySchema } from '../schemas/api.js'
/** 行测模考分析:记录、目标分差、趋势、模块均分 */
const FALLBACK_PROFILE: Profile = {
nickname: '备考人',
examDate: '2026-11-30',
targetScore: 72,
startedAt: todayISO()
}
/** 按日期(同日按创建时间)升序排序 */
function sortByDate(records: MockExam[]): MockExam[] {
return [...records].sort((a, b) => {
const byDate = a.date.localeCompare(b.date)
return byDate !== 0 ? byDate : a.createdAt.localeCompare(b.createdAt)
})
}
function toView(r: MockExam) {
return {
id: r.id,
name: r.name,
date: r.date,
total: r.total,
modules: r.modules,
note: r.note
}
}
/**
*
* - records
* - targetScore / latest / targetGap / changeVsPrevious
* - trend / moduleAverages
*/
export async function analysis() {
const [records, profile] = await Promise.all([
readData<MockExam[]>(dataFiles.mockExams, []),
readData<Profile>(dataFiles.profile, {
nickname: '备考人',
examDate: '2026-11-30',
targetScore: 72,
startedAt: todayISO()
})
readData<Profile>(dataFiles.profile, FALLBACK_PROFILE)
])
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date))
const sorted = sortByDate(records)
const trend = sorted.map((r) => ({ date: r.date, total: r.total }))
const moduleAverages = MODULES.map((module) => {
const scores = records.map((r) => r.modules[module] ?? 0).filter((s) => s > 0)
@ -28,21 +55,32 @@ export async function analysis() {
return { module, average }
})
const latest = sorted.length > 0 ? sorted[sorted.length - 1] : null
const previous = sorted.length > 1 ? sorted[sorted.length - 2] : null
const targetScore = profile.targetScore
return {
records: sorted
.slice()
.reverse()
.map((r) => ({ id: r.id, name: r.name, date: r.date, total: r.total, modules: r.modules, note: r.note })),
targetScore: profile.targetScore,
records: sorted.slice().reverse().map(toView),
targetScore,
latest: latest ? { id: latest.id, name: latest.name, date: latest.date, total: latest.total } : null,
targetGap: latest ? targetScore - latest.total : null,
changeVsPrevious: latest && previous ? latest.total - previous.total : null,
trend,
moduleAverages
}
}
/** 录入模考成绩。TODO 任务10持久化并更新趋势与模块分析。 */
async function findMockExam(id: string): Promise<MockExam> {
const records = await readData<MockExam[]>(dataFiles.mockExams, [])
const item = records.find((r) => r.id === id)
if (!item) throw ApiError.notFound('模考记录不存在')
return item
}
/** 录入模考成绩:校验后写入 mock-exams.json原子替换返回持久化结果 */
export async function record(request: FastifyRequest) {
const body = request.body as z.infer<typeof MockExamRecordBodySchema>
return {
const item: MockExam = {
id: newId('mock'),
name: body.name,
date: body.date,
@ -57,4 +95,43 @@ export async function record(request: FastifyRequest) {
note: body.note ?? '',
createdAt: new Date().toISOString()
}
await updateData<MockExam[]>(dataFiles.mockExams, [], (list) => [...list, item])
return item
}
/** 修改模考记录:保留 id 与 createdAt其余字段按提交覆盖 */
export async function update(request: FastifyRequest) {
const { id } = request.params as { id: string }
const body = request.body as z.infer<typeof MockExamRecordBodySchema>
await findMockExam(id)
const updated = await updateData<MockExam[]>(dataFiles.mockExams, [], (list) =>
list.map((r) =>
r.id === id
? {
...r,
name: body.name,
date: body.date,
total: body.total,
modules: {
'言语理解': body['言语理解'],
'数量关系': body['数量关系'],
'判断推理': body['判断推理'],
'资料分析': body['资料分析'],
'常识判断': body['常识判断']
},
note: body.note ?? ''
}
: r
)
)
return updated.find((r) => r.id === id) as MockExam
}
/** 删除模考记录;分析接口随数据文件同步变化 */
export async function remove(request: FastifyRequest) {
const { id } = request.params as { id: string }
await findMockExam(id)
await updateData<MockExam[]>(dataFiles.mockExams, [], (list) => list.filter((r) => r.id !== id))
return { id, deleted: true }
}

View File

@ -1,7 +1,7 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { ErrorResponseSchema } from './schemas/api.js'
import { MockExamSchema, NewsItemSchema, StudyPlanSchema } from './schemas/entities.js'
import { NewsItemSchema, StudyPlanSchema } from './schemas/entities.js'
import * as schemas from './schemas/api.js'
import * as dashboard from './handlers/dashboard.js'
import * as practice from './handlers/practice.js'
@ -166,9 +166,22 @@ const routes: RouteDef[] = [
{
method: 'POST', url: '/api/mock-exams/record', summary: '录入模考成绩', tags: ['模考分析'],
body: schemas.MockExamRecordBodySchema,
response: { 200: MockExamSchema },
response: { 200: schemas.MockExamRecordResponseSchema },
handler: mock.record
},
{
method: 'POST', url: '/api/mock-exams/:id/update', summary: '修改模考成绩', tags: ['模考分析'],
params: schemas.MockExamIdParamsSchema,
body: schemas.MockExamUpdateBodySchema,
response: { 200: schemas.MockExamRecordResponseSchema },
handler: mock.update
},
{
method: 'DELETE', url: '/api/mock-exams/:id', summary: '删除模考成绩', tags: ['模考分析'],
params: schemas.MockExamIdParamsSchema,
response: { 200: schemas.MockExamDeleteResponseSchema },
handler: mock.remove
},
// 要闻
{

View File

@ -326,6 +326,17 @@ export const MockExamAnalysisResponseSchema = z.object({
note: z.string()
})),
targetScore: z.number().min(0),
/** 最近一场成绩(按日期取最新;无记录为 null */
latest: z.object({
id: z.string(),
name: z.string(),
date: dateParam,
total: z.number().min(0)
}).nullable(),
/** 目标分差 = 目标分 - 最近成绩;无记录为 null */
targetGap: z.number().nullable(),
/** 较上次变化 = 最近成绩 - 上一场成绩;不足两场为 null */
changeVsPrevious: z.number().nullable(),
trend: z.array(z.object({ date: dateParam, total: z.number().min(0) })),
moduleAverages: z.array(z.object({ module: z.enum(MODULES), average: z.number().min(0) }))
})
@ -340,6 +351,28 @@ export const MockExamRecordBodySchema = z.object({
'常识判断': z.number().min(0).max(100),
note: z.string().optional()
})
/** 修改模考记录:字段与录入一致(保留 id/createdAt */
export const MockExamUpdateBodySchema = MockExamRecordBodySchema
export const MockExamRecordResponseSchema = z.object({
id: z.string(),
name: z.string(),
date: dateParam,
total: z.number().min(0).max(100),
modules: z.object({
'言语理解': z.number().min(0).max(100),
'数量关系': z.number().min(0).max(100),
'判断推理': z.number().min(0).max(100),
'资料分析': z.number().min(0).max(100),
'常识判断': z.number().min(0).max(100)
}),
note: z.string(),
createdAt: z.string()
})
export const MockExamIdParamsSchema = z.object({ id: z.string().min(1) })
export const MockExamDeleteResponseSchema = z.object({
id: z.string(),
deleted: z.boolean()
})
// ---------- 要闻 ----------
export const NewsListQuerySchema = z.object({

View File

@ -22,7 +22,11 @@ export async function buildServer() {
}
})
await app.register(cors, { origin: true })
// 允许浏览器跨域调用本地后端GET/POST/PATCH/DELETE 均为现有接口用到的语义化方法)
await app.register(cors, {
origin: true,
methods: ['GET', 'HEAD', 'POST', 'PATCH', 'DELETE']
})
await app.register(swagger, {
openapi: {