diff --git a/client/src/api/index.ts b/client/src/api/index.ts
index 74549ec..1e1401c 100644
--- a/client/src/api/index.ts
+++ b/client/src/api/index.ts
@@ -29,6 +29,8 @@ type BodyOf
= paths[P][M] extends {
// ---- 常用响应类型 ----
export type DashboardOverview = SuccessBody
export type DashboardTrends = SuccessBody
+export type DashboardMastery = SuccessBody
+export type DashboardWeakPoints = SuccessBody
export type PracticeModules = SuccessBody
export type Profile = SuccessBody
export type Settings = SuccessBody
@@ -37,7 +39,9 @@ export type Settings = SuccessBody
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'))
}
// ---- 刷题 ----
diff --git a/client/src/components/charts/AppLineChart.vue b/client/src/components/charts/AppLineChart.vue
new file mode 100644
index 0000000..f3be920
--- /dev/null
+++ b/client/src/components/charts/AppLineChart.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/charts/AppTrendChart.vue b/client/src/components/charts/AppTrendChart.vue
new file mode 100644
index 0000000..bbd307b
--- /dev/null
+++ b/client/src/components/charts/AppTrendChart.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+ 每日答题数
+ 正确率
+
+
+
+
+
+
+
diff --git a/client/src/components/dashboard/MasteryCard.vue b/client/src/components/dashboard/MasteryCard.vue
new file mode 100644
index 0000000..7b36619
--- /dev/null
+++ b/client/src/components/dashboard/MasteryCard.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/dashboard/MockTrendCard.vue b/client/src/components/dashboard/MockTrendCard.vue
new file mode 100644
index 0000000..57e1a6c
--- /dev/null
+++ b/client/src/components/dashboard/MockTrendCard.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+ {{ trendLabel }}
+
+
+
+
+
+
+ 近 {{ points.length }} 场 · 总分 · 目标 {{ targetScore }} 分
+
+
+
+
+
+
diff --git a/client/src/components/dashboard/StatCard.vue b/client/src/components/dashboard/StatCard.vue
new file mode 100644
index 0000000..ee1fc1d
--- /dev/null
+++ b/client/src/components/dashboard/StatCard.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+ {{ label }}
+ {{ value }}
+ {{ sub }}
+
+
+
+
+
diff --git a/client/src/components/dashboard/TodayTasksCard.vue b/client/src/components/dashboard/TodayTasksCard.vue
new file mode 100644
index 0000000..31230ea
--- /dev/null
+++ b/client/src/components/dashboard/TodayTasksCard.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+ {{ done }}/{{ total }}
+ 查看全部
+
+
+
+
+
+
+ -
+
+
+
+
+ {{ task.title }}
+ {{ task.target }}
+
+ {{ task.type }}
+
+
+
+
+
+
diff --git a/client/src/components/dashboard/WeakPointsCard.vue b/client/src/components/dashboard/WeakPointsCard.vue
new file mode 100644
index 0000000..18b73c3
--- /dev/null
+++ b/client/src/components/dashboard/WeakPointsCard.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+ 基于错因归因自动统计
+
+
+
+
+
+
+
+
+
diff --git a/client/src/layouts/DesktopLayout.vue b/client/src/layouts/DesktopLayout.vue
index 7c225be..5fefd52 100644
--- a/client/src/layouts/DesktopLayout.vue
+++ b/client/src/layouts/DesktopLayout.vue
@@ -14,16 +14,27 @@ const route = useRoute()
备考通
@@ -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);
diff --git a/client/src/layouts/MobileLayout.vue b/client/src/layouts/MobileLayout.vue
index 6ba82ef..ab14295 100644
--- a/client/src/layouts/MobileLayout.vue
+++ b/client/src/layouts/MobileLayout.vue
@@ -6,11 +6,13 @@ import { mobileNav } from '../router/nav'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '备考通'))
+// 首页以自己的问候语作为页头,不再重复显示顶部栏标题
+const isHome = computed(() => route.path === '/')
-
+
diff --git a/client/src/router/index.ts b/client/src/router/index.ts
index e1a4f65..4d1bb0c 100644
--- a/client/src/router/index.ts
+++ b/client/src/router/index.ts
@@ -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: '要闻' } },
diff --git a/client/src/router/nav.ts b/client/src/router/nav.ts
index bdc2e0e..e7cc3b3 100644
--- a/client/src/router/nav.ts
+++ b/client/src/router/nav.ts
@@ -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' },
diff --git a/client/src/utils/format.ts b/client/src/utils/format.ts
index 153c526..37964f9 100644
--- a/client/src/utils/format.ts
+++ b/client/src/utils/format.ts
@@ -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 '晚上好'
+}
diff --git a/client/src/views/dashboard/DashboardView.vue b/client/src/views/dashboard/DashboardView.vue
index f8b0dc9..950116b 100644
--- a/client/src/views/dashboard/DashboardView.vue
+++ b/client/src/views/dashboard/DashboardView.vue
@@ -1,101 +1,236 @@
-
-
-
数据中枢
-
{{ profile.nickname }},欢迎回来 · 今日也要稳步推进
-
-
-
-
-
+
+
-
-
-
-
-
学习天数
-
{{ data.studyDays }}天
+
+
+
+
+
+
+
+ {{ d.studyDays }}
+ 备考天数
+ 完成 {{ goalProgress }}%
-
- 累计答题
- {{ data.totalAnswered }}题
-
-
-
正确率
-
{{ data.accuracy }}%
+
+
+ {{ d.daysToExam }}
+ 距离国考
+ {{ formatDateShort(d.examDate) }} 笔试
-
-
-
-
+
+
+
+ 今日学习
+ {{ formatMinutes(d.todayMinutes) }}
+
+
+
+
+
+
+ {{ d.todayAnswered }}题
+ 今日刷题
+
+
+ {{ d.todayAccuracy }}%
+ 今日正确率
+
+
+ {{ d.todayTasksDone }}/{{ d.todayTasksTotal }}项
+ 今日任务
+
+
+
+
+
+ 开始刷题
+ 今日计划
+
+
+
+
+
+
+
+
+ 待复习错题
+ 今日新增 {{ d.todayNewWrong }} 道 · 待复习 {{ d.pendingReview }} 道
+
-
- {{ s.label }}
- {{ s.value }}{{ s.unit }}
+ 去复习
+
+
+
+
+
+
+
+
+
+
+
+ {{ latestMock.name }}
+ {{ formatDateShort(latestMock.date) }} · 行测
-
+
+ {{ latestMock.total }}
+
+ 较上次 {{ mockDelta >= 0 ? '+' : '' }}{{ mockDelta }}
+
+
+
+ 距目标 {{ targetScore }} 分 · 还差 {{ Math.max(0, targetScore - latestMock.total) }} 分
+
+
+
+
+
+
+
+
+
+
数据中枢
+
备考第 {{ d.studyDays }} 天:距离国考还有 {{ d.daysToExam }} 天:已连续打卡 {{ d.streakDays }} 天
+
+ 今日已打卡
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -107,7 +242,20 @@ const stats = computed(() => {
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(() => {
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);
}
diff --git a/docs/checks/task-05.md b/docs/checks/task-05.md
new file mode 100644
index 0000000..cb211fd
--- /dev/null
+++ b/docs/checks/task-05.md
@@ -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/54,trend 54→67,target 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 结果对齐。
\ No newline at end of file
diff --git a/server/data/mock-exams.json b/server/data/mock-exams.json
index fe51488..3c350eb 100644
--- a/server/data/mock-exams.json
+++ b/server/data/mock-exams.json
@@ -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"
+ }
+]
diff --git a/server/data/practice-records.json b/server/data/practice-records.json
index fe51488..855dd54 100644
--- a/server/data/practice-records.json
+++ b/server/data/practice-records.json
@@ -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"
+ }
+]
diff --git a/server/data/questions.json b/server/data/questions.json
index 081de37..bc69f33 100644
--- a/server/data/questions.json
+++ b/server/data/questions.json
@@ -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"
}
]
diff --git a/server/data/study-plans.json b/server/data/study-plans.json
index a61c24c..feb5f42 100644
--- a/server/data/study-plans.json
+++ b/server/data/study-plans.json
@@ -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"
}
]
diff --git a/server/data/wrong-questions.json b/server/data/wrong-questions.json
index fe51488..f0e72cc 100644
--- a/server/data/wrong-questions.json
+++ b/server/data/wrong-questions.json
@@ -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"
+ }
+]
diff --git a/server/src/data/seed.ts b/server/src/data/seed.ts
index 9832bac..983f889 100644
--- a/server/src/data/seed.ts
+++ b/server/src/data/seed.ts
@@ -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(dataFiles.profile, profile)
await readData(dataFiles.settings, { dailyTargetMinutes: 90, reminderTime: '19:30', preferredDuration: 15 })
await readData(dataFiles.questions, questions)
await readData(dataFiles.practiceSessions, [])
- await readData(dataFiles.practiceRecords, [])
- await readData(dataFiles.wrongQuestions, [])
- await readData(dataFiles.studyPlans, plans)
- await readData(dataFiles.mockExams, [])
+ await readData(dataFiles.practiceRecords, buildRecords())
+ await readData(dataFiles.wrongQuestions, buildWrongQuestions())
+ await readData(dataFiles.studyPlans, buildPlans())
+ await readData(dataFiles.mockExams, buildMockExams())
await readData(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)
])
}
diff --git a/server/src/handlers/dashboard.ts b/server/src/handlers/dashboard.ts
index 004b964..3d9d191 100644
--- a/server/src/handlers/dashboard.ts
+++ b/server/src/handlers/dashboard.ts
@@ -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(dataFiles.profile, DEFAULT_PROFILE),
readData(dataFiles.questions, []),
readData(dataFiles.practiceRecords, []),
readData(dataFiles.studyPlans, []),
- readData(dataFiles.wrongQuestions, [])
+ readData(dataFiles.wrongQuestions, []),
+ readData(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
const records = await readData(dataFiles.practiceRecords, [])
- const byDate = new Map()
+ const byDate = new Map()
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(dataFiles.practiceRecords, []),
+ readData(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(dataFiles.wrongQuestions, []),
+ readData(dataFiles.questions, [])
+ ])
+ const questionById = new Map(questions.map((q) => [q.id, q]))
+ return { weakPoints: computeWeakPoints(wrongQuestions, questionById) }
+}
diff --git a/server/src/routes.ts b/server/src/routes.ts
index 81b957d..57c3aaa 100644
--- a/server/src/routes.ts
+++ b/server/src/routes.ts
@@ -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
+ },
// 刷题
{
diff --git a/server/src/schemas/api.ts b/server/src/schemas/api.ts
index 344b12d..9ca7d61 100644
--- a/server/src/schemas/api.ts
+++ b/server/src/schemas/api.ts
@@ -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({
diff --git a/server/src/services/stats.ts b/server/src/services/stats.ts
new file mode 100644
index 0000000..02c693c
--- /dev/null
+++ b/server/src/services/stats.ts
@@ -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 {
+ const dates = new Set()
+ 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): 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=0、accuracy=0(前端据此区分「暂无数据」)。
+ */
+export function computeMastery(
+ records: PracticeRecord[],
+ questionById: Map
+): 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,
+ limit = 6
+): WeakPoint[] {
+ const since = addDays(todayISO(), -6)
+ const groups = new Map }>()
+
+ 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()
+ }
+ 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]
+ }))
+}