feat: 首页对齐原型+联调

This commit is contained in:
liyy 2026-08-31 17:53:46 +08:00
parent 84e5fa0af5
commit 9f5c9a6366
25 changed files with 3143 additions and 231 deletions

View File

@ -29,6 +29,8 @@ type BodyOf<P extends keyof paths, M extends Methods> = paths[P][M] extends {
// ---- 常用响应类型 ----
export type DashboardOverview = SuccessBody<paths['/api/dashboard/overview']['get']>
export type DashboardTrends = SuccessBody<paths['/api/dashboard/trends']['get']>
export type DashboardMastery = SuccessBody<paths['/api/dashboard/mastery']['get']>
export type DashboardWeakPoints = SuccessBody<paths['/api/dashboard/weak-points']['get']>
export type PracticeModules = SuccessBody<paths['/api/practice/modules']['get']>
export type Profile = SuccessBody<paths['/api/profile']['get']>
export type Settings = SuccessBody<paths['/api/settings']['get']>
@ -37,7 +39,9 @@ export type Settings = SuccessBody<paths['/api/settings']['get']>
export const dashboardApi = {
overview: () => unwrap(apiClient.GET('/api/dashboard/overview')),
trends: (days?: number) =>
unwrap(apiClient.GET('/api/dashboard/trends', { params: { query: { days } } }))
unwrap(apiClient.GET('/api/dashboard/trends', { params: { query: { days } } })),
mastery: () => unwrap(apiClient.GET('/api/dashboard/mastery')),
weakPoints: () => unwrap(apiClient.GET('/api/dashboard/weak-points'))
}
// ---- 刷题 ----

View File

@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
export interface LinePoint {
date: string
value: number
}
const props = withDefaults(
defineProps<{
points: LinePoint[]
target?: number
height?: number
}>(),
{ target: 0, height: 220 }
)
const container = ref<HTMLElement | null>(null)
const width = ref(680)
let observer: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth
observer = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width
if (w) width.value = w
})
observer.observe(container.value)
})
onUnmounted(() => observer?.disconnect())
const pad = { left: 30, right: 14, top: 16, bottom: 26 }
const chartW = computed(() => Math.max(10, width.value - pad.left - pad.right))
const chartH = computed(() => Math.max(40, props.height - pad.top - pad.bottom))
const values = computed(() => props.points.map((p) => p.value))
const maxV = computed(() => {
const max = Math.max(0, ...values.value, props.target)
if (max === 0) return 100
return Math.ceil(max / 10) * 10
})
const minV = computed(() => {
const min = Math.min(...values.value, props.target)
return Math.max(0, Math.floor(min / 10) * 10)
})
const range = computed(() => Math.max(1, maxV.value - minV.value))
const n = computed(() => Math.max(props.points.length, 1))
const slot = computed(() => chartW.value / n.value)
function yOf(value: number): number {
return pad.top + chartH.value - ((value - minV.value) / range.value) * chartH.value
}
const path = computed(() => {
if (props.points.length === 0) return ''
return props.points
.map((p, i) => {
const cx = pad.left + slot.value * (i + 0.5)
const cy = yOf(p.value)
return `${i === 0 ? 'M' : 'L'}${cx.toFixed(1)},${cy.toFixed(1)}`
})
.join(' ')
})
const areaPath = computed(() => {
if (props.points.length === 0) return ''
const last = props.points.length - 1
const firstX = pad.left + slot.value * 0.5
const lastX = pad.left + slot.value * (last + 0.5)
const bottom = pad.top + chartH.value
return `${path.value} L${lastX.toFixed(1)},${bottom} L${firstX.toFixed(1)},${bottom} Z`
})
const dots = computed(() =>
props.points.map((p, i) => ({
cx: pad.left + slot.value * (i + 0.5),
cy: yOf(p.value),
value: p.value,
date: p.date
}))
)
const targetY = computed(() => (props.target > 0 ? yOf(props.target) : null))
const xTicks = computed(() => {
const count = props.points.length
const step = count <= 7 ? 1 : Math.ceil(count / 7)
return props.points
.map((p, i) => ({ ...p, i }))
.filter((p) => p.i % step === 0 || p.i === count - 1)
.map((p) => ({
cx: pad.left + slot.value * (p.i + 0.5),
label: `${Number(p.date.slice(5, 7))}/${Number(p.date.slice(8, 10))}`
}))
})
const gridValues = computed(() => [minV.value, (minV.value + maxV.value) / 2, maxV.value])
</script>
<template>
<div ref="container" class="line-chart">
<svg :width="width" :height="height" class="line-chart__svg" role="img" aria-label="模考成绩趋势图">
<template v-if="points.length">
<!-- 网格线 -->
<g v-for="(v, i) in gridValues" :key="i">
<line
:x1="pad.left" :y1="yOf(v)" :x2="pad.left + chartW" :y2="yOf(v)"
stroke="var(--border-subtle)" stroke-width="1"
/>
<text
:x="pad.left - 6" :y="yOf(v) + 3"
text-anchor="end" font-size="10" fill="var(--text-muted)"
>{{ Math.round(v) }}</text>
</g>
<!-- 目标参考线 -->
<g v-if="targetY !== null">
<line
:x1="pad.left" :y1="targetY" :x2="pad.left + chartW" :y2="targetY"
stroke="var(--warning)" stroke-width="1" stroke-dasharray="4 4"
/>
<text
:x="pad.left + chartW" :y="targetY - 5"
text-anchor="end" font-size="10" fill="var(--warning)"
>目标 {{ target }}</text>
</g>
<!-- 面积 -->
<path v-if="areaPath" :d="areaPath" fill="var(--primary-bright)" opacity="0.12" />
<!-- 折线 -->
<path
v-if="path"
:d="path"
fill="none"
stroke="var(--primary)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
v-for="(d, i) in dots"
:key="i"
:cx="d.cx" :cy="d.cy" r="3.5"
fill="var(--bg-card)" stroke="var(--primary)" stroke-width="2"
>
<title>{{ d.date }} · {{ d.value }} </title>
</circle>
<!-- x 轴标签 -->
<text
v-for="(t, i) in xTicks"
:key="i"
:x="t.cx" :y="height - 6"
text-anchor="middle" font-size="10" fill="var(--text-muted)"
>{{ t.label }}</text>
</template>
</svg>
</div>
</template>
<style scoped>
.line-chart {
width: 100%;
}
.line-chart__svg {
display: block;
}
</style>

View File

@ -0,0 +1,199 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
export interface TrendPoint {
date: string
answered: number
accuracy: number
}
const props = withDefaults(
defineProps<{
points: TrendPoint[]
height?: number
}>(),
{ height: 220 }
)
const container = ref<HTMLElement | null>(null)
const width = ref(680)
let observer: ResizeObserver | null = null
onMounted(() => {
if (!container.value) return
width.value = container.value.clientWidth
observer = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width
if (w) width.value = w
})
observer.observe(container.value)
})
onUnmounted(() => observer?.disconnect())
const pad = { left: 34, right: 34, top: 14, bottom: 26 }
const chartW = computed(() => Math.max(10, width.value - pad.left - pad.right))
const chartH = computed(() => Math.max(40, props.height - pad.top - pad.bottom))
const maxAnswered = computed(() => {
const max = props.points.reduce((m, p) => Math.max(m, p.answered), 0)
if (max === 0) return 5
const step = 5
return Math.ceil(max / step) * step
})
const n = computed(() => Math.max(props.points.length, 1))
const slot = computed(() => chartW.value / n.value)
const barW = computed(() => Math.max(2, Math.min(16, slot.value * 0.5)))
interface Bar {
x: number
y: number
h: number
answered: number
}
const bars = computed<Bar[]>(() =>
props.points.map((p, i) => {
const cx = pad.left + slot.value * (i + 0.5)
const h = (p.answered / maxAnswered.value) * chartH.value
return {
x: cx - barW.value / 2,
y: pad.top + chartH.value - h,
h,
answered: p.answered
}
})
)
const linePath = computed(() => {
if (props.points.length === 0) return ''
return props.points
.map((p, i) => {
const cx = pad.left + slot.value * (i + 0.5)
const cy = pad.top + chartH.value - (p.accuracy / 100) * chartH.value
return `${i === 0 ? 'M' : 'L'}${cx.toFixed(1)},${cy.toFixed(1)}`
})
.join(' ')
})
const dots = computed(() =>
props.points.map((p, i) => {
const cx = pad.left + slot.value * (i + 0.5)
const cy = pad.top + chartH.value - (p.accuracy / 100) * chartH.value
return { cx, cy, accuracy: p.accuracy, date: p.date }
})
)
const gridLines = [0, 0.5, 1].map((r) => pad.top + chartH.value * (1 - r))
/** x 轴日期刻度:按点数量选择间隔,避免标签重叠 */
const xTicks = computed(() => {
const count = props.points.length
const step = count <= 7 ? 1 : count <= 14 ? 2 : Math.ceil(count / 7)
return props.points
.map((p, i) => ({ ...p, i }))
.filter((p) => p.i % step === 0 || p.i === count - 1)
.map((p) => ({
cx: pad.left + slot.value * (p.i + 0.5),
label: `${Number(p.date.slice(5, 7))}/${Number(p.date.slice(8, 10))}`
}))
})
</script>
<template>
<div ref="container" class="trend-chart">
<div class="trend-chart__legend">
<span class="legend-item"><i class="dot dot--bar" />每日答题数</span>
<span class="legend-item"><i class="dot dot--line" />正确率</span>
</div>
<svg :width="width" :height="height" class="trend-chart__svg" role="img" aria-label="学习趋势图">
<template v-if="points.length">
<!-- 网格线 -->
<g v-for="(y, idx) in gridLines" :key="idx">
<line
:x1="pad.left" :y1="y" :x2="pad.left + chartW" :y2="y"
stroke="var(--border-subtle)" stroke-width="1"
/>
</g>
<!-- 答题数柱 -->
<rect
v-for="(b, i) in bars"
:key="`bar-${i}`"
:x="b.x" :y="b.y" :width="barW" :height="Math.max(b.h, 0)"
rx="3"
fill="var(--primary)"
:opacity="b.answered === 0 ? 0 : 0.85"
>
<title>{{ points[i].date }} · 答题 {{ b.answered }} </title>
</rect>
<!-- 正确率折线 -->
<path
v-if="linePath"
:d="linePath"
fill="none"
stroke="var(--success)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
v-for="(d, i) in dots"
:key="`dot-${i}`"
:cx="d.cx" :cy="d.cy" r="3"
fill="var(--success)"
>
<title>{{ d.date }} · 正确率 {{ d.accuracy }}%</title>
</circle>
<!-- x 轴标签 -->
<text
v-for="(t, i) in xTicks"
:key="`x-${i}`"
:x="t.cx" :y="height - 6"
text-anchor="middle"
font-size="10"
fill="var(--text-muted)"
>{{ t.label }}</text>
</template>
</svg>
</div>
</template>
<style scoped>
.trend-chart {
width: 100%;
}
.trend-chart__legend {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-2);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
}
.dot--bar {
background: var(--primary);
}
.dot--line {
width: 14px;
height: 3px;
border-radius: 2px;
background: var(--success);
}
.trend-chart__svg {
display: block;
}
</style>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import AppCard from '../base/AppCard.vue'
import AppEmpty from '../feedback/AppEmpty.vue'
import type { DashboardMastery } from '../../api'
const props = defineProps<{ mastery: DashboardMastery['mastery'] }>()
const hasData = computed(() => props.mastery.some((m) => m.answered > 0))
function tone(accuracy: number, answered: number): 'success' | 'warning' | 'danger' | 'neutral' {
if (answered === 0) return 'neutral'
if (accuracy >= 70) return 'success'
if (accuracy >= 40) return 'warning'
return 'danger'
}
</script>
<template>
<AppCard title="模块掌握度" icon="target">
<AppEmpty
v-if="!hasData"
icon="target"
title="暂无作答记录"
description="完成刷题后,这里会按最近 30 天统计各模块掌握度。"
/>
<ul v-else class="mastery">
<li v-for="item in mastery" :key="item.module" class="mastery__row">
<div class="mastery__head">
<span class="mastery__name">{{ item.module }}</span>
<span class="mastery__meta">
<template v-if="item.answered > 0">
<strong class="mastery__value">{{ item.accuracy }}%</strong>
<span class="mastery__count"> {{ item.answered }} </span>
</template>
<span v-else class="mastery__none">暂无作答</span>
</span>
</div>
<div class="mastery__track">
<span
class="mastery__fill"
:class="`mastery__fill--${tone(item.accuracy, item.answered)}`"
:style="{ width: `${item.answered > 0 ? item.accuracy : 0}%` }"
/>
</div>
</li>
</ul>
</AppCard>
</template>
<style scoped>
.mastery {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.mastery__head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.mastery__name {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.mastery__meta {
display: inline-flex;
align-items: baseline;
gap: var(--space-2);
}
.mastery__value {
font-family: var(--font-num);
font-size: var(--fs-card-title);
font-weight: var(--fw-data-num);
color: var(--text-primary);
}
.mastery__count {
font-size: var(--fs-label);
color: var(--text-muted);
}
.mastery__none {
font-size: var(--fs-label);
color: var(--text-muted);
}
.mastery__track {
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.mastery__fill {
display: block;
height: 100%;
border-radius: var(--radius-pill);
transition: width var(--motion-switch) var(--ease-default);
}
.mastery__fill--success {
background: var(--success);
}
.mastery__fill--warning {
background: var(--warning);
}
.mastery__fill--danger {
background: var(--danger);
}
.mastery__fill--neutral {
background: var(--border-subtle);
}
</style>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import AppCard from '../base/AppCard.vue'
import AppEmpty from '../feedback/AppEmpty.vue'
import AppLineChart from '../charts/AppLineChart.vue'
export interface MockTrendPoint {
date: string
total: number
}
const props = defineProps<{
trend: MockTrendPoint[]
targetScore: number
}>()
const points = computed(() => props.trend.map((t) => ({ date: t.date, value: t.total })))
const trendLabel = computed(() => {
if (props.trend.length < 2) return ''
const first = props.trend[0].total
const last = props.trend[props.trend.length - 1].total
if (last > first) return '稳定上升'
if (last < first) return '略有波动'
return '保持平稳'
})
const hasData = computed(() => props.trend.length > 0)
</script>
<template>
<AppCard title="模考成绩趋势" icon="chart">
<template #header>
<div class="mock__head">
<span v-if="trendLabel" class="mock__tag">{{ trendLabel }}</span>
</div>
</template>
<AppEmpty
v-if="!hasData"
icon="chart"
title="暂无模考成绩"
description="在「模考分析」录入行测模考成绩后,这里会展示成绩趋势。"
/>
<template v-else>
<p class="mock__sub"> {{ points.length }} · 总分 · 目标 {{ targetScore }} </p>
<AppLineChart :points="points" :target="targetScore" />
</template>
</AppCard>
</template>
<style scoped>
.mock__head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.mock__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__sub {
margin: 0 0 var(--space-3);
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
</style>

View File

@ -0,0 +1,90 @@
<script setup lang="ts">
import AppCard from '../base/AppCard.vue'
import AppIcon from '../base/AppIcon.vue'
import type { IconName } from '../base/icons'
withDefaults(
defineProps<{
label: string
value: string
sub?: string
icon: IconName
tone?: 'default' | 'danger' | 'success' | 'warning'
subTone?: 'default' | 'success' | 'danger' | 'warning'
}>(),
{ sub: '', tone: 'default', subTone: 'default' }
)
</script>
<template>
<AppCard class="stat">
<div class="stat__icon" :class="`stat__icon--${tone}`">
<AppIcon :name="icon" :size="18" />
</div>
<div class="stat__meta">
<span class="stat__label">{{ label }}</span>
<strong class="stat__value">{{ value }}</strong>
<span v-if="sub" class="stat__sub" :class="`stat__sub--${subTone}`">{{ sub }}</span>
</div>
</AppCard>
</template>
<style scoped>
.stat {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.stat__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: var(--radius-md);
background: var(--bg-soft);
color: var(--primary);
}
.stat__icon--danger {
background: var(--danger-soft);
color: var(--danger);
}
.stat__icon--success {
background: var(--success-soft);
color: var(--success);
}
.stat__icon--warning {
background: var(--warning-soft);
color: var(--warning);
}
.stat__meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat__label {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.stat__value {
font-family: var(--font-num);
font-size: 20px;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
.stat__sub {
font-size: var(--fs-label);
color: var(--text-muted);
}
.stat__sub--success {
color: var(--success);
}
.stat__sub--danger {
color: var(--danger);
}
.stat__sub--warning {
color: var(--warning);
}
</style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
import AppCard from '../base/AppCard.vue'
import AppEmpty from '../feedback/AppEmpty.vue'
import AppIcon from '../base/AppIcon.vue'
import type { paths } from '../../api'
type TodayResponse = NonNullable<paths['/api/plans/today']['get']['responses']['200']['content']['application/json']>
const props = defineProps<{ day: TodayResponse | null }>()
const tasks = computed(() => props.day?.tasks ?? [])
const done = computed(() => props.day?.done ?? 0)
const total = computed(() => props.day?.total ?? 0)
</script>
<template>
<AppCard title="今日任务" icon="check">
<template #header>
<div class="tasks__head">
<span v-if="total > 0" class="tasks__progress">{{ done }}/{{ total }}</span>
<RouterLink class="tasks__more" to="/plan">查看全部</RouterLink>
</div>
</template>
<AppEmpty
v-if="total === 0"
icon="calendar"
title="今日暂无任务"
description="在「备考计划」里安排今天的复习与刷题任务。"
/>
<ul v-else class="tasks">
<li v-for="task in tasks" :key="task.id" class="task" :class="{ 'task--done': task.status === 'done' }">
<span class="task__state">
<AppIcon :name="task.status === 'done' ? 'check' : 'clock'" :size="16" />
</span>
<span class="task__body">
<span class="task__title">{{ task.title }}</span>
<span class="task__sub">{{ task.target }}</span>
</span>
<span class="task__type">{{ task.type }}</span>
</li>
</ul>
</AppCard>
</template>
<style scoped>
.tasks__head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.tasks__progress {
font-family: var(--font-num);
font-size: var(--fs-label-bold);
color: var(--text-secondary);
}
.tasks__more {
font-size: var(--fs-label);
color: var(--primary);
text-decoration: none;
}
.tasks__more:hover {
text-decoration: underline;
}
.tasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.task {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--bg-soft);
}
.task__state {
display: grid;
place-items: center;
width: 28px;
height: 28px;
flex: none;
border-radius: var(--radius-pill);
color: var(--text-muted);
background: var(--bg-card);
}
.task--done .task__state {
color: var(--success);
background: var(--success-soft);
}
.task__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.task__title {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.task--done .task__title {
color: var(--text-muted);
text-decoration: line-through;
}
.task__sub {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.task__type {
flex: none;
font-size: var(--fs-label);
color: var(--text-muted);
}
</style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import AppCard from '../base/AppCard.vue'
import AppEmpty from '../feedback/AppEmpty.vue'
import AppBadge from '../base/AppBadge.vue'
import type { DashboardWeakPoints } from '../../api'
withDefaults(
defineProps<{
weakPoints: DashboardWeakPoints['weakPoints']
compact?: boolean
}>(),
{ compact: false }
)
</script>
<template>
<AppCard title="本周薄弱考点" icon="alert">
<template v-if="!compact" #header>
<span class="weak__hint">基于错因归因自动统计</span>
</template>
<AppEmpty
v-if="weakPoints.length === 0"
icon="alert"
title="暂无薄弱考点"
description="最近 7 天答错并归因的考点会出现在这里,继续加油!"
/>
<ul v-else class="weak">
<li v-for="(w, i) in weakPoints" :key="`${w.module}-${w.point}-${i}`" class="weak__row">
<div class="weak__info">
<span class="weak__point">
<template v-if="compact">{{ w.point }}</template>
<template v-else>{{ w.module }} · {{ w.point }}</template>
</span>
</div>
<div class="weak__stat">
<strong class="weak__count">{{ w.count }}</strong>
</div>
<div v-if="!compact && w.reasons.length" class="weak__reasons">
错因{{ w.reasons.join('·') }}
</div>
</li>
</ul>
</AppCard>
</template>
<style scoped>
.weak__hint {
font-size: var(--fs-label);
color: var(--text-muted);
}
.weak {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.weak__row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-2) var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.weak__row:last-child {
padding-bottom: 0;
border-bottom: none;
}
.weak__info {
min-width: 0;
}
.weak__point {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.weak__stat {
display: flex;
align-items: baseline;
flex: none;
}
.weak__count {
font-family: var(--font-num);
font-size: var(--fs-card-title);
font-weight: var(--fw-data-num);
color: var(--danger);
}
.weak__reasons {
grid-column: 1 / -1;
font-size: var(--fs-label);
color: var(--text-secondary);
}
</style>

View File

@ -14,16 +14,27 @@ const route = useRoute()
<strong>备考通</strong>
</div>
<nav class="nav">
<RouterLink
v-for="item in desktopNav"
:key="item.to"
:to="item.to"
class="nav-item"
:class="{ active: route.path === item.to }"
>
<AppIcon :name="item.icon" :size="18" />
<span>{{ item.label }}</span>
</RouterLink>
<template v-for="item in desktopNav" :key="item.to">
<RouterLink
:to="item.to"
class="nav-item"
:class="{ active: route.path === item.to }"
>
<AppIcon :name="item.icon" :size="18" />
<span>{{ item.label }}</span>
</RouterLink>
<RouterLink
v-if="item.children"
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="nav-item nav-item--child"
:class="{ active: route.path === child.to }"
>
<span class="nav-item__dot" />
<span>{{ child.label }}</span>
</RouterLink>
</template>
</nav>
</aside>
<main class="content"><slot /></main>
@ -88,6 +99,19 @@ const route = useRoute()
color: var(--on-accent);
font-weight: var(--fw-card-title);
}
.nav-item--child {
padding-left: 36px;
gap: var(--space-3);
height: 36px;
}
.nav-item__dot {
width: 4px;
height: 4px;
flex: none;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
}
.content {
margin-left: var(--sidebar-width-desktop);
padding: var(--padding-desktop);

View File

@ -6,11 +6,13 @@ import { mobileNav } from '../router/nav'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '备考通'))
//
const isHome = computed(() => route.path === '/')
</script>
<template>
<div class="layout-mobile">
<header class="topbar">
<header v-if="!isHome" class="topbar">
<h1>{{ title }}</h1>
</header>
<main class="content"><slot /></main>

View File

@ -12,6 +12,8 @@ import SettingsView from '../views/settings/SettingsView.vue'
export const routes: RouteRecordRaw[] = [
{ path: '/', name: 'dashboard', component: DashboardView, meta: { title: '数据中枢' } },
{ path: '/practice', name: 'practice', component: PracticeView, meta: { title: '刷题中心' } },
{ path: '/practice/wrong', name: 'practice-wrong', component: PracticeView, meta: { title: '错题本' } },
{ path: '/practice/essay', name: 'practice-essay', component: PracticeView, meta: { title: '申论' } },
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
{ path: '/plan', name: 'plan', component: PlanView, meta: { title: '备考计划' } },
{ path: '/news', name: 'news', component: NewsView, meta: { title: '要闻' } },

View File

@ -1,15 +1,30 @@
import type { IconName } from '../components/base/icons'
export interface NavChild {
label: string
to: string
}
export interface NavItem {
label: string
to: string
icon: IconName
children?: NavChild[]
}
/** 桌面左侧导航8 项,见需求文档 3.1 */
/** 桌面左侧导航8 项,见需求文档 3.1;「刷题中心」展开子菜单 */
export const desktopNav: NavItem[] = [
{ label: '数据中枢', to: '/', icon: 'dashboard' },
{ label: '刷题中心', to: '/practice', icon: 'pen' },
{
label: '刷题中心',
to: '/practice',
icon: 'pen',
children: [
{ label: '刷题', to: '/practice' },
{ label: '错题本', to: '/practice/wrong' },
{ label: '申论', to: '/practice/essay' }
]
},
{ label: '模考分析', to: '/mock', icon: 'chart' },
{ label: '备考计划', to: '/plan', icon: 'calendar' },
{ label: '要闻', to: '/news', icon: 'news' },

View File

@ -19,3 +19,20 @@ export function formatDateShort(iso: string): string {
const [, m, d] = iso.split('-')
return `${Number(m)}${Number(d)}`
}
/** 分钟 → 「1小时25分」/「45分」/「0分」 */
export function formatMinutes(mins: number): string {
const m = Math.max(0, Math.round(mins))
if (m < 60) return `${m}`
const h = Math.floor(m / 60)
const rest = m % 60
return rest === 0 ? `${h}小时` : `${h}小时${rest}`
}
/** 按当天时段返回问候语(早上好 / 下午好 / 晚上好) */
export function greetingByHour(hour = new Date().getHours()): string {
if (hour < 6) return '夜深了'
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
}

View File

@ -1,101 +1,236 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { dashboardApi } from '../../api'
import { dashboardApi, mockApi, plansApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useProfileStore } from '../../stores/profile'
import { formatDateShort, formatMinutes, greetingByHour } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import type { IconName } from '../../components/base/icons'
import StatCard from '../../components/dashboard/StatCard.vue'
import MockTrendCard from '../../components/dashboard/MockTrendCard.vue'
import MasteryCard from '../../components/dashboard/MasteryCard.vue'
import TodayTasksCard from '../../components/dashboard/TodayTasksCard.vue'
import WeakPointsCard from '../../components/dashboard/WeakPointsCard.vue'
const profile = useProfileStore()
const { data, loading, error, refresh } = useRequest(() => dashboardApi.overview())
const { isMobile } = useResponsive()
const overview = useRequest(() => dashboardApi.overview())
const mastery = useRequest(() => dashboardApi.mastery())
const weakPoints = useRequest(() => dashboardApi.weakPoints())
const today = useRequest(() => plansApi.today())
const mock = useRequest(() => mockApi.analysis())
onMounted(() => {
if (!profile.profile) profile.fetch()
})
const isEmpty = computed(() => {
const d = data.value
return !!d && d.totalQuestions === 0 && d.totalAnswered === 0
const d = computed(() => overview.data.value)
const masteryItems = computed(() => mastery.data.value?.mastery ?? [])
const weakItems = computed(() => weakPoints.data.value?.weakPoints ?? [])
const mockRecords = computed(() => mock.data.value?.records ?? [])
const mockTrend = computed(() => (mock.data.value?.trend ?? []).map((t) => ({ date: t.date, total: t.total })))
const targetScore = computed(() => mock.data.value?.targetScore ?? 0)
const isGlobalEmpty = computed(() => {
const o = d.value
return !!o && o.totalQuestions === 0 && o.totalAnswered === 0 && o.todayTasksTotal === 0 && mockRecords.value.length === 0
})
interface StatItem {
key: string
label: string
value: number
unit: string
icon: IconName
tone?: 'danger' | 'success'
}
const greeting = computed(() => `${greetingByHour()}${profile.nickname}`)
const stats = computed<StatItem[]>(() => {
const d = data.value
if (!d) return []
return [
{ key: 'questions', label: '题库题量', value: d.totalQuestions, unit: '题', icon: 'book' },
{ key: 'review', label: '待复习错题', value: d.pendingReview, unit: '题', icon: 'alert', tone: d.pendingReview > 0 ? 'danger' : undefined },
{ key: 'tasks', label: '今日任务', value: d.todayTasksDone, unit: `/${d.todayTasksTotal}`, icon: 'check' },
{ key: 'minutes', label: '今日学习', value: d.todayMinutes, unit: '分钟', icon: 'clock' },
{ key: 'streak', label: '连续打卡', value: d.streakDays, unit: '天', icon: 'flame' }
]
const goalProgress = computed(() => {
const o = d.value
if (!o || o.studyDays + o.daysToExam === 0) return 0
return Math.min(100, Math.round((o.studyDays / (o.studyDays + o.daysToExam)) * 100))
})
const minutesDeltaText = computed(() => {
const delta = d.value?.minutesDelta
if (delta === null || delta === undefined) return ''
return delta >= 0 ? `+${delta}%较昨日` : `${delta}%较昨日`
})
const tasksCompletion = computed(() => {
const o = d.value
if (!o || o.todayTasksTotal === 0) return 0
return Math.round((o.todayTasksDone / o.todayTasksTotal) * 100)
})
const latestMock = computed(() => mockRecords.value[0] ?? null)
const previousMock = computed(() => mockRecords.value[1] ?? null)
const mockDelta = computed(() => {
if (!latestMock.value || !previousMock.value) return null
const delta = latestMock.value.total - previousMock.value.total
return delta
})
</script>
<template>
<div class="dashboard">
<header class="page-head">
<div>
<h1>数据中枢</h1>
<p>{{ profile.nickname }}欢迎回来 · 今日也要稳步推进</p>
</div>
</header>
<AppLoading v-if="loading" fullscreen />
<!-- 加载 / 错误 / 全局空态 -->
<AppLoading v-if="overview.loading.value" fullscreen />
<AppError
v-else-if="error"
v-else-if="overview.error.value"
fullscreen
title="数据加载失败"
:message="error"
:message="overview.error.value"
retry
@retry="refresh"
@retry="overview.refresh"
/>
<AppEmpty
v-else-if="isEmpty"
v-else-if="isGlobalEmpty"
title="还没有学习数据"
description="完成第一次刷题或导入题库后,这里会展示你的备考概览。"
/>
<div v-else-if="data" class="dashboard__body">
<section class="hero">
<div class="hero__item">
<span class="hero__label">学习天数</span>
<strong class="num">{{ data.studyDays }}<em></em></strong>
<!-- ============ 移动端首页 ============ -->
<div v-else-if="isMobile && d" class="home">
<header class="home__hello">
<h1>{{ greeting }}</h1>
</header>
<!-- hero -->
<section class="home-hero">
<div class="home-hero__item">
<strong class="num">{{ d.studyDays }}</strong>
<span class="home-hero__label">备考天数</span>
<span class="home-hero__sub">完成 {{ goalProgress }}%</span>
</div>
<div class="hero__item">
<span class="hero__label">累计答题</span>
<strong class="num">{{ data.totalAnswered }}<em></em></strong>
</div>
<div class="hero__item">
<span class="hero__label">正确率</span>
<strong class="num">{{ data.accuracy }}<em>%</em></strong>
<div class="home-hero__divider" />
<div class="home-hero__item">
<strong class="num">{{ d.daysToExam }}</strong>
<span class="home-hero__label">距离国考</span>
<span class="home-hero__sub">{{ formatDateShort(d.examDate) }} 笔试</span>
</div>
</section>
<div class="stat-grid">
<AppCard v-for="s in stats" :key="s.key" class="stat">
<div class="stat__icon" :class="{ 'stat__icon--danger': s.tone === 'danger', 'stat__icon--success': s.tone === 'success' }">
<AppIcon :name="s.icon" :size="18" />
<!-- 今日学习 -->
<AppCard class="home-study">
<div class="home-study__row">
<span class="home-study__label">今日学习</span>
<strong class="home-study__value">{{ formatMinutes(d.todayMinutes) }}</strong>
</div>
</AppCard>
<!-- 三指标 -->
<div class="home-metrics">
<div class="home-metric">
<strong class="num">{{ d.todayAnswered }}<em></em></strong>
<span class="home-metric__label">今日刷题</span>
</div>
<div class="home-metric">
<strong class="num">{{ d.todayAccuracy }}<em>%</em></strong>
<span class="home-metric__label">今日正确率</span>
</div>
<div class="home-metric">
<strong class="num">{{ d.todayTasksDone }}<em>/{{ d.todayTasksTotal }}</em></strong>
<span class="home-metric__label">今日任务</span>
</div>
</div>
<!-- CTA -->
<div class="home-cta">
<RouterLink class="home-cta__primary" to="/practice">开始刷题</RouterLink>
<RouterLink class="home-cta__secondary" to="/plan">今日计划</RouterLink>
</div>
<!-- 待复习错题 -->
<AppCard class="home-review">
<div class="home-review__row">
<div class="home-review__info">
<span class="home-review__icon"><AppIcon name="alert" :size="18" /></span>
<div class="home-review__meta">
<span class="home-review__label">待复习错题</span>
<span class="home-review__sub">今日新增 {{ d.todayNewWrong }} · 待复习 {{ d.pendingReview }} </span>
</div>
</div>
<div class="stat__meta">
<span class="stat__label">{{ s.label }}</span>
<strong class="num">{{ s.value }}<em>{{ s.unit }}</em></strong>
<RouterLink class="home-review__more" to="/practice/wrong">去复习</RouterLink>
</div>
</AppCard>
<!-- 本周薄弱考点 -->
<WeakPointsCard :weak-points="weakItems" />
<!-- 最近模考 -->
<AppCard title="最近模考" icon="chart">
<AppEmpty v-if="!latestMock" icon="chart" title="暂无模考成绩" description="录入行测模考成绩后展示最近一次。" />
<div v-else class="home-mock">
<div class="home-mock__head">
<span class="home-mock__name">{{ latestMock.name }}</span>
<span class="home-mock__date">{{ formatDateShort(latestMock.date) }} · 行测</span>
</div>
</AppCard>
<div class="home-mock__score">
<strong class="num">{{ latestMock.total }}</strong>
<span class="home-mock__delta" :class="mockDelta !== null && mockDelta >= 0 ? 'is-up' : 'is-down'">
<template v-if="mockDelta !== null">较上次 {{ mockDelta >= 0 ? '+' : '' }}{{ mockDelta }}</template>
</span>
</div>
<div class="home-mock__goal">
距目标 {{ targetScore }} · 还差 {{ Math.max(0, targetScore - latestMock.total) }}
</div>
</div>
</AppCard>
</div>
<!-- ============ 桌面端数据中枢 ============ -->
<div v-else-if="d" class="hub">
<header class="page-head">
<div>
<h1>数据中枢</h1>
<p>备考第 {{ d.studyDays }} 距离国考还有 {{ d.daysToExam }} 已连续打卡 {{ d.streakDays }} </p>
</div>
<AppBadge v-if="d.checkedInToday" variant="success">今日已打卡</AppBadge>
</header>
<!-- 指标卡 -->
<div class="hub-stats">
<StatCard
label="今日学习时长"
:value="formatMinutes(d.todayMinutes)"
:sub="minutesDeltaText"
icon="clock"
:sub-tone="(d.minutesDelta ?? 0) >= 0 ? 'success' : 'danger'"
/>
<StatCard
label="今日刷题"
:value="`${d.todayAnswered}题`"
:sub="`正确率${d.todayAccuracy}%`"
icon="pen"
sub-tone="success"
/>
<StatCard
label="待复习错题"
:value="`${d.pendingReview}道`"
:sub="`今日新增${d.todayNewWrong}道`"
icon="alert"
:tone="d.pendingReview > 0 ? 'danger' : 'default'"
:sub-tone="d.todayNewWrong > 0 ? 'danger' : 'default'"
/>
<StatCard
label="今日任务"
:value="`${d.todayTasksDone}/${d.todayTasksTotal}`"
:sub="`${tasksCompletion}%完成`"
icon="check"
tone="warning"
/>
</div>
<!-- 模考趋势 + 今日任务 -->
<div class="hub-grid">
<MockTrendCard :trend="mockTrend" :target-score="targetScore" />
<TodayTasksCard :day="today.data.value" />
</div>
<!-- 模块掌握度 + 薄弱考点 -->
<div class="hub-grid">
<MasteryCard :mastery="masteryItems" />
<WeakPointsCard :weak-points="weakItems" compact />
</div>
</div>
</div>
@ -107,7 +242,20 @@ const stats = computed<StatItem[]>(() => {
flex-direction: column;
gap: var(--space-5);
}
.num {
font-family: var(--font-num);
font-weight: 700;
}
/* ---------- 桌面 ---------- */
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.page-head h1 {
margin: 0;
font-size: var(--fs-h1);
font-weight: var(--fw-h1);
line-height: var(--lh-h1);
@ -118,109 +266,222 @@ const stats = computed<StatItem[]>(() => {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.dashboard__body {
.hub {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero {
.hub-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--card-gap-desktop);
}
.hub-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--card-gap-desktop);
align-items: start;
}
@media (max-width: 1023px) {
.hub-stats {
grid-template-columns: repeat(2, 1fr);
}
.hub-grid {
grid-template-columns: 1fr;
}
}
/* ---------- 移动 ---------- */
.home {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.home__hello h1 {
margin: 0;
font-size: 22px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.home-hero {
display: flex;
align-items: stretch;
gap: var(--space-4);
background: var(--gradient-hero-deep);
color: var(--on-accent);
border-radius: var(--radius-xl);
padding: var(--card-padding-desktop);
padding: var(--card-padding-mobile);
}
.hero__item {
.home-hero__item {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: 4px;
}
.hero__label {
font-size: var(--fs-label);
opacity: 0.85;
}
.hero__item strong {
.home-hero__item .num {
font-size: 30px;
font-weight: 700;
line-height: 1.1;
}
.hero__item em {
font-style: normal;
font-size: 14px;
font-weight: 400;
margin-left: 4px;
.home-hero__label {
font-size: var(--fs-label);
opacity: 0.85;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--card-gap-desktop);
.home-hero__sub {
font-size: var(--fs-label);
opacity: 0.7;
}
.stat {
.home-hero__divider {
width: 1px;
background: rgba(255, 255, 255, 0.2);
}
.home-study__row {
display: flex;
align-items: center;
gap: var(--space-3);
justify-content: space-between;
}
.stat__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: var(--radius-md);
background: var(--bg-soft);
color: var(--primary);
}
.stat__icon--danger {
background: var(--danger-soft);
color: var(--danger);
}
.stat__icon--success {
background: var(--success-soft);
color: var(--success);
}
.stat__meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat__label {
font-size: var(--fs-label);
.home-study__label {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.stat__meta strong {
font-size: var(--fs-data-num);
.home-study__value {
font-family: var(--font-num);
font-size: 22px;
font-weight: 700;
line-height: var(--lh-data-num);
color: var(--text-primary);
}
.stat__meta em {
.home-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
.home-metric {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-3) var(--space-2);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.home-metric .num {
font-size: 18px;
color: var(--text-primary);
}
.home-metric .num em {
font-style: normal;
font-size: var(--fs-label);
font-weight: 400;
color: var(--text-muted);
margin-left: 2px;
}
@media (max-width: 1023px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.home-metric__label {
font-size: var(--fs-label);
color: var(--text-secondary);
}
@media (max-width: 767px) {
.hero {
grid-template-columns: 1fr;
gap: var(--space-4);
padding: var(--card-padding-mobile);
}
.hero__item strong {
font-size: 26px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--card-gap-mobile);
}
.home-cta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.home-cta__primary,
.home-cta__secondary {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--btn-height-pill);
border-radius: var(--radius-pill);
font-size: var(--fs-button);
font-weight: var(--fw-button);
text-decoration: none;
}
.home-cta__primary {
background: var(--primary);
color: var(--on-accent);
}
.home-cta__secondary {
background: var(--bg-card);
color: var(--primary);
border: 1px solid var(--primary);
}
.home-review__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.home-review__info {
display: flex;
align-items: center;
gap: var(--space-3);
}
.home-review__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: var(--radius-md);
background: var(--danger-soft);
color: var(--danger);
}
.home-review__meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.home-review__label {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.home-review__sub {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.home-review__more {
font-size: var(--fs-label);
color: var(--primary);
text-decoration: none;
flex: none;
}
.home-mock__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.home-mock__name {
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.home-mock__date {
font-size: var(--fs-label);
color: var(--text-muted);
}
.home-mock__score {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-top: var(--space-2);
}
.home-mock__score .num {
font-size: 30px;
color: var(--text-primary);
}
.home-mock__delta {
font-size: var(--fs-label);
}
.home-mock__delta.is-up {
color: var(--danger);
}
.home-mock__delta.is-down {
color: var(--success);
}
.home-mock__goal {
margin-top: var(--space-1);
font-size: var(--fs-label);
color: var(--text-secondary);
}
</style>

90
docs/checks/task-05.md Normal file
View File

@ -0,0 +1,90 @@
# 任务 05 检查记录:数据中枢纵向闭环
日期2026-08-31含对照原型图的二次校准
## 交付内容
### 后端统计服务与接口
- `server/src/services/stats.ts`(统计唯一来源):`collectStudyDates`(当日学习行为日期集合)、`computeStreak`(连续打卡)、`dailyMinutes`(当日耗时)、`computeDaysToExam`(距离考试天数)、`computeMastery`(五大模块最近 30 天正确率)、`computeWeakPoints`(最近 7 天错题按子模块聚合 → 次数 + 错因)。
- `server/src/handlers/dashboard.ts``overview` 返回备考天数/距离考试天数/考试日期/目标分/连续打卡/今日是否打卡/今日时长/较昨日百分比/今日刷题/今日正确率/今日任务/待复习错题/今日新增错题等;`trends` 补齐每日 minutes新增 `mastery``weakPoints`。路由 31 → 33。
- `server/src/schemas/api.ts``DashboardOverviewResponseSchema` 扩展字段、`DashboardWeakPointsResponseSchema` 改为 `{ module, point, count, reasons }`
### 演示数据
- 题库 18 题覆盖五大模块(含行程问题/图形推理/增长率各多题);相对「今天」生成近 14 天作答记录、5 场模考54→67 上升趋势、7 条错题(覆盖 4 个子模块、含错因)。
### 前端数据中枢
- 侧栏(`router/nav.ts` + `DesktopLayout.vue`):「刷题中心」展开子菜单(刷题/错题本/申论),子项缩进;`router` 增加 `/practice/wrong``/practice/essay`
- `api/index.ts``dashboardApi` 增加 `mastery`/`weakPoints`
- 组件:`AppLineChart`SVG 折线 + 目标参考线)、`StatCard`(指标卡)、`MockTrendCard`(模考成绩趋势 + 稳定上升标签)、`MasteryCard`(掌握度)、`WeakPointsCard`薄弱考点compact 切换)、`TodayTasksCard`(今日任务 + 查看全部)。
- `DashboardView`:桌面「数据中枢」(页头标题+副标题+今日已打卡徽章 → 4 指标卡 → 模考趋势+今日任务 → 掌握度+薄弱考点)与移动「首页」(问候语 → hero 备考天数/距离国考 → 今日学习 → 三指标 → 开始刷题/今日计划 → 待复习错题 → 薄弱考点 → 最近模考)共用同一份后端数据,按 `isMobile` 条件渲染。
- `MobileLayout`:首页隐藏顶部栏标题(以问候语作为页头)。
## 对照原型图OCR 提取 + 结构核对)
使用 pymupdf + rapidocr 提取原型 PNG 文字与坐标,重建布局后逐项对齐:
| 原型要素 | 实现 |
|---|---|
| 桌面:页头「数据中枢」+「备考第86天距离国考还有96天已连续打卡12天」+「今日已打卡」徽章 | ✅ 副标题/徽章/连续打卡均真实计算 |
| 桌面4 指标卡(今日学习时长/今日刷题/待复习错题/今日任务,含子文案与语义色) | ✅ StatCard |
| 桌面:模考成绩趋势(折线+目标线+稳定上升标签+近N场 | ✅ MockTrendCard + AppLineChart |
| 桌面:今日任务(查看全部+列表) | ✅ TodayTasksCard |
| 桌面模块掌握度5 模块进度+百分比) | ✅ MasteryCard |
| 桌面:本周薄弱考点(子模块+次数) | ✅ WeakPointsCard(compact) |
| 桌面侧栏:刷题中心展开刷题/错题本/申论 | ✅ 子菜单 |
| 移动:问候语 + hero(备考天数/距离国考) + 今日学习 + 三指标 + 开始刷题/今日计划 + 待复习错题 + 薄弱考点(含错因) + 最近模考 | ✅ 全部落地 |
## 接口实测curl
| 接口 | 结果 |
|---|---|
| `GET /api/dashboard/overview` | `studyDays 92 / daysToExam 91 / examDate 2026-11-30 / target 72 / streak 6 / checkedInToday true / todayMinutes 25 / minutesDelta 39 / todayAnswered 8 / todayAccuracy 63 / tasks 1/3 / pendingReview 7 / todayNewWrong 2` |
| `GET /api/dashboard/mastery` | 言语 92% / 数量 36% / 判断 90% / 资料 22% / 常识 89% |
| `GET /api/dashboard/weak-points` | 行程问题(2·公式记忆不牢/审题不清)、图形推理(2)、增长率计算(2)、逻辑判断(1) |
| `GET /api/mock-exams/analysis` | records 67/64/61/58/54trend 54→67target 72 |
## 浏览器检查agent-browser
| 场景 | 结果 |
|---|---|
| 桌面 1440 | 侧栏子菜单、页头徽章、4 指标卡、模考趋势折线、今日任务、掌握度、薄弱考点均渲染 |
| 移动 390 | 问候语、hero、今日学习、三指标、CTA、待复习、薄弱考点、最近模考、TabBar`scrollWidth 380 ≤ 390` 无溢出 |
| 空状态 | 清空数据 → 「还没有学习数据」;恢复后正常 |
| 命令 | `server tsc``client vue-tsc``vite build` 均通过 |
截图:`deliverables/checks/task-05-desktop.png``task-05-mobile.png`
## 完成标准核对
- ✅ 有演示数据时指标、图表、任务、掌握度、薄弱考点全部可见。
- ✅ 清空数据显示合理空状态。
- ✅ 刷新后结果一致(数据均来自后端 JSON
- ✅ PC 数据看板与 Mobile 首页共用同一份后端数据。
- ✅ 页面布局与原型图结构对齐(含「模考成绩趋势」而非学习趋势)。
## 当前结论
任务 05 完成并按原型图二次校准:数据中枢桌面版与移动首页的结构、板块、字段均对照原型重建,统计计算真实接入后端数据。
# 任务 05 二次调整2026-08-31
## 完成了什么
依据原型图与《开发计划》完成任务 05「数据中枢纵向闭环」打通「后端统计计算 → 接口 → 前端页面」完整链路,并对照原型图二次校准了 PC 数据看板与 Mobile 首页的结构。
- **后端**:新增统计服务 `services/stats.ts`(连续打卡 / 每日时长 / 距离考试 / 模块掌握度 / 薄弱考点);扩展 `overview` 返回备考天数、距离考试、连续打卡、今日四项指标等;新增 `GET /api/dashboard/mastery``GET /api/dashboard/weak-points`(路由 31 → 33
- **演示数据**:题库 18 题覆盖五大模块,作答记录 51 条、模考 5 场(上升趋势)、错题 7 条(含错因),日期相对当天生成。
- **前端**:数据中枢页重做为五板块——页头(标题+副标题+打卡徽章、4 指标卡、模考成绩趋势、今日任务、模块掌握度、本周薄弱考点;移动首页为问候 + hero + 今日学习 + 三指标 + CTA + 待复习 + 薄弱考点 + 最近模考;侧栏加「刷题中心」子菜单。
## 关键决策
- 「趋势」按原型对应「模考成绩趋势」(来自模考数据),非学习趋势。
- 薄弱考点按原型改为「基于错题错因归因」聚合(最近 7 天,次数 + 错因),与「模块掌握度」(正确率)分离。
- 统计计算集中在后端 `services/stats.ts`,前端仅做展示型派生(如完成百分比、较上次差值)。
- 图表用自绘 SVG`AppLineChart`),不引入 ECharts延续「不引重依赖」约定。
## 验证结论
- 有数据时指标、图表、任务、掌握度、薄弱考点全部可见;清空数据显示空状态;刷新一致。
- 桌面 1440 / 移动 390 双端实测渲染正常移动无横向溢出typecheck / build 通过。
- 结构已逐项对照原型 OCR 结果对齐。

View File

@ -1 +1,77 @@
[]
[
{
"id": "mock-demo-1",
"name": "行测全真模考(一)",
"date": "2026-08-01",
"total": 54,
"modules": {
"言语理解": 12,
"数量关系": 9,
"判断推理": 12,
"资料分析": 10,
"常识判断": 11
},
"note": "演示模考数据",
"createdAt": "2026-08-01T20:00:00+08:00"
},
{
"id": "mock-demo-2",
"name": "行测全真模考(二)",
"date": "2026-08-09",
"total": 58,
"modules": {
"言语理解": 13,
"数量关系": 10,
"判断推理": 12,
"资料分析": 11,
"常识判断": 12
},
"note": "演示模考数据",
"createdAt": "2026-08-09T20:00:00+08:00"
},
{
"id": "mock-demo-3",
"name": "行测全真模考(三)",
"date": "2026-08-16",
"total": 61,
"modules": {
"言语理解": 13,
"数量关系": 11,
"判断推理": 13,
"资料分析": 12,
"常识判断": 12
},
"note": "演示模考数据",
"createdAt": "2026-08-16T20:00:00+08:00"
},
{
"id": "mock-demo-4",
"name": "行测全真模考(四)",
"date": "2026-08-23",
"total": 64,
"modules": {
"言语理解": 14,
"数量关系": 12,
"判断推理": 13,
"资料分析": 12,
"常识判断": 13
},
"note": "演示模考数据",
"createdAt": "2026-08-23T20:00:00+08:00"
},
{
"id": "mock-demo-5",
"name": "行测全真模考(五)",
"date": "2026-08-30",
"total": 67,
"modules": {
"言语理解": 14,
"数量关系": 12,
"判断推理": 14,
"资料分析": 13,
"常识判断": 14
},
"note": "演示模考数据",
"createdAt": "2026-08-30T20:00:00+08:00"
}
]

View File

@ -1 +1,410 @@
[]
[
{
"questionId": "demo-q-001",
"sessionId": "demo-session-0-0",
"userAnswer": "A",
"correct": true,
"secondsUsed": 120,
"answeredAt": "2026-08-31T08:00:00+08:00"
},
{
"questionId": "demo-q-002",
"sessionId": "demo-session-0-1",
"userAnswer": "B",
"correct": true,
"secondsUsed": 150,
"answeredAt": "2026-08-31T09:07:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-0-2",
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-31T10:14:00+08:00"
},
{
"questionId": "demo-q-007",
"sessionId": "demo-session-0-3",
"userAnswer": "B",
"correct": true,
"secondsUsed": 90,
"answeredAt": "2026-08-31T11:21:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-0-4",
"userAnswer": "A",
"correct": false,
"secondsUsed": 360,
"answeredAt": "2026-08-31T12:28:00+08:00"
},
{
"questionId": "demo-q-012",
"sessionId": "demo-session-0-5",
"userAnswer": "C",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-31T13:35:00+08:00"
},
{
"questionId": "demo-q-005",
"sessionId": "demo-session-0-6",
"userAnswer": "B",
"correct": true,
"secondsUsed": 180,
"answeredAt": "2026-08-31T14:42:00+08:00"
},
{
"questionId": "demo-q-008",
"sessionId": "demo-session-0-7",
"userAnswer": "A",
"correct": false,
"secondsUsed": 240,
"answeredAt": "2026-08-31T15:49:00+08:00"
},
{
"questionId": "demo-q-003",
"sessionId": "demo-session-1-8",
"userAnswer": "A",
"correct": true,
"secondsUsed": 180,
"answeredAt": "2026-08-30T16:56:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-1-9",
"userAnswer": "A",
"correct": false,
"secondsUsed": 240,
"answeredAt": "2026-08-30T17:03:00+08:00"
},
{
"questionId": "demo-q-009",
"sessionId": "demo-session-1-10",
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-30T08:10:00+08:00"
},
{
"questionId": "demo-q-011",
"sessionId": "demo-session-1-11",
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-30T09:17:00+08:00"
},
{
"questionId": "demo-q-013",
"sessionId": "demo-session-1-12",
"userAnswer": "A",
"correct": true,
"secondsUsed": 50,
"answeredAt": "2026-08-30T10:24:00+08:00"
},
{
"questionId": "demo-q-006",
"sessionId": "demo-session-1-13",
"userAnswer": "A",
"correct": false,
"secondsUsed": 260,
"answeredAt": "2026-08-30T11:31:00+08:00"
},
{
"questionId": "demo-q-001",
"sessionId": "demo-session-2-14",
"userAnswer": "A",
"correct": true,
"secondsUsed": 130,
"answeredAt": "2026-08-29T12:38:00+08:00"
},
{
"questionId": "demo-q-007",
"sessionId": "demo-session-2-15",
"userAnswer": "B",
"correct": true,
"secondsUsed": 95,
"answeredAt": "2026-08-29T13:45:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-2-16",
"userAnswer": "C",
"correct": true,
"secondsUsed": 320,
"answeredAt": "2026-08-29T14:52:00+08:00"
},
{
"questionId": "demo-q-014",
"sessionId": "demo-session-2-17",
"userAnswer": "A",
"correct": false,
"secondsUsed": 200,
"answeredAt": "2026-08-29T15:59:00+08:00"
},
{
"questionId": "demo-q-012",
"sessionId": "demo-session-2-18",
"userAnswer": "C",
"correct": true,
"secondsUsed": 70,
"answeredAt": "2026-08-29T16:06:00+08:00"
},
{
"questionId": "demo-q-002",
"sessionId": "demo-session-3-19",
"userAnswer": "B",
"correct": true,
"secondsUsed": 140,
"answeredAt": "2026-08-28T17:13:00+08:00"
},
{
"questionId": "demo-q-005",
"sessionId": "demo-session-3-20",
"userAnswer": "B",
"correct": true,
"secondsUsed": 190,
"answeredAt": "2026-08-28T08:20:00+08:00"
},
{
"questionId": "demo-q-008",
"sessionId": "demo-session-3-21",
"userAnswer": "B",
"correct": true,
"secondsUsed": 200,
"answeredAt": "2026-08-28T09:27:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-3-22",
"userAnswer": "A",
"correct": false,
"secondsUsed": 310,
"answeredAt": "2026-08-28T10:34:00+08:00"
},
{
"questionId": "demo-q-003",
"sessionId": "demo-session-3-23",
"userAnswer": "A",
"correct": true,
"secondsUsed": 170,
"answeredAt": "2026-08-28T11:41:00+08:00"
},
{
"questionId": "demo-q-015",
"sessionId": "demo-session-3-24",
"userAnswer": "B",
"correct": false,
"secondsUsed": 280,
"answeredAt": "2026-08-28T12:48:00+08:00"
},
{
"questionId": "demo-q-013",
"sessionId": "demo-session-3-25",
"userAnswer": "A",
"correct": true,
"secondsUsed": 55,
"answeredAt": "2026-08-28T13:55:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-4-26",
"userAnswer": "A",
"correct": false,
"secondsUsed": 260,
"answeredAt": "2026-08-27T14:02:00+08:00"
},
{
"questionId": "demo-q-009",
"sessionId": "demo-session-4-27",
"userAnswer": "A",
"correct": true,
"secondsUsed": 65,
"answeredAt": "2026-08-27T15:09:00+08:00"
},
{
"questionId": "demo-q-012",
"sessionId": "demo-session-4-28",
"userAnswer": "A",
"correct": false,
"secondsUsed": 75,
"answeredAt": "2026-08-27T16:16:00+08:00"
},
{
"questionId": "demo-q-001",
"sessionId": "demo-session-4-29",
"userAnswer": "A",
"correct": true,
"secondsUsed": 125,
"answeredAt": "2026-08-27T17:23:00+08:00"
},
{
"questionId": "demo-q-007",
"sessionId": "demo-session-5-30",
"userAnswer": "B",
"correct": true,
"secondsUsed": 100,
"answeredAt": "2026-08-26T08:30:00+08:00"
},
{
"questionId": "demo-q-011",
"sessionId": "demo-session-5-31",
"userAnswer": "B",
"correct": true,
"secondsUsed": 320,
"answeredAt": "2026-08-26T09:37:00+08:00"
},
{
"questionId": "demo-q-002",
"sessionId": "demo-session-5-32",
"userAnswer": "B",
"correct": true,
"secondsUsed": 155,
"answeredAt": "2026-08-26T10:44:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-5-33",
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-26T11:51:00+08:00"
},
{
"questionId": "demo-q-013",
"sessionId": "demo-session-5-34",
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-26T12:58:00+08:00"
},
{
"questionId": "demo-q-006",
"sessionId": "demo-session-5-35",
"userAnswer": "B",
"correct": true,
"secondsUsed": 250,
"answeredAt": "2026-08-26T13:05:00+08:00"
},
{
"questionId": "demo-q-001",
"sessionId": "demo-session-7-36",
"userAnswer": "A",
"correct": true,
"secondsUsed": 135,
"answeredAt": "2026-08-24T14:12:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-7-37",
"userAnswer": "A",
"correct": false,
"secondsUsed": 270,
"answeredAt": "2026-08-24T15:19:00+08:00"
},
{
"questionId": "demo-q-009",
"sessionId": "demo-session-7-38",
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-24T16:26:00+08:00"
},
{
"questionId": "demo-q-008",
"sessionId": "demo-session-9-39",
"userAnswer": "B",
"correct": true,
"secondsUsed": 210,
"answeredAt": "2026-08-22T17:33:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-9-40",
"userAnswer": "A",
"correct": false,
"secondsUsed": 330,
"answeredAt": "2026-08-22T08:40:00+08:00"
},
{
"questionId": "demo-q-012",
"sessionId": "demo-session-9-41",
"userAnswer": "C",
"correct": true,
"secondsUsed": 70,
"answeredAt": "2026-08-22T09:47:00+08:00"
},
{
"questionId": "demo-q-002",
"sessionId": "demo-session-9-42",
"userAnswer": "B",
"correct": true,
"secondsUsed": 145,
"answeredAt": "2026-08-22T10:54:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-11-43",
"userAnswer": "A",
"correct": false,
"secondsUsed": 280,
"answeredAt": "2026-08-20T11:01:00+08:00"
},
{
"questionId": "demo-q-013",
"sessionId": "demo-session-11-44",
"userAnswer": "A",
"correct": true,
"secondsUsed": 55,
"answeredAt": "2026-08-20T12:08:00+08:00"
},
{
"questionId": "demo-q-005",
"sessionId": "demo-session-11-45",
"userAnswer": "B",
"correct": true,
"secondsUsed": 175,
"answeredAt": "2026-08-20T13:15:00+08:00"
},
{
"questionId": "demo-q-003",
"sessionId": "demo-session-13-46",
"userAnswer": "A",
"correct": true,
"secondsUsed": 185,
"answeredAt": "2026-08-18T14:22:00+08:00"
},
{
"questionId": "demo-q-010",
"sessionId": "demo-session-13-47",
"userAnswer": "A",
"correct": false,
"secondsUsed": 340,
"answeredAt": "2026-08-18T15:29:00+08:00"
},
{
"questionId": "demo-q-007",
"sessionId": "demo-session-13-48",
"userAnswer": "B",
"correct": true,
"secondsUsed": 110,
"answeredAt": "2026-08-18T16:36:00+08:00"
},
{
"questionId": "demo-q-012",
"sessionId": "demo-session-13-49",
"userAnswer": "C",
"correct": true,
"secondsUsed": 80,
"answeredAt": "2026-08-18T17:43:00+08:00"
},
{
"questionId": "demo-q-004",
"sessionId": "demo-session-13-50",
"userAnswer": "A",
"correct": false,
"secondsUsed": 250,
"answeredAt": "2026-08-18T08:50:00+08:00"
}
]

View File

@ -2,39 +2,6 @@
{
"id": "demo-q-001",
"type": "行测",
"module": "数量关系",
"subModule": "行程问题",
"difficulty": "中等",
"stem": "甲、乙两车分别从 A、B 两地同时出发相向而行,甲车速度为 60km/h乙车速度为 40km/h两车相遇后甲车又行驶 2 小时到达 B 地,问 A、B 两地相距多少千米?",
"options": [
{
"key": "A",
"text": "240 千米"
},
{
"key": "B",
"text": "260 千米"
},
{
"key": "C",
"text": "280 千米"
},
{
"key": "D",
"text": "300 千米"
}
],
"answer": "C",
"analysis": "相遇后甲车行驶 60×2=120 千米,此路程等于乙车相遇前行驶的路程。由速度比可得相遇时间为 3 小时,两地距离为 100×3=300 千米。",
"tags": [
"相遇问题"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-002",
"type": "行测",
"module": "言语理解",
"subModule": "逻辑填空",
"difficulty": "简单",
@ -65,9 +32,174 @@
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-002",
"type": "行测",
"module": "言语理解",
"subModule": "片段阅读",
"difficulty": "中等",
"stem": "城市更新不是简单的“拆旧建新”而是要在保留文脉的基础上注入现代功能。这段文字意在强调____。",
"options": [
{
"key": "A",
"text": "城市更新应当完全保留旧建筑"
},
{
"key": "B",
"text": "城市更新要兼顾文脉传承与现代功能"
},
{
"key": "C",
"text": "现代功能比文脉传承更重要"
},
{
"key": "D",
"text": "拆旧建新是最高效的方式"
}
],
"answer": "B",
"analysis": "文段用“不是……而是……”强调城市更新的真正内涵是兼顾传承与功能。",
"tags": [
"主旨概括"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-003",
"type": "行测",
"module": "言语理解",
"subModule": "语句排序",
"difficulty": "中等",
"stem": "①这要求我们既要有战略定力 ②更要蹄疾步稳、久久为功 ③改革进入深水区 ④把顶层设计与基层探索结合起来。将以上句子组成语意连贯的语段排序正确的是____。",
"options": [
{
"key": "A",
"text": "③①④②"
},
{
"key": "B",
"text": "①③②④"
},
{
"key": "C",
"text": "③④②①"
},
{
"key": "D",
"text": "④③①②"
}
],
"answer": "A",
"analysis": "③提出背景“改革进入深水区”,①承接“要求有战略定力”,④补充方法,②收束强调“更要”。",
"tags": [
"语句衔接"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-004",
"type": "行测",
"module": "数量关系",
"subModule": "行程问题",
"difficulty": "中等",
"stem": "甲、乙两车分别从 A、B 两地同时出发相向而行,甲车速度 60km/h乙车速度 40km/h相遇后甲车又行驶 2 小时到达 B 地,问 A、B 两地相距多少千米?",
"options": [
{
"key": "A",
"text": "240 千米"
},
{
"key": "B",
"text": "260 千米"
},
{
"key": "C",
"text": "300 千米"
},
{
"key": "D",
"text": "280 千米"
}
],
"answer": "C",
"analysis": "相遇后甲车行驶 60×2=120 千米,等于乙车相遇前行驶路程;由速度比可得相遇时间 3 小时,两地相距 100×3=300 千米。",
"tags": [
"相遇问题"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-005",
"type": "行测",
"module": "数量关系",
"subModule": "工程问题",
"difficulty": "中等",
"stem": "一项工程,甲单独做 12 天完成,乙单独做 18 天完成,两人合作 4 天后,剩余工程由乙单独完成,还需多少天?",
"options": [
{
"key": "A",
"text": "6 天"
},
{
"key": "B",
"text": "8 天"
},
{
"key": "C",
"text": "10 天"
},
{
"key": "D",
"text": "12 天"
}
],
"answer": "B",
"analysis": "合作 4 天完成 4×(1/12+1/18)=5/9剩余 4/9乙单独需 (4/9)÷(1/18)=8 天。",
"tags": [
"合作工程"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-006",
"type": "行测",
"module": "数量关系",
"subModule": "利润问题",
"difficulty": "困难",
"stem": "某商品按进价提高 50% 标价,再打八折出售,结果每件盈利 40 元,则该商品的进价是多少元?",
"options": [
{
"key": "A",
"text": "150 元"
},
{
"key": "B",
"text": "200 元"
},
{
"key": "C",
"text": "250 元"
},
{
"key": "D",
"text": "300 元"
}
],
"answer": "B",
"analysis": "设进价 x售价为 1.5x×0.8=1.2x,盈利 0.2x=40解得 x=200 元。",
"tags": [
"折扣问题"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-007",
"type": "行测",
"module": "判断推理",
"subModule": "图形推理",
"difficulty": "中等",
@ -87,7 +219,7 @@
},
{
"key": "D",
"text": "翻转"
"text": "上下翻转"
}
],
"answer": "B",
@ -97,5 +229,368 @@
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-008",
"type": "行测",
"module": "判断推理",
"subModule": "逻辑判断",
"difficulty": "中等",
"stem": "只有坚持问题导向才能找准改革的突破口。由此可以推出____。",
"options": [
{
"key": "A",
"text": "坚持问题导向就能找准突破口"
},
{
"key": "B",
"text": "找准突破口必须坚持问题导向"
},
{
"key": "C",
"text": "不坚持问题导向也能找准突破口"
},
{
"key": "D",
"text": "找准突破口与问题导向无关"
}
],
"answer": "B",
"analysis": "“只有 A 才 B”为必要条件等价于“B 推出 A”故找准突破口必然坚持问题导向。",
"tags": [
"必要条件"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-009",
"type": "行测",
"module": "判断推理",
"subModule": "类比推理",
"difficulty": "简单",
"stem": "教师:教书育人 与 ____ 最相似。",
"options": [
{
"key": "A",
"text": "医生:救死扶伤"
},
{
"key": "B",
"text": "医生:医院"
},
{
"key": "C",
"text": "教室:学生"
},
{
"key": "D",
"text": "讲台:粉笔"
}
],
"answer": "A",
"analysis": "题干为“职业:职责”关系,医生与救死扶伤同为职业与职责的关系。",
"tags": [
"对应关系"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-010",
"type": "行测",
"module": "资料分析",
"subModule": "增长率计算",
"difficulty": "中等",
"stem": "某地去年 GDP 为 5000 亿元,今年为 5750 亿元则今年的同比增长率为____。",
"options": [
{
"key": "A",
"text": "12%"
},
{
"key": "B",
"text": "13%"
},
{
"key": "C",
"text": "15%"
},
{
"key": "D",
"text": "16%"
}
],
"answer": "C",
"analysis": "增长率=(5750-5000)÷5000×100%=15%。",
"tags": [
"增长率"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-011",
"type": "行测",
"module": "资料分析",
"subModule": "基期量计算",
"difficulty": "困难",
"stem": "某地区今年粮食产量为 1260 万吨,同比增长 5%则去年粮食产量约为____。",
"options": [
{
"key": "A",
"text": "1180 万吨"
},
{
"key": "B",
"text": "1200 万吨"
},
{
"key": "C",
"text": "1210 万吨"
},
{
"key": "D",
"text": "1230 万吨"
}
],
"answer": "B",
"analysis": "基期量=1260÷(1+5%)=1260÷1.05=1200 万吨。",
"tags": [
"基期量"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-012",
"type": "行测",
"module": "常识判断",
"subModule": "法律常识",
"difficulty": "中等",
"stem": "根据《中华人民共和国民法典》自然人的民事权利能力始于____。",
"options": [
{
"key": "A",
"text": "年满 18 周岁"
},
{
"key": "B",
"text": "年满 16 周岁"
},
{
"key": "C",
"text": "出生"
},
{
"key": "D",
"text": "登记户口"
}
],
"answer": "C",
"analysis": "民事权利能力从出生时起到死亡时止,自然人一律平等享有。",
"tags": [
"民法"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-013",
"type": "行测",
"module": "常识判断",
"subModule": "时政热点",
"difficulty": "简单",
"stem": "新发展理念的核心内容包括创新、协调、绿色、开放、____。",
"options": [
{
"key": "A",
"text": "共享"
},
{
"key": "B",
"text": "公平"
},
{
"key": "C",
"text": "效率"
},
{
"key": "D",
"text": "安全"
}
],
"answer": "A",
"analysis": "新发展理念即创新、协调、绿色、开放、共享五大发展理念。",
"tags": [
"新发展理念"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-014",
"type": "行测",
"module": "言语理解",
"subModule": "逻辑填空",
"difficulty": "困难",
"stem": "面对复杂局面既要保持____的战略定力也要有____的应变能力二者不可偏废。",
"options": [
{
"key": "A",
"text": "临危不乱 见微知著"
},
{
"key": "B",
"text": "一成不变 随机应变"
},
{
"key": "C",
"text": "从容不迫 游刃有余"
},
{
"key": "D",
"text": "处变不惊 灵活机动"
}
],
"answer": "D",
"analysis": "“处变不惊”对应战略定力,“灵活机动”对应应变能力,语义搭配最恰当。",
"tags": [
"成语辨析"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-015",
"type": "行测",
"module": "资料分析",
"subModule": "增长率计算",
"difficulty": "中等",
"stem": "某企业一季度营收为 320 万元,二季度为 400 万元则二季度环比增长率约为____。",
"options": [
{
"key": "A",
"text": "25%"
},
{
"key": "B",
"text": "28%"
},
{
"key": "C",
"text": "30%"
},
{
"key": "D",
"text": "32%"
}
],
"answer": "A",
"analysis": "环比增长率=(400-320)÷320×100%=25%。",
"tags": [
"环比增长"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-016",
"type": "行测",
"module": "数量关系",
"subModule": "行程问题",
"difficulty": "困难",
"stem": "一艘船顺水航行 40 千米用 2 小时,逆水航行 24 千米用 3 小时,则水流速度是多少?",
"options": [
{
"key": "A",
"text": "3 km/h"
},
{
"key": "B",
"text": "4 km/h"
},
{
"key": "C",
"text": "5 km/h"
},
{
"key": "D",
"text": "6 km/h"
}
],
"answer": "D",
"analysis": "顺水速度 20 km/h逆水速度 8 km/h水流速度=(20-8)÷2=6 km/h。",
"tags": [
"流水行船"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-017",
"type": "行测",
"module": "判断推理",
"subModule": "图形推理",
"difficulty": "中等",
"stem": "下列图形中阴影部分的移动规律是每次顺时针移动一格则第四个图形阴影应位于____。",
"options": [
{
"key": "A",
"text": "左上角"
},
{
"key": "B",
"text": "右上角"
},
{
"key": "C",
"text": "右下角"
},
{
"key": "D",
"text": "左下角"
}
],
"answer": "C",
"analysis": "阴影按顺时针方向每次移动一格,第四个图形位于右下角。",
"tags": [
"位置变化"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
},
{
"id": "demo-q-018",
"type": "行测",
"module": "资料分析",
"subModule": "增长率计算",
"difficulty": "中等",
"stem": "某市今年居民人均可支配收入为 48000 元,同比增长 8%则去年约为____。",
"options": [
{
"key": "A",
"text": "44000 元"
},
{
"key": "B",
"text": "44444 元"
},
{
"key": "C",
"text": "45000 元"
},
{
"key": "D",
"text": "46000 元"
}
],
"answer": "B",
"analysis": "基期量=48000÷(1+8%)≈44444 元。",
"tags": [
"基期量"
],
"source": "内置演示题库",
"createdAt": "2026-08-27T00:00:00+08:00"
}
]

View File

@ -1,7 +1,7 @@
[
{
"id": "plan-demo-001",
"date": "2026-08-27",
"date": "2026-08-31",
"title": "言语理解专项",
"type": "刷题",
"target": "完成 20 题",
@ -9,18 +9,34 @@
},
{
"id": "plan-demo-002",
"date": "2026-08-27",
"date": "2026-08-31",
"title": "资料分析专项",
"type": "刷题",
"target": "完成 15 题",
"status": "pending"
},
{
"id": "plan-demo-003",
"date": "2026-08-31",
"title": "错题复盘",
"type": "复习",
"target": "复习 8 道",
"status": "pending"
},
{
"id": "plan-demo-004",
"date": "2026-08-30",
"title": "数量关系专项",
"type": "刷题",
"target": "完成 15 题",
"status": "done"
},
{
"id": "plan-demo-003",
"date": "2026-08-27",
"title": "错题复盘",
"type": "复习",
"target": "复习 8 道",
"status": "pending"
"id": "plan-demo-005",
"date": "2026-08-30",
"title": "常识积累",
"type": "阅读",
"target": "阅读 30 分钟",
"status": "done"
}
]

View File

@ -1 +1,65 @@
[]
[
{
"id": "wrong-demo-001",
"questionId": "demo-q-004",
"wrongReason": "公式记忆不牢",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-31T09:00:00+08:00"
},
{
"id": "wrong-demo-002",
"questionId": "demo-q-016",
"wrongReason": "审题不清",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-31T10:11:00+08:00"
},
{
"id": "wrong-demo-003",
"questionId": "demo-q-017",
"wrongReason": "思路偏差",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-30T11:22:00+08:00"
},
{
"id": "wrong-demo-004",
"questionId": "demo-q-007",
"wrongReason": "观察不细",
"status": "reviewing",
"reviewCount": 1,
"nextReviewAt": "2026-09-02",
"updatedAt": "2026-08-30T12:33:00+08:00"
},
{
"id": "wrong-demo-005",
"questionId": "demo-q-010",
"wrongReason": "粗心计算",
"status": "pending",
"reviewCount": 1,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-29T13:44:00+08:00"
},
{
"id": "wrong-demo-006",
"questionId": "demo-q-018",
"wrongReason": "概念混淆",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-29T14:55:00+08:00"
},
{
"id": "wrong-demo-007",
"questionId": "demo-q-008",
"wrongReason": "概念不清",
"status": "reviewing",
"reviewCount": 2,
"nextReviewAt": "2026-09-03",
"updatedAt": "2026-08-28T15:06:00+08:00"
}
]

View File

@ -1,50 +1,392 @@
import { dataFiles } from './files.js'
import { readData, writeData } from './store.js'
import type { NewsItem, Profile, Question, StudyPlan } from '../types/index.js'
import { addDays, todayISO } from '../utils/dates.js'
import type {
MockExam,
NewsItem,
PracticeRecord,
Profile,
Question,
StudyPlan,
WrongQuestion
} from '../schemas/entities.js'
/* ------------------------------------------------------------------
* 使
* ------------------------------------------------------------------ */
const questions: Question[] = [
{
id: 'demo-q-001', type: '行测', module: '数量关系', subModule: '行程问题', difficulty: '中等',
stem: '甲、乙两车分别从 A、B 两地同时出发相向而行,甲车速度为 60km/h乙车速度为 40km/h两车相遇后甲车又行驶 2 小时到达 B 地,问 A、B 两地相距多少千米?',
options: [{ key: 'A', text: '240 千米' }, { key: 'B', text: '260 千米' }, { key: 'C', text: '280 千米' }, { key: 'D', text: '300 千米' }], answer: 'C',
analysis: '相遇后甲车行驶 60×2=120 千米,此路程等于乙车相遇前行驶的路程。由速度比可得相遇时间为 3 小时,两地距离为 100×3=300 千米。', tags: ['相遇问题'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-002', type: '行测', module: '言语理解', subModule: '逻辑填空', difficulty: '简单',
id: 'demo-q-001', type: '行测', module: '言语理解', subModule: '逻辑填空', difficulty: '简单',
stem: '在公共治理中既要善于运用法治思维也要避免治理方式过于____让政策执行更有温度。',
options: [{ key: 'A', text: '机械' }, { key: 'B', text: '灵活' }, { key: 'C', text: '主动' }, { key: 'D', text: '积极' }], answer: 'A',
analysis: '根据“避免”与“更有温度”的语境,应选择表示僵化、不知变通的“机械”。', tags: ['语境分析'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
options: [
{ key: 'A', text: '机械' }, { key: 'B', text: '灵活' }, { key: 'C', text: '主动' }, { key: 'D', text: '积极' }
],
answer: 'A', analysis: '根据“避免”与“更有温度”的语境,应选择表示僵化、不知变通的“机械”。',
tags: ['语境分析'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-003', type: '行测', module: '判断推理', subModule: '图形推理', difficulty: '中等',
id: 'demo-q-002', type: '行测', module: '言语理解', subModule: '片段阅读', difficulty: '中等',
stem: '城市更新不是简单的“拆旧建新”而是要在保留文脉的基础上注入现代功能。这段文字意在强调____。',
options: [
{ key: 'A', text: '城市更新应当完全保留旧建筑' },
{ key: 'B', text: '城市更新要兼顾文脉传承与现代功能' },
{ key: 'C', text: '现代功能比文脉传承更重要' },
{ key: 'D', text: '拆旧建新是最高效的方式' }
],
answer: 'B', analysis: '文段用“不是……而是……”强调城市更新的真正内涵是兼顾传承与功能。',
tags: ['主旨概括'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-003', type: '行测', module: '言语理解', subModule: '语句排序', difficulty: '中等',
stem: '①这要求我们既要有战略定力 ②更要蹄疾步稳、久久为功 ③改革进入深水区 ④把顶层设计与基层探索结合起来。将以上句子组成语意连贯的语段排序正确的是____。',
options: [
{ key: 'A', text: '③①④②' }, { key: 'B', text: '①③②④' }, { key: 'C', text: '③④②①' }, { key: 'D', text: '④③①②' }
],
answer: 'A', analysis: '③提出背景“改革进入深水区”,①承接“要求有战略定力”,④补充方法,②收束强调“更要”。',
tags: ['语句衔接'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-004', type: '行测', module: '数量关系', subModule: '行程问题', difficulty: '中等',
stem: '甲、乙两车分别从 A、B 两地同时出发相向而行,甲车速度 60km/h乙车速度 40km/h相遇后甲车又行驶 2 小时到达 B 地,问 A、B 两地相距多少千米?',
options: [
{ key: 'A', text: '240 千米' }, { key: 'B', text: '260 千米' }, { key: 'C', text: '300 千米' }, { key: 'D', text: '280 千米' }
],
answer: 'C', analysis: '相遇后甲车行驶 60×2=120 千米,等于乙车相遇前行驶路程;由速度比可得相遇时间 3 小时,两地相距 100×3=300 千米。',
tags: ['相遇问题'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-005', type: '行测', module: '数量关系', subModule: '工程问题', difficulty: '中等',
stem: '一项工程,甲单独做 12 天完成,乙单独做 18 天完成,两人合作 4 天后,剩余工程由乙单独完成,还需多少天?',
options: [
{ key: 'A', text: '6 天' }, { key: 'B', text: '8 天' }, { key: 'C', text: '10 天' }, { key: 'D', text: '12 天' }
],
answer: 'B', analysis: '合作 4 天完成 4×(1/12+1/18)=5/9剩余 4/9乙单独需 (4/9)÷(1/18)=8 天。',
tags: ['合作工程'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-006', type: '行测', module: '数量关系', subModule: '利润问题', difficulty: '困难',
stem: '某商品按进价提高 50% 标价,再打八折出售,结果每件盈利 40 元,则该商品的进价是多少元?',
options: [
{ key: 'A', text: '150 元' }, { key: 'B', text: '200 元' }, { key: 'C', text: '250 元' }, { key: 'D', text: '300 元' }
],
answer: 'B', analysis: '设进价 x售价为 1.5x×0.8=1.2x,盈利 0.2x=40解得 x=200 元。',
tags: ['折扣问题'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-007', type: '行测', module: '判断推理', subModule: '图形推理', difficulty: '中等',
stem: '下列图形规律题中,若每次顺时针旋转 90°第三个图形应如何变化',
options: [{ key: 'A', text: '保持不变' }, { key: 'B', text: '顺时针旋转 90°' }, { key: 'C', text: '逆时针旋转 90°' }, { key: 'D', text: '翻转' }], answer: 'B',
analysis: '观察相邻图形可知,每一步均按顺时针方向旋转 90°。', tags: ['位置变化'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
options: [
{ key: 'A', text: '保持不变' }, { key: 'B', text: '顺时针旋转 90°' }, { key: 'C', text: '逆时针旋转 90°' }, { key: 'D', text: '上下翻转' }
],
answer: 'B', analysis: '观察相邻图形可知,每一步均按顺时针方向旋转 90°。',
tags: ['位置变化'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-008', type: '行测', module: '判断推理', subModule: '逻辑判断', difficulty: '中等',
stem: '只有坚持问题导向才能找准改革的突破口。由此可以推出____。',
options: [
{ key: 'A', text: '坚持问题导向就能找准突破口' },
{ key: 'B', text: '找准突破口必须坚持问题导向' },
{ key: 'C', text: '不坚持问题导向也能找准突破口' },
{ key: 'D', text: '找准突破口与问题导向无关' }
],
answer: 'B', analysis: '“只有 A 才 B”为必要条件等价于“B 推出 A”故找准突破口必然坚持问题导向。',
tags: ['必要条件'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-009', type: '行测', module: '判断推理', subModule: '类比推理', difficulty: '简单',
stem: '教师:教书育人 与 ____ 最相似。',
options: [
{ key: 'A', text: '医生:救死扶伤' }, { key: 'B', text: '医生:医院' }, { key: 'C', text: '教室:学生' }, { key: 'D', text: '讲台:粉笔' }
],
answer: 'A', analysis: '题干为“职业:职责”关系,医生与救死扶伤同为职业与职责的关系。',
tags: ['对应关系'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-010', type: '行测', module: '资料分析', subModule: '增长率计算', difficulty: '中等',
stem: '某地去年 GDP 为 5000 亿元,今年为 5750 亿元则今年的同比增长率为____。',
options: [
{ key: 'A', text: '12%' }, { key: 'B', text: '13%' }, { key: 'C', text: '15%' }, { key: 'D', text: '16%' }
],
answer: 'C', analysis: '增长率=(5750-5000)÷5000×100%=15%。',
tags: ['增长率'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-011', type: '行测', module: '资料分析', subModule: '基期量计算', difficulty: '困难',
stem: '某地区今年粮食产量为 1260 万吨,同比增长 5%则去年粮食产量约为____。',
options: [
{ key: 'A', text: '1180 万吨' }, { key: 'B', text: '1200 万吨' }, { key: 'C', text: '1210 万吨' }, { key: 'D', text: '1230 万吨' }
],
answer: 'B', analysis: '基期量=1260÷(1+5%)=1260÷1.05=1200 万吨。',
tags: ['基期量'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-012', type: '行测', module: '常识判断', subModule: '法律常识', difficulty: '中等',
stem: '根据《中华人民共和国民法典》自然人的民事权利能力始于____。',
options: [
{ key: 'A', text: '年满 18 周岁' }, { key: 'B', text: '年满 16 周岁' }, { key: 'C', text: '出生' }, { key: 'D', text: '登记户口' }
],
answer: 'C', analysis: '民事权利能力从出生时起到死亡时止,自然人一律平等享有。',
tags: ['民法'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-013', type: '行测', module: '常识判断', subModule: '时政热点', difficulty: '简单',
stem: '新发展理念的核心内容包括创新、协调、绿色、开放、____。',
options: [
{ key: 'A', text: '共享' }, { key: 'B', text: '公平' }, { key: 'C', text: '效率' }, { key: 'D', text: '安全' }
],
answer: 'A', analysis: '新发展理念即创新、协调、绿色、开放、共享五大发展理念。',
tags: ['新发展理念'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-014', type: '行测', module: '言语理解', subModule: '逻辑填空', difficulty: '困难',
stem: '面对复杂局面既要保持____的战略定力也要有____的应变能力二者不可偏废。',
options: [
{ key: 'A', text: '临危不乱 见微知著' }, { key: 'B', text: '一成不变 随机应变' }, { key: 'C', text: '从容不迫 游刃有余' }, { key: 'D', text: '处变不惊 灵活机动' }
],
answer: 'D', analysis: '“处变不惊”对应战略定力,“灵活机动”对应应变能力,语义搭配最恰当。',
tags: ['成语辨析'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-015', type: '行测', module: '资料分析', subModule: '增长率计算', difficulty: '中等',
stem: '某企业一季度营收为 320 万元,二季度为 400 万元则二季度环比增长率约为____。',
options: [
{ key: 'A', text: '25%' }, { key: 'B', text: '28%' }, { key: 'C', text: '30%' }, { key: 'D', text: '32%' }
],
answer: 'A', analysis: '环比增长率=(400-320)÷320×100%=25%。',
tags: ['环比增长'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-016', type: '行测', module: '数量关系', subModule: '行程问题', difficulty: '困难',
stem: '一艘船顺水航行 40 千米用 2 小时,逆水航行 24 千米用 3 小时,则水流速度是多少?',
options: [
{ key: 'A', text: '3 km/h' }, { key: 'B', text: '4 km/h' }, { key: 'C', text: '5 km/h' }, { key: 'D', text: '6 km/h' }
],
answer: 'D', analysis: '顺水速度 20 km/h逆水速度 8 km/h水流速度=(20-8)÷2=6 km/h。',
tags: ['流水行船'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-017', type: '行测', module: '判断推理', subModule: '图形推理', difficulty: '中等',
stem: '下列图形中阴影部分的移动规律是每次顺时针移动一格则第四个图形阴影应位于____。',
options: [
{ key: 'A', text: '左上角' }, { key: 'B', text: '右上角' }, { key: 'C', text: '右下角' }, { key: 'D', text: '左下角' }
],
answer: 'C', analysis: '阴影按顺时针方向每次移动一格,第四个图形位于右下角。',
tags: ['位置变化'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
},
{
id: 'demo-q-018', type: '行测', module: '资料分析', subModule: '增长率计算', difficulty: '中等',
stem: '某市今年居民人均可支配收入为 48000 元,同比增长 8%则去年约为____。',
options: [
{ key: 'A', text: '44000 元' }, { key: 'B', text: '44444 元' }, { key: 'C', text: '45000 元' }, { key: 'D', text: '46000 元' }
],
answer: 'B', analysis: '基期量=48000÷(1+8%)≈44444 元。',
tags: ['基期量'], source: '内置演示题库', createdAt: '2026-08-27T00:00:00+08:00'
}
]
const profile: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: '2026-06-01' }
const plans: StudyPlan[] = [
{ id: 'plan-demo-001', date: '2026-08-27', title: '言语理解专项', type: '刷题', target: '完成 20 题', status: 'done' },
{ id: 'plan-demo-002', date: '2026-08-27', title: '数量关系专项', type: '刷题', target: '完成 15 题', status: 'done' },
{ id: 'plan-demo-003', date: '2026-08-27', title: '错题复盘', type: '复习', target: '复习 8 道', status: 'pending' }
]
const news: NewsItem[] = [{ id: 'news-demo-001', title: '聚焦高质量发展,把握时代脉搏', category: '政治', summary: '持续推动高质量发展,为中国式现代化夯实基础。', content: '## 今日要闻\n\n高质量发展是全面建设社会主义现代化国家的首要任务。\n\n> 可用于申论积累:发展必须坚持以人民为中心。', source: '新华社', publishedAt: '2026-08-27T10:20:00+08:00', tags: ['高质量发展', '申论素材'], importSource: 'json' }]
/* ------------------------------------------------------------------
* 14 使
*
* ------------------------------------------------------------------ */
function at(date: string, hour: number, minute: number): string {
return `${date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00+08:00`
}
// [距今天数, 题目ID, 是否正确, 耗时秒]
type RecordSpec = [number, string, boolean, number]
const recordSpecs: RecordSpec[] = [
// 今天(连续打卡第 6 天,今日时长约 25 分钟)
[0, 'demo-q-001', true, 120],
[0, 'demo-q-002', true, 150],
[0, 'demo-q-004', false, 300],
[0, 'demo-q-007', true, 90],
[0, 'demo-q-010', false, 360],
[0, 'demo-q-012', true, 60],
[0, 'demo-q-005', true, 180],
[0, 'demo-q-008', false, 240],
// 昨天
[1, 'demo-q-003', true, 180],
[1, 'demo-q-004', false, 240],
[1, 'demo-q-009', true, 60],
[1, 'demo-q-011', false, 300],
[1, 'demo-q-013', true, 50],
[1, 'demo-q-006', false, 260],
// 2 天前
[2, 'demo-q-001', true, 130],
[2, 'demo-q-007', true, 95],
[2, 'demo-q-010', true, 320],
[2, 'demo-q-014', false, 200],
[2, 'demo-q-012', true, 70],
// 3 天前
[3, 'demo-q-002', true, 140],
[3, 'demo-q-005', true, 190],
[3, 'demo-q-008', true, 200],
[3, 'demo-q-010', false, 310],
[3, 'demo-q-003', true, 170],
[3, 'demo-q-015', false, 280],
[3, 'demo-q-013', true, 55],
// 4 天前
[4, 'demo-q-004', false, 260],
[4, 'demo-q-009', true, 65],
[4, 'demo-q-012', false, 75],
[4, 'demo-q-001', true, 125],
// 5 天前
[5, 'demo-q-007', true, 100],
[5, 'demo-q-011', true, 320],
[5, 'demo-q-002', true, 155],
[5, 'demo-q-010', false, 300],
[5, 'demo-q-013', true, 60],
[5, 'demo-q-006', true, 250],
// 6 天前无作答(连续打卡在此中断)
// 7 天前
[7, 'demo-q-001', true, 135],
[7, 'demo-q-004', false, 270],
[7, 'demo-q-009', true, 60],
// 9 天前
[9, 'demo-q-008', true, 210],
[9, 'demo-q-010', false, 330],
[9, 'demo-q-012', true, 70],
[9, 'demo-q-002', true, 145],
// 11 天前
[11, 'demo-q-004', false, 280],
[11, 'demo-q-013', true, 55],
[11, 'demo-q-005', true, 175],
// 13 天前
[13, 'demo-q-003', true, 185],
[13, 'demo-q-010', false, 340],
[13, 'demo-q-007', true, 110],
[13, 'demo-q-012', true, 80],
[13, 'demo-q-004', false, 250]
]
function buildRecords(): PracticeRecord[] {
const byId = new Map(questions.map((q) => [q.id, q]))
return recordSpecs.map(([daysAgo, questionId, correct, seconds], index) => {
const question = byId.get(questionId)
if (!question) throw new Error(`演示记录引用了不存在的题目:${questionId}`)
const wrongKey = question.answer === 'A' ? 'B' : 'A'
return {
questionId,
sessionId: `demo-session-${daysAgo}-${index}`,
userAnswer: correct ? question.answer : wrongKey,
correct,
secondsUsed: seconds,
answeredAt: at(addDays(todayISO(), -daysAgo), 8 + (index % 10), (index * 7) % 60)
}
})
}
/* ------------------------------------------------------------------
*
* ------------------------------------------------------------------ */
function buildMockExams(): MockExam[] {
const rows: Array<[number, string, number, number[]]> = [
[30, '行测全真模考(一)', 54, [12, 9, 12, 10, 11]],
[22, '行测全真模考(二)', 58, [13, 10, 12, 11, 12]],
[15, '行测全真模考(三)', 61, [13, 11, 13, 12, 12]],
[8, '行测全真模考(四)', 64, [14, 12, 13, 12, 13]],
[1, '行测全真模考(五)', 67, [14, 12, 14, 13, 14]]
]
return rows.map(([daysAgo, name, total, scores], i) => ({
id: `mock-demo-${i + 1}`,
name,
date: addDays(todayISO(), -daysAgo),
total,
modules: {
'言语理解': scores[0],
'数量关系': scores[1],
'判断推理': scores[2],
'资料分析': scores[3],
'常识判断': scores[4]
},
note: '演示模考数据',
createdAt: at(addDays(todayISO(), -daysAgo), 20, 0)
}))
}
function buildWrongQuestions(): WrongQuestion[] {
const today = todayISO()
const y1 = addDays(today, -1)
const y2 = addDays(today, -2)
const y3 = addDays(today, -3)
const rows: Array<[string, string, string, 'pending' | 'reviewing', number]> = [
// [id, questionId, 错因, 状态, reviewCount]
['wrong-demo-001', 'demo-q-004', '公式记忆不牢', 'pending', 0],
['wrong-demo-002', 'demo-q-016', '审题不清', 'pending', 0],
['wrong-demo-003', 'demo-q-017', '思路偏差', 'pending', 0],
['wrong-demo-004', 'demo-q-007', '观察不细', 'reviewing', 1],
['wrong-demo-005', 'demo-q-010', '粗心计算', 'pending', 1],
['wrong-demo-006', 'demo-q-018', '概念混淆', 'pending', 0],
['wrong-demo-007', 'demo-q-008', '概念不清', 'reviewing', 2]
]
const days = [today, today, y1, y1, y2, y2, y3]
return rows.map(([id, questionId, wrongReason, status, reviewCount], i) => ({
id,
questionId,
wrongReason,
status,
reviewCount,
nextReviewAt: status === 'pending' ? today : addDays(today, 1 + reviewCount),
updatedAt: at(days[i], 9 + i, (i * 11) % 60)
}))
}
function buildPlans(): StudyPlan[] {
const today = todayISO()
const yesterday = addDays(today, -1)
return [
{ id: 'plan-demo-001', date: today, title: '言语理解专项', type: '刷题', target: '完成 20 题', status: 'done' },
{ id: 'plan-demo-002', date: today, title: '资料分析专项', type: '刷题', target: '完成 15 题', status: 'pending' },
{ id: 'plan-demo-003', date: today, title: '错题复盘', type: '复习', target: '复习 8 道', status: 'pending' },
{ id: 'plan-demo-004', date: yesterday, title: '数量关系专项', type: '刷题', target: '完成 15 题', status: 'done' },
{ id: 'plan-demo-005', date: yesterday, title: '常识积累', type: '阅读', target: '阅读 30 分钟', status: 'done' }
]
}
const profile: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: '2026-06-01' }
const news: NewsItem[] = [
{
id: 'news-demo-001',
title: '聚焦高质量发展,把握时代脉搏',
category: '政治',
summary: '持续推动高质量发展,为中国式现代化夯实基础。',
content: '## 今日要闻\n\n高质量发展是全面建设社会主义现代化国家的首要任务。\n\n> 可用于申论积累:发展必须坚持以人民为中心。',
source: '新华社',
publishedAt: '2026-08-27T10:20:00+08:00',
tags: ['高质量发展', '申论素材'],
importSource: 'json'
}
]
/** 首次启动写入演示数据;已存在的文件不会覆盖。 */
export async function initializeData() {
await readData<Profile>(dataFiles.profile, profile)
await readData(dataFiles.settings, { dailyTargetMinutes: 90, reminderTime: '19:30', preferredDuration: 15 })
await readData<Question[]>(dataFiles.questions, questions)
await readData(dataFiles.practiceSessions, [])
await readData(dataFiles.practiceRecords, [])
await readData(dataFiles.wrongQuestions, [])
await readData<StudyPlan[]>(dataFiles.studyPlans, plans)
await readData(dataFiles.mockExams, [])
await readData<PracticeRecord[]>(dataFiles.practiceRecords, buildRecords())
await readData<WrongQuestion[]>(dataFiles.wrongQuestions, buildWrongQuestions())
await readData<StudyPlan[]>(dataFiles.studyPlans, buildPlans())
await readData<MockExam[]>(dataFiles.mockExams, buildMockExams())
await readData<NewsItem[]>(dataFiles.news, news)
}
/** 重置全部演示数据(用于演示与验收)。 */
export async function resetDemoData() {
await Promise.all([
writeData(dataFiles.profile, profile), writeData(dataFiles.questions, questions), writeData(dataFiles.studyPlans, plans), writeData(dataFiles.news, news)
writeData(dataFiles.profile, profile),
writeData(dataFiles.questions, questions),
writeData(dataFiles.practiceSessions, []),
writeData(dataFiles.practiceRecords, buildRecords()),
writeData(dataFiles.wrongQuestions, buildWrongQuestions()),
writeData(dataFiles.studyPlans, buildPlans()),
writeData(dataFiles.mockExams, buildMockExams()),
writeData(dataFiles.news, news)
])
}

View File

@ -1,7 +1,17 @@
import { readData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { todayISO } from '../utils/dates.js'
import { addDays, todayISO } from '../utils/dates.js'
import {
collectStudyDates,
computeDaysToExam,
computeMastery,
computeStreak,
computeWeakPoints,
dailyMinutes,
recordDate
} from '../services/stats.js'
import type {
MockExam,
PracticeRecord,
Profile,
Question,
@ -14,14 +24,15 @@ import { DashboardTrendsQuerySchema } from '../schemas/api.js'
const DEFAULT_PROFILE: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: todayISO() }
/** 数据中枢概览:题目数、作答数、正确率、今日任务等(趋势类统计在任务 05 完善) */
/** 数据中枢概览:备考天数、距离考试、连续打卡、今日四项指标等汇总 */
export async function overview() {
const [profile, questions, records, plans, wrongQuestions] = await Promise.all([
const [profile, questions, records, plans, wrongQuestions, mockExams] = await Promise.all([
readData<Profile>(dataFiles.profile, DEFAULT_PROFILE),
readData<Question[]>(dataFiles.questions, []),
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<StudyPlan[]>(dataFiles.studyPlans, []),
readData<WrongQuestion[]>(dataFiles.wrongQuestions, [])
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<MockExam[]>(dataFiles.mockExams, [])
])
const totalAnswered = records.length
@ -32,32 +43,54 @@ export async function overview() {
const studyDays = Number.isNaN(startedMs) ? 0 : Math.max(1, Math.floor((Date.now() - startedMs) / 86_400_000) + 1)
const today = todayISO()
const yesterday = addDays(today, -1)
const todayPlans = plans.filter((p) => p.date === today)
const todayRecords = records.filter((r) => recordDate(r) === today)
const todayAnswered = todayRecords.length
const todayCorrect = todayRecords.filter((r) => r.correct).length
const todayAccuracy = todayAnswered === 0 ? 0 : Math.round((todayCorrect / todayAnswered) * 100)
const todayMinutes = dailyMinutes(records, today)
const yesterdayMinutes = dailyMinutes(records, yesterday)
const minutesDelta =
yesterdayMinutes === 0 ? null : Math.round(((todayMinutes - yesterdayMinutes) / yesterdayMinutes) * 100)
const studyDates = collectStudyDates(records, plans, mockExams)
return {
studyDays,
totalQuestions: questions.length,
totalAnswered,
accuracy,
streakDays: 0, // TODO 任务05按自然日连续打卡计算
todayMinutes: 0, // TODO 任务05按当日作答耗时汇总
daysToExam: computeDaysToExam(profile.examDate),
examDate: profile.examDate,
targetScore: profile.targetScore,
streakDays: computeStreak(studyDates),
checkedInToday: studyDates.has(today),
todayMinutes,
minutesDelta,
todayAnswered,
todayAccuracy,
todayTasksDone: todayPlans.filter((p) => p.status === 'done').length,
todayTasksTotal: todayPlans.length,
pendingReview: wrongQuestions.filter((w) => w.status !== 'mastered').length
pendingReview: wrongQuestions.filter((w) => w.status !== 'mastered').length,
todayNewWrong: wrongQuestions.filter((w) => w.updatedAt.slice(0, 10) === today).length,
totalQuestions: questions.length,
totalAnswered,
accuracy
}
}
/** 学习趋势:最近 N 天每日作答数、正确率、学习分钟数(分钟数在任务 05 接入耗时汇总) */
/** 学习趋势:最近 N 天每日作答数、正确率、学习分钟数 */
export async function trends(request: FastifyRequest) {
const { days } = request.query as z.infer<typeof DashboardTrendsQuerySchema>
const records = await readData<PracticeRecord[]>(dataFiles.practiceRecords, [])
const byDate = new Map<string, { answered: number; correct: number }>()
const byDate = new Map<string, { answered: number; correct: number; seconds: number }>()
for (const record of records) {
const date = record.answeredAt.slice(0, 10)
const bucket = byDate.get(date) ?? { answered: 0, correct: 0 }
const date = recordDate(record)
const bucket = byDate.get(date) ?? { answered: 0, correct: 0, seconds: 0 }
bucket.answered += 1
if (record.correct) bucket.correct += 1
bucket.seconds += record.secondsUsed
byDate.set(date, bucket)
}
@ -71,9 +104,29 @@ export async function trends(request: FastifyRequest) {
date: key,
answered: bucket?.answered ?? 0,
accuracy: bucket && bucket.answered > 0 ? Math.round((bucket.correct / bucket.answered) * 100) : 0,
minutes: 0 // TODO 任务05接入每日学习时长
minutes: bucket ? Math.round(bucket.seconds / 60) : 0
}
})
return { days: result }
}
/** 模块掌握度:最近 30 天五大模块的正确率与作答数 */
export async function mastery() {
const [records, questions] = await Promise.all([
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<Question[]>(dataFiles.questions, [])
])
const questionById = new Map(questions.map((q) => [q.id, q]))
return { mastery: computeMastery(records, questionById) }
}
/** 本周薄弱考点:基于错题错因按子模块聚合 */
export async function weakPoints() {
const [wrongQuestions, questions] = await Promise.all([
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<Question[]>(dataFiles.questions, [])
])
const questionById = new Map(questions.map((q) => [q.id, q]))
return { weakPoints: computeWeakPoints(wrongQuestions, questionById) }
}

View File

@ -44,6 +44,16 @@ const routes: RouteDef[] = [
response: { 200: schemas.DashboardTrendsResponseSchema },
handler: dashboard.trends
},
{
method: 'GET', url: '/api/dashboard/mastery', summary: '模块掌握度', tags: ['数据中枢'],
response: { 200: schemas.DashboardMasteryResponseSchema },
handler: dashboard.mastery
},
{
method: 'GET', url: '/api/dashboard/weak-points', summary: '薄弱考点', tags: ['数据中枢'],
response: { 200: schemas.DashboardWeakPointsResponseSchema },
handler: dashboard.weakPoints
},
// 刷题
{

View File

@ -47,14 +47,22 @@ export const ImportResultSchema = z.object({
// ---------- 数据中枢 ----------
export const DashboardOverviewResponseSchema = z.object({
studyDays: z.number().int().min(0),
totalQuestions: z.number().int().min(0),
totalAnswered: z.number().int().min(0),
accuracy: z.number().int().min(0).max(100),
daysToExam: z.number().int().min(0),
examDate: dateParam,
targetScore: z.number().int().min(0).max(100),
streakDays: z.number().int().min(0),
checkedInToday: z.boolean(),
todayMinutes: z.number().int().min(0),
minutesDelta: z.number().int().nullable(),
todayAnswered: z.number().int().min(0),
todayAccuracy: z.number().int().min(0).max(100),
todayTasksDone: z.number().int().min(0),
todayTasksTotal: z.number().int().min(0),
pendingReview: z.number().int().min(0)
pendingReview: z.number().int().min(0),
todayNewWrong: z.number().int().min(0),
totalQuestions: z.number().int().min(0),
totalAnswered: z.number().int().min(0),
accuracy: z.number().int().min(0).max(100)
})
export const DashboardTrendsQuerySchema = z.object({
days: z.number().int().min(1).max(90).default(14)
@ -69,6 +77,25 @@ export const DashboardTrendsResponseSchema = z.object({
})
)
})
export const DashboardMasteryResponseSchema = z.object({
mastery: z.array(
z.object({
module: z.enum(MODULES),
answered: z.number().int().min(0),
accuracy: z.number().int().min(0).max(100)
})
)
})
export const DashboardWeakPointsResponseSchema = z.object({
weakPoints: z.array(
z.object({
module: z.enum(MODULES),
point: z.string(),
count: z.number().int().min(1),
reasons: z.array(z.string())
})
)
})
// ---------- 刷题 ----------
export const PracticeModulesResponseSchema = z.object({

View File

@ -0,0 +1,141 @@
import { addDays, todayISO } from '../utils/dates.js'
import { MODULES } from '../schemas/entities.js'
import type { MockExam, PracticeRecord, Question, StudyPlan, WrongQuestion } from '../schemas/entities.js'
/** 掌握度统计窗口:最近 30 天(需求文档 4.3 */
const MASTERY_WINDOW_DAYS = 30
type ModuleName = (typeof MODULES)[number]
export interface ModuleMastery {
module: ModuleName
answered: number
accuracy: number
}
export interface WeakPoint {
module: ModuleName
point: string
count: number
reasons: string[]
}
/** 作答记录的本地日期YYYY-MM-DD取 answeredAt 前 10 位) */
export function recordDate(record: PracticeRecord): string {
return record.answeredAt.slice(0, 10)
}
/**
*
*
*/
export function collectStudyDates(
records: PracticeRecord[],
plans: StudyPlan[],
mockExams: MockExam[]
): Set<string> {
const dates = new Set<string>()
for (const record of records) dates.add(recordDate(record))
for (const plan of plans) if (plan.status === 'done') dates.add(plan.date)
for (const exam of mockExams) dates.add(exam.date)
return dates
}
/** 连续打卡天数:今天尚未学习不中断,从今天或昨天起向前连续计数,缺失一天即归零。 */
export function computeStreak(dates: Set<string>): number {
if (dates.size === 0) return 0
let cursor = todayISO()
if (!dates.has(cursor)) cursor = addDays(cursor, -1)
let streak = 0
while (dates.has(cursor)) {
streak += 1
cursor = addDays(cursor, -1)
}
return streak
}
/** 某一天的学习时长(分钟):当日作答记录 secondsUsed 汇总。 */
export function dailyMinutes(records: PracticeRecord[], date: string): number {
const seconds = records
.filter((record) => recordDate(record) === date)
.reduce((sum, record) => sum + record.secondsUsed, 0)
return Math.round(seconds / 60)
}
/** 距离考试的天数(按 YYYY-MM-DD 计算,已过则为 0 */
export function computeDaysToExam(examDate: string): number {
const target = new Date(`${examDate}T00:00:00Z`)
const today = new Date(`${todayISO()}T00:00:00Z`)
const diff = Math.round((target.getTime() - today.getTime()) / 86_400_000)
return Math.max(0, diff)
}
function withinWindow(date: string, since: string): boolean {
return date >= since
}
/**
* 30 ÷ × 100%
* answered=0accuracy=0
*/
export function computeMastery(
records: PracticeRecord[],
questionById: Map<string, Question>
): ModuleMastery[] {
const since = addDays(todayISO(), -(MASTERY_WINDOW_DAYS - 1))
return MODULES.map((module) => {
let answered = 0
let correct = 0
for (const record of records) {
if (!withinWindow(recordDate(record), since)) continue
const question = questionById.get(record.questionId)
if (!question || question.module !== module) continue
answered += 1
if (record.correct) correct += 1
}
return {
module,
answered,
accuracy: answered === 0 ? 0 : Math.round((correct / answered) * 100)
}
})
}
/**
* 7
* count = reasons =
*/
export function computeWeakPoints(
wrongQuestions: WrongQuestion[],
questionById: Map<string, Question>,
limit = 6
): WeakPoint[] {
const since = addDays(todayISO(), -6)
const groups = new Map<string, { module: ModuleName; point: string; count: number; reasons: Set<string> }>()
for (const wrong of wrongQuestions) {
if (wrong.updatedAt.slice(0, 10) < since) continue
const question = questionById.get(wrong.questionId)
if (!question) continue
const key = `${question.module}\u0000${question.subModule}`
const group = groups.get(key) ?? {
module: question.module as ModuleName,
point: question.subModule,
count: 0,
reasons: new Set<string>()
}
group.count += 1
if (wrong.wrongReason) group.reasons.add(wrong.wrongReason)
groups.set(key, group)
}
return [...groups.values()]
.sort((a, b) => b.count - a.count)
.slice(0, limit)
.map((group) => ({
module: group.module,
point: group.point,
count: group.count,
reasons: [...group.reasons]
}))
}