feat: 完成模考分析闭环
This commit is contained in:
parent
09b0045d3f
commit
32b5355ab3
@ -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'>) =>
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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: '要闻' } },
|
||||
|
||||
315
client/src/views/mock/MockFormView.vue
Normal file
315
client/src/views/mock/MockFormView.vue
Normal 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>
|
||||
@ -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
80
docs/checks/task-10.md
Normal 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`(新增):成绩录入/修改共用表单。
|
||||
- 字段:模考名称、日期、五个模块分数(0–100)、备注;总分由五模块自动汇总展示。
|
||||
- 校验:名称必填、日期必填、模块合计 ≤ 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=67;targetGap=5(目标 72);changeVsPrevious=+3;trend 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 = 模块和,保持一致);后端仅做 0–100 范围校验,不强制模块和等于 total。
|
||||
- CORS 方法白名单修复属于本任务联调发现的前置缺陷(浏览器端 DELETE/PATCH 预检被拒),一并修复并回归题库删除、计划切换。
|
||||
|
||||
## 当前结论
|
||||
|
||||
任务 10 完成:行测模考录入、列表、趋势、模块分数、目标分差、较上次变化、空状态与删除/修改联动全部实测通过;桌面与移动双端可操作;测试数据已恢复演示基线。
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
// 要闻
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user