feat: 首页对齐原型+联调
This commit is contained in:
parent
84e5fa0af5
commit
9f5c9a6366
@ -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'))
|
||||
}
|
||||
|
||||
// ---- 刷题 ----
|
||||
|
||||
170
client/src/components/charts/AppLineChart.vue
Normal file
170
client/src/components/charts/AppLineChart.vue
Normal 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>
|
||||
199
client/src/components/charts/AppTrendChart.vue
Normal file
199
client/src/components/charts/AppTrendChart.vue
Normal 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>
|
||||
114
client/src/components/dashboard/MasteryCard.vue
Normal file
114
client/src/components/dashboard/MasteryCard.vue
Normal 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>
|
||||
72
client/src/components/dashboard/MockTrendCard.vue
Normal file
72
client/src/components/dashboard/MockTrendCard.vue
Normal 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>
|
||||
90
client/src/components/dashboard/StatCard.vue
Normal file
90
client/src/components/dashboard/StatCard.vue
Normal 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>
|
||||
122
client/src/components/dashboard/TodayTasksCard.vue
Normal file
122
client/src/components/dashboard/TodayTasksCard.vue
Normal 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>
|
||||
97
client/src/components/dashboard/WeakPointsCard.vue
Normal file
97
client/src/components/dashboard/WeakPointsCard.vue
Normal 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>
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '要闻' } },
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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 '晚上好'
|
||||
}
|
||||
|
||||
@ -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
90
docs/checks/task-05.md
Normal 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/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 结果对齐。
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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)
|
||||
])
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
// 刷题
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
141
server/src/services/stats.ts
Normal file
141
server/src/services/stats.ts
Normal 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=0、accuracy=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]
|
||||
}))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user