feat: 增加要闻/设置/主页

This commit is contained in:
liyy 2026-09-01 11:36:08 +08:00
parent 9f5c9a6366
commit 2d428f1be0
33 changed files with 3472 additions and 352 deletions

View File

@ -34,6 +34,12 @@ export type DashboardWeakPoints = SuccessBody<paths['/api/dashboard/weak-points'
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']>
export type PlansToday = SuccessBody<paths['/api/plans/today']['get']>
export type PlansWeek = SuccessBody<paths['/api/plans/week']['get']>
export type PlanReview = SuccessBody<paths['/api/plans/review']['get']>
export type StudyPlanItem = SuccessBody<paths['/api/plans/today']['get']>['tasks'][number]
export type NewsItem = SuccessBody<paths['/api/news/list']['get']>['items'][number]
export type ImportResult = SuccessBody<paths['/api/news/import/json']['post']>
// ---- 数据中枢 ----
export const dashboardApi = {
@ -84,7 +90,8 @@ export const plansApi = {
createTask: (body: BodyOf<'/api/plans/tasks', 'post'>) =>
unwrap(apiClient.POST('/api/plans/tasks', { body })),
toggleTask: (id: string) =>
unwrap(apiClient.PATCH('/api/plans/tasks/{id}/toggle', { params: { path: { id } } }))
unwrap(apiClient.PATCH('/api/plans/tasks/{id}/toggle', { params: { path: { id } } })),
review: () => unwrap(apiClient.GET('/api/plans/review'))
}
// ---- 模考 ----

View File

@ -23,7 +23,7 @@ withDefaults(
:class="[`btn--${variant}`, `btn--${size}`, { 'btn--block': block }]"
>
<AppIcon v-if="icon" :name="icon" :size="size === 'lg' ? 18 : 16" />
<span><slot /></span>
<span class="btn__label"><slot /></span>
</button>
</template>
@ -42,6 +42,12 @@ withDefaults(
opacity var(--motion-hover-fast) var(--ease-default);
white-space: nowrap;
}
.btn__label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.btn--md {
height: var(--btn-height);
padding: 0 var(--space-4);

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import AppIcon from './AppIcon.vue'
import AppButton from './AppButton.vue'
withDefaults(
defineProps<{
title: string
maxWidth?: number
}>(),
{ maxWidth: 520 }
)
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<Teleport to="body">
<div class="modal" @click.self="emit('close')">
<div class="modal__panel" :style="{ maxWidth: `${maxWidth}px` }">
<header class="modal__head">
<h3>{{ title }}</h3>
<AppButton class="modal__close" variant="ghost" size="md" aria-label="关闭" @click="emit('close')">
<AppIcon name="close" :size="18" />
</AppButton>
</header>
<div class="modal__body"><slot /></div>
<footer v-if="$slots.footer" class="modal__foot"><slot name="footer" /></footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
padding: var(--space-4);
background: rgba(26, 31, 46, 0.4);
}
.modal__panel {
width: 100%;
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-level-3);
overflow: hidden;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
}
.modal__head h3 {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.modal__close {
display: grid !important;
place-items: center;
width: 32px !important;
height: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: var(--radius-sm) !important;
background: var(--bg-soft) !important;
color: var(--text-secondary) !important;
gap: 0 !important;
cursor: pointer;
transition: background var(--motion-hover-fast) var(--ease-default);
}
.modal__close:hover {
background: var(--border-subtle) !important;
}
.modal__body {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.modal__foot {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border-subtle);
}
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
subtitle?: string
/** 移动端是否纵向堆叠(部分页面移动版按钮在标题下方) */
stackMobile?: boolean
}>(),
{ subtitle: '', stackMobile: false }
)
</script>
<template>
<header class="page-head" :class="{ 'page-head--stack': stackMobile }">
<div class="page-head__title">
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.default" class="page-head__actions"><slot /></div>
</header>
</template>
<style scoped>
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.page-head__title {
flex: 1;
min-width: 0;
}
.page-head h1 {
margin: 0;
font-size: var(--fs-h1);
font-weight: var(--fw-h1);
line-height: var(--lh-h1);
color: var(--text-primary);
}
.page-head p {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.page-head__actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex: none;
}
@media (max-width: 767px) {
.page-head--stack {
flex-direction: column;
}
}
</style>

View File

@ -14,6 +14,8 @@ export type IconName =
| 'alert'
| 'check'
| 'chevron-right'
| 'chevron-left'
| 'close'
| 'clock'
| 'flame'
| 'target'
@ -93,6 +95,8 @@ export const icons: Record<IconName, IconNode[]> = {
],
check: [{ tag: 'path', attrs: { d: 'M20 6 9 17l-5-5' } }],
'chevron-right': [{ tag: 'path', attrs: { d: 'M9 18l6-6-6-6' } }],
'chevron-left': [{ tag: 'path', attrs: { d: 'M15 18l-6-6 6-6' } }],
close: [{ tag: 'path', attrs: { d: 'M18 6 6 18' } }, { tag: 'path', attrs: { d: 'M6 6l12 12' } }],
clock: [
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
{ tag: 'path', attrs: { d: 'M12 6v6l4 2' } }

View File

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

View File

@ -4,7 +4,9 @@ import DashboardView from '../views/dashboard/DashboardView.vue'
import PracticeView from '../views/practice/PracticeView.vue'
import MockView from '../views/mock/MockView.vue'
import PlanView from '../views/plan/PlanView.vue'
import PlanReviewView from '../views/plan/PlanReviewView.vue'
import NewsView from '../views/news/NewsView.vue'
import NewsDetailView from '../views/news/NewsDetailView.vue'
import ProfileView from '../views/profile/ProfileView.vue'
import QuestionsView from '../views/questions/QuestionsView.vue'
import SettingsView from '../views/settings/SettingsView.vue'
@ -16,7 +18,9 @@ export const routes: RouteRecordRaw[] = [
{ 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: '/plan/review', name: 'plan-review', component: PlanReviewView, meta: { title: '周报复盘' } },
{ path: '/news', name: 'news', component: NewsView, meta: { title: '要闻' } },
{ path: '/news/:id', name: 'news-detail', component: NewsDetailView, meta: { title: '要闻详情' } },
{ path: '/profile', name: 'profile', component: ProfileView, meta: { title: '我的' } },
{ path: '/questions', name: 'questions', component: QuestionsView, meta: { title: '题库管理' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '设置' } },

View File

@ -180,6 +180,54 @@
font: var(--fw-button) var(--fs-button) / var(--lh-button) var(--ff-button);
}
/* 通用按钮基础inline-flex 布局 + 不换行 .btn-link / .btn-ghost 等复用
解决窄屏下 icon 与文本换行问题 */
.btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
white-space: nowrap;
flex: none;
}
/* 描边次级按钮(如「周报复盘」「账号设置」) */
.btn-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
white-space: nowrap;
flex: none;
padding: 0 var(--space-3);
height: var(--btn-height);
border-radius: var(--radius-btn);
border: 1px solid var(--primary);
color: var(--primary);
font-size: var(--fs-button);
font-weight: var(--fw-button);
text-decoration: none;
}
/* 幽灵按钮(无描边,如「订阅推送」) */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
white-space: nowrap;
flex: none;
padding: 0 var(--space-3);
height: var(--btn-height);
border: 1px solid var(--primary);
border-radius: var(--radius-btn);
background: transparent;
color: var(--primary);
font-size: var(--fs-button);
font-weight: var(--fw-button);
cursor: pointer;
}
/* 语义标签 / 徽章(主色 + 浅底成对) */
.hub-badge {
font: var(--fw-label-bold) var(--fs-label) / var(--lh-label) var(--ff-label);

View File

@ -36,3 +36,43 @@ export function greetingByHour(hour = new Date().getHours()): string {
if (hour < 18) return '下午好'
return '晚上好'
}
/** YYYY-MM-DD → 「第 N 周」(以 2026 年第 1 周为基准;跨年按自然周近似) */
export function weekOfYear(iso: string): number {
const date = new Date(`${iso}T00:00:00Z`)
const start = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
const week = Math.ceil(((date.getTime() - start.getTime()) / 86_400_000 + 1) / 7)
return Math.max(1, week)
}
/** YYYY-MM-DD → 「周X」周一周日 */
export function weekdayName(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return names[date.getUTCDay()]
}
/** YYYY-MM-DD → 「M月D日 周X」 */
export function formatDateWeekday(iso: string): string {
return `${formatDateShort(iso)} ${weekdayName(iso)}`
}
/** ISO 时间串 → 「X小时前 / X分钟前 / X天前」 */
export function timeAgo(iso: string): string {
const diff = Date.now() - Date.parse(iso)
if (Number.isNaN(diff)) return ''
const minutes = Math.floor(diff / 60_000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
/** 备考天数:距备考开始日期的自然天数(含今天) */
export function daysSince(iso: string): number {
const start = Date.parse(`${iso}T00:00:00Z`)
if (Number.isNaN(start)) return 0
return Math.max(1, Math.floor((Date.now() - start) / 86_400_000) + 1)
}

View File

@ -0,0 +1,115 @@
/**
* Markdown
* / / / / /
* / / / 线 HTML
*
* 线 HTML
* <em>/<strong>/<code>/<a 等标签均为本地白名单结构
*/
/** 转义 HTML 特殊字符,防止原始内容注入 */
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/** 渲染行内元素:粗体、斜体、行内代码、链接 */
function renderInline(text: string): string {
return text
// 行内代码(先于其他标记处理,避免代码内标记被误解析)
.replace(/`([^`]+)`/g, (_m, code: string) => `<code>${code}</code>`)
// 加粗
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// 斜体
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
// 链接 [text](url)
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label: string, url: string) => {
const safeUrl = /^https?:\/\//i.test(url) ? url : '#'
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}
/**
* markdown HTML
*
*/
export function renderMarkdown(source: string): string {
if (!source) return ''
const lines = escapeHtml(source).replace(/\r\n/g, '\n').split('\n')
const blocks: string[] = []
let list: { ordered: boolean; items: string[] } | null = null
let paragraph: string[] = []
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push(`<p>${renderInline(paragraph.join(' '))}</p>`)
paragraph = []
}
}
const flushList = () => {
if (list) {
const tag = list.ordered ? 'ol' : 'ul'
blocks.push(`<${tag}>${list.items.map((i) => `<li>${renderInline(i)}</li>`).join('')}</${tag}>`)
list = null
}
}
for (const line of lines) {
const trimmed = line.trim()
if (trimmed === '') {
flushParagraph()
flushList()
continue
}
// 标题
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/)
if (heading) {
flushParagraph()
flushList()
const level = heading[1].length
blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`)
continue
}
// 水平分割线
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
flushParagraph()
flushList()
blocks.push('<hr />')
continue
}
// 引用
if (trimmed.startsWith('>')) {
flushParagraph()
flushList()
const quote = trimmed.replace(/^>\s?/, '')
blocks.push(`<blockquote><p>${renderInline(quote)}</p></blockquote>`)
continue
}
// 无序 / 有序列表
const unordered = trimmed.match(/^[-*+]\s+(.*)$/)
const ordered = trimmed.match(/^\d+\.\s+(.*)$/)
if (unordered || ordered) {
flushParagraph()
const isOrdered = Boolean(ordered)
const content = (unordered ?? ordered)?.[1] ?? ''
if (!list || list.ordered !== isOrdered) {
flushList()
list = { ordered: isOrdered, items: [] }
}
list.items.push(content)
continue
}
// 普通文本:累积为段落
flushList()
paragraph.push(trimmed)
}
flushParagraph()
flushList()
return blocks.join('\n')
}

View File

@ -11,6 +11,7 @@ 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 AppPageHeader from '../../components/base/AppPageHeader.vue'
import StatCard from '../../components/dashboard/StatCard.vue'
import MockTrendCard from '../../components/dashboard/MockTrendCard.vue'
import MasteryCard from '../../components/dashboard/MasteryCard.vue'
@ -180,13 +181,12 @@ const mockDelta = computed(() => {
<!-- ============ 桌面端数据中枢 ============ -->
<div v-else-if="d" class="hub">
<header class="page-head">
<div>
<h1>数据中枢</h1>
<p>备考第 {{ d.studyDays }} 距离国考还有 {{ d.daysToExam }} 已连续打卡 {{ d.streakDays }} </p>
</div>
<AppPageHeader
title="数据中枢"
:subtitle="`备考第 ${d.studyDays} 天:距离国考还有 ${d.daysToExam} 天:已连续打卡 ${d.streakDays} 天`"
>
<AppBadge v-if="d.checkedInToday" variant="success">今日已打卡</AppBadge>
</header>
</AppPageHeader>
<!-- 指标卡 -->
<div class="hub-stats">
@ -248,24 +248,6 @@ const mockDelta = computed(() => {
}
/* ---------- 桌面 ---------- */
.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);
color: var(--text-primary);
}
.page-head p {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.hub {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { newsApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { renderMarkdown } from '../../utils/markdown'
import { formatDateShort } from '../../utils/format'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppButton from '../../components/base/AppButton.vue'
const route = useRoute()
const id = computed(() => String(route.params.id ?? ''))
const detail = useRequest(() => newsApi.detail(id.value))
const html = computed(() => renderMarkdown(detail.data.value?.content ?? ''))
</script>
<template>
<div class="news-detail">
<AppLoading v-if="detail.loading.value" fullscreen />
<AppError
v-else-if="detail.error.value"
fullscreen
title="要闻加载失败"
:message="detail.error.value"
retry
@retry="detail.refresh"
/>
<template v-else-if="detail.data.value">
<header class="head">
<AppButton class="head__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()">
<AppIcon name="chevron-left" :size="20" />
</AppButton>
<span class="head__label">要闻详情</span>
</header>
<article class="article">
<section class="article__hero">
<div class="article__tag">{{ detail.data.value.category }}</div>
<h1 class="article__title">{{ detail.data.value.title }}</h1>
<p class="article__meta">
{{ detail.data.value.source }} · {{ formatDateShort(detail.data.value.publishedAt.slice(0, 10)) }}
</p>
</section>
<section class="article__body" v-html="html" />
</article>
</template>
<AppEmpty v-else icon="news" title="该要闻不存在" description="可能已被删除或链接有误。" />
</div>
</template>
<style scoped>
.news-detail {
display: flex;
flex-direction: column;
}
.head {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.head__back {
display: grid !important;
place-items: center;
width: 36px !important;
height: 36px !important;
padding: 0 !important;
border: none !important;
border-radius: 50% !important;
background: var(--bg-card) !important;
color: var(--text-primary) !important;
gap: 0 !important;
cursor: pointer;
}
.head__label {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.article {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.article__hero {
padding: var(--card-padding-desktop);
background: var(--gradient-hero-deep);
color: var(--on-accent);
}
.article__tag {
display: inline-flex;
padding: 4px var(--space-3);
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.18);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.article__title {
margin: var(--space-3) 0 0;
font-size: var(--fs-h1);
font-weight: var(--fw-h1);
line-height: var(--lh-h1);
}
.article__meta {
margin: var(--space-2) 0 0;
font-size: var(--fs-label);
opacity: 0.8;
}
.article__body {
padding: var(--card-padding-desktop);
color: var(--text-primary);
font-size: var(--fs-body);
line-height: var(--lh-body);
}
.article__body :deep(h1),
.article__body :deep(h2),
.article__body :deep(h3) {
color: var(--text-primary);
margin: var(--space-5) 0 var(--space-2);
font-weight: var(--fw-card-title);
}
.article__body :deep(h2) {
font-size: 18px;
}
.article__body :deep(h3) {
font-size: var(--fs-card-title);
}
.article__body :deep(p) {
margin: var(--space-3) 0;
}
.article__body :deep(ul),
.article__body :deep(ol) {
margin: var(--space-3) 0;
padding-left: var(--space-5);
}
.article__body :deep(li) {
margin: var(--space-1) 0;
}
.article__body :deep(blockquote) {
margin: var(--space-4) 0;
padding: var(--space-2) var(--space-4);
border-left: 3px solid var(--primary);
background: var(--bg-soft);
color: var(--text-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.article__body :deep(code) {
padding: 2px var(--space-1);
border-radius: var(--radius-sm);
background: var(--bg-soft);
font-family: var(--font-num);
font-size: 0.9em;
}
.article__body :deep(a) {
color: var(--primary);
text-decoration: none;
}
.article__body :deep(hr) {
margin: var(--space-5) 0;
border: none;
border-top: 1px solid var(--border-subtle);
}
.article__body :deep(strong) {
color: var(--text-primary);
font-weight: var(--fw-card-title);
}
@media (max-width: 767px) {
.article__hero,
.article__body {
padding: var(--card-padding-mobile);
}
}
</style>

View File

@ -1,7 +1,407 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
import { computed, ref } from 'vue'
import { newsApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { formatDateShort, timeAgo } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppModal from '../../components/base/AppModal.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
const { isMobile } = useResponsive()
const app = useAppStore()
const categories = ['全部', '公告', '联考', '政策', '时政', '申论']
const activeCategory = ref('全部')
const list = useRequest(() => newsApi.list({ page: 1, pageSize: 50 }))
const items = computed(() => list.data.value?.items ?? [])
//
const pinned = computed(() => items.value.filter((n) => n.category === '公告')[0] ?? items.value[0] ?? null)
const restItems = computed(() => items.value.filter((n) => n.id !== pinned.value?.id))
const categoryTone = (c: string) =>
c === '公告' ? 'primary' : c === '政策' ? 'warning' : c === '联考' ? 'soft' : 'success'
function filterCategory(category: string) {
activeCategory.value = category
list.refresh()
}
//
const showImport = ref(false)
const importType = ref<'json' | 'rss' | 'api' | 'url'>('json')
const importText = ref('')
const importUrl = ref('')
function openImport() {
showImport.value = true
importType.value = 'json'
importText.value = ''
importUrl.value = ''
}
type ImportResult = { success: number; skipped: number; failed: number; errors: { message: string }[] }
async function doImport() {
try {
let result: ImportResult
if (importType.value === 'json') {
if (!importText.value.trim()) {
app.toast('请粘贴 JSON 内容', 'error')
return
}
let parsed: { news?: unknown[] }
try {
parsed = JSON.parse(importText.value)
} catch {
app.toast('JSON 解析失败,请检查格式', 'error')
return
}
result = await newsApi.importJson({ version: '1.0', news: (parsed.news ?? []) as never })
} else if (importType.value === 'rss') {
result = await newsApi.importRss({ url: importUrl.value })
} else if (importType.value === 'api') {
result = await newsApi.importApi({ url: importUrl.value })
} else {
result = await newsApi.importUrl({ url: importUrl.value })
}
showImport.value = false
app.toast(`导入完成:成功 ${result.success} · 跳过 ${result.skipped} · 失败 ${result.failed}`, 'success')
list.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '导入失败', 'error')
}
}
</script>
<template>
<ComingSoon />
<div class="news">
<AppLoading v-if="list.loading.value" fullscreen />
<AppError
v-else-if="list.error.value"
fullscreen
title="要闻加载失败"
:message="list.error.value"
retry
@retry="list.refresh"
/>
<template v-else>
<!-- 页头 -->
<AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态">
<AppButton variant="ghost" @click="openImport">订阅推送</AppButton>
<AppButton variant="primary" @click="openImport">
<AppIcon name="inbox" :size="16" />发布公告
</AppButton>
</AppPageHeader>
<!-- 分类 Tab -->
<div class="news-tabs">
<AppButton
v-for="c in categories"
:key="c"
variant="ghost"
size="md"
class="news-tabs__tab"
:class="{ 'is-active': activeCategory === c }"
@click="filterCategory(c)"
>
{{ c }}
</AppButton>
</div>
<AppEmpty
v-if="items.length === 0"
icon="news"
title="暂无要闻"
description="暂无相关分类内容,可导入或稍后再来。"
/>
<template v-else>
<!-- 置顶卡 -->
<RouterLink
v-if="pinned"
class="news-pin"
:to="`/news/${pinned.id}`"
>
<div class="news-pin__tag"><span class="news-pin__dot" />置顶</div>
<h2 class="news-pin__title">{{ pinned.title }}</h2>
<p class="news-pin__meta">{{ pinned.source }} · {{ formatDateShort(pinned.publishedAt.slice(0, 10)) }}</p>
</RouterLink>
<!-- 列表 -->
<div class="news-list">
<RouterLink
v-for="n in restItems"
:key="n.id"
class="news-item"
:to="`/news/${n.id}`"
>
<div class="news-item__body">
<h3 class="news-item__title">{{ n.title }}</h3>
<p class="news-item__summary">{{ n.summary }}</p>
<p class="news-item__meta">
{{ n.source }} · {{ timeAgo(n.publishedAt) }}
</p>
</div>
<span class="news-item__badge" :class="`is-${categoryTone(n.category)}`">{{ n.category }}</span>
</RouterLink>
</div>
</template>
</template>
<!-- 导入弹层 -->
<AppModal v-if="showImport" title="导入要闻" :max-width="520" @close="showImport = false">
<div class="import-tabs">
<AppButton
v-for="t in (['json', 'rss', 'api', 'url'] as const)"
:key="t"
variant="ghost"
size="md"
class="import-tabs__tab"
:class="{ 'is-active': importType === t }"
@click="importType = t"
>
{{ t.toUpperCase() }}
</AppButton>
</div>
<textarea
v-if="importType === 'json'"
v-model="importText"
class="modal__textarea"
placeholder='粘贴 JSON{"version":"1.0","news":[{...}]}'
/>
<div
v-else
class="import-hint"
>
<p class="import-hint__title">该导入来源为占位入口</p>
<p class="import-hint__text">
提交后将返回{{ importType.toUpperCase() }} 导入未配置的明确反馈当前仅 JSON 为可用的维护方式
</p>
<div class="input-row">
<input v-model="importUrl" class="modal__input" :placeholder="`请输入 ${importType.toUpperCase()} 地址`" />
</div>
</div>
<p class="import-note">JSON 导入将按标题 + 分类 + 发布时间指纹去重重复条目自动跳过</p>
<template #footer>
<AppButton variant="ghost" @click="showImport = false">取消</AppButton>
<AppButton variant="primary" @click="doImport">开始导入</AppButton>
</template>
</AppModal>
</div>
</template>
<style scoped>
.news {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 分类 tab */
.news-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
/* 分类 tab覆盖 AppButton 基样式) */
.news-tabs__tab {
height: auto !important;
padding: 6px var(--space-4) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-pill) !important;
background: var(--bg-card) !important;
color: var(--text-secondary) !important;
font-size: var(--fs-label) !important;
font-weight: var(--fw-label-bold) !important;
cursor: pointer;
transition: all var(--motion-hover-fast) var(--ease-default);
}
.news-tabs__tab.is-active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: var(--on-accent) !important;
}
/* 置顶卡 */
.news-pin {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--card-padding-desktop);
border-radius: var(--radius-lg);
background: var(--gradient-hero-deep);
color: var(--on-accent);
text-decoration: none;
}
.news-pin__tag {
display: inline-flex;
align-items: center;
gap: var(--space-2);
align-self: flex-start;
padding: 4px var(--space-3);
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.18);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.news-pin__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.news-pin__title {
margin: 0;
font-size: 18px;
font-weight: var(--fw-h1);
line-height: var(--lh-h1);
}
.news-pin__meta {
margin: 0;
font-size: var(--fs-label);
opacity: 0.8;
}
/* 列表 */
.news-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.news-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--card-padding-desktop);
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
text-decoration: none;
transition: box-shadow var(--motion-card-hover) var(--ease-default);
}
.news-item:hover {
box-shadow: var(--shadow-level-1);
}
.news-item__body {
flex: 1;
min-width: 0;
}
.news-item__title {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.news-item__summary {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
line-height: var(--lh-subtitle);
}
.news-item__meta {
margin: var(--space-2) 0 0;
font-size: var(--fs-label);
color: var(--text-muted);
}
.news-item__badge {
flex: none;
padding: 4px var(--space-3);
border-radius: var(--radius-pill);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.news-item__badge.is-primary {
background: var(--bg-soft);
color: var(--primary);
}
.news-item__badge.is-warning {
background: var(--warning-soft);
color: var(--warning);
}
.news-item__badge.is-success {
background: var(--success-soft);
color: var(--success);
}
.news-item__badge.is-soft {
background: var(--bg-soft);
color: var(--text-secondary);
}
.import-tabs {
display: flex;
gap: var(--space-2);
}
.import-tabs__tab {
height: auto !important;
padding: 6px var(--space-3) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: var(--radius-sm) !important;
background: var(--bg-card) !important;
color: var(--text-secondary) !important;
font-size: var(--fs-label) !important;
font-weight: var(--fw-label-bold) !important;
cursor: pointer;
}
.import-tabs__tab.is-active {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: var(--on-accent) !important;
}
.modal__textarea {
min-height: 160px;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-btn);
background: var(--bg-card);
color: var(--text-primary);
font-family: var(--font-num);
font-size: var(--fs-label);
resize: vertical;
}
.import-hint {
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-soft);
}
.import-hint__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.import-hint__text {
margin: var(--space-1) 0 var(--space-3);
font-size: var(--fs-label);
color: var(--text-secondary);
line-height: var(--lh-subtitle);
}
.input-row {
display: flex;
}
.modal__input {
flex: 1;
height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-btn);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
}
.import-note {
margin: 0;
font-size: var(--fs-label);
color: var(--text-muted);
}
</style>

View File

@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed } from 'vue'
import { plansApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
const review = useRequest(() => plansApi.review())
const data = computed(() => review.data.value)
//
const answered = computed(() => data.value?.answered ?? 0)
const accuracy = computed(() => data.value?.accuracy ?? 0)
//
const reasonTone = ['danger', 'warning', 'primary']
const reasonBarColor = ['var(--danger)', 'var(--warning)', 'var(--primary)', 'var(--text-muted)']
const reasonColor = (i: number) => reasonBarColor[i % reasonBarColor.length]
const totalWeakCount = computed(() =>
(data.value?.weakPoints ?? []).reduce((sum, w) => sum + w.count, 0)
)
const weakCount = (w: { count: number }) => (totalWeakCount.value === 0 ? 0 : Math.round((w.count / totalWeakCount.value) * 100))
const weakTone = ['danger', 'warning', 'primary']
</script>
<template>
<div class="review">
<AppLoading v-if="review.loading.value" fullscreen />
<AppError
v-else-if="review.error.value"
fullscreen
title="周报加载失败"
:message="review.error.value"
retry
@retry="review.refresh"
/>
<template v-else-if="data">
<!-- 页头 -->
<header class="review__head">
<AppButton class="review__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()">
<AppIcon name="chevron-left" :size="20" />
</AppButton>
<h1>周报复盘</h1>
<span class="review__export">本周小结</span>
</header>
<!-- 周区间 + 汇总 -->
<section class="review-hero">
<p class="review-hero__range">
{{ data.weekNumber }} 周复盘{{ formatDateShort(data.weekStart) }} - {{ formatDateShort(data.weekEnd) }}
</p>
<h2 class="review-hero__stats">
本周刷题 {{ answered }} · 正确率 {{ accuracy }}%
</h2>
<p class="review-hero__sub">
学习{{ formatMinutes(data.studyMinutes) }} · 完成任务 {{ data.taskDone }}/{{ data.taskTotal }} · 连续打卡 {{ data.streakDays }}
</p>
</section>
<!-- 本周薄弱考点 Top 3 -->
<AppCard title="本周薄弱考点 Top 3" icon="chart">
<AppEmpty
v-if="(data.weakPoints ?? []).length === 0"
icon="chart"
title="本周没有明显薄弱点"
description="继续保持每日刷题节奏,稳步提升正确率。"
/>
<div v-else class="weak-list">
<div v-for="(w, i) in data.weakPoints" :key="`${w.module}-${w.point}`" class="weak-item">
<span class="weak-item__rank" :class="`is-${weakTone[i % weakTone.length]}`">{{ i + 1 }}</span>
<div class="weak-item__body">
<p class="weak-item__title">{{ w.module }} · {{ w.point }}</p>
<p class="weak-item__meta"> {{ w.count }} · 正确率 {{ w.accuracy }}%</p>
<div class="progress">
<div class="progress__bar" :style="{ width: `${weakCount(w)}%` }" />
</div>
</div>
<span class="weak-item__tag">{{ w.reasons[0] || '待改进' }}</span>
</div>
</div>
</AppCard>
<!-- 错因分布 -->
<AppCard title="错因分布" icon="book">
<AppEmpty v-if="(data.wrongReasons ?? []).length === 0" icon="book" title="本周暂无错因记录" />
<ul v-else class="reason-list">
<li v-for="(r, i) in data.wrongReasons" :key="r.reason" class="reason-item">
<span class="reason-item__label">{{ r.reason }}</span>
<div class="reason-item__track">
<div class="reason-item__bar" :style="{ width: `${Math.min(100, r.count * 12)}%`, background: reasonColor(i) }" />
</div>
<span class="reason-item__value">{{ r.count }}</span>
</li>
</ul>
</AppCard>
<!-- 下周建议 -->
<AppCard title="下周建议" icon="target">
<AppEmpty v-if="(data.suggestions ?? []).length === 0" icon="target" title="暂无专项建议" />
<ul v-else class="suggest-list">
<li v-for="(s, i) in data.suggestions" :key="i">{{ s }}</li>
</ul>
</AppCard>
</template>
</div>
</template>
<style scoped>
.review {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.review__head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.review__back {
display: grid !important;
place-items: center;
width: 36px !important;
height: 36px !important;
padding: 0 !important;
border: none !important;
border-radius: 50% !important;
background: var(--bg-card) !important;
color: var(--text-primary) !important;
gap: 0 !important;
cursor: pointer;
}
.review__head h1 {
margin: 0;
font-size: var(--fs-h1);
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.review__export {
margin-left: auto;
font-size: var(--fs-label);
color: var(--text-muted);
}
.review-hero {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.review-hero__range {
margin: 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.review-hero__stats {
margin: var(--space-2) 0 0;
font-size: 22px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.review-hero__sub {
margin: var(--space-1) 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.weak-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.weak-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.weak-item__rank {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: none;
border-radius: 50%;
color: #fff;
font-family: var(--font-num);
font-size: 13px;
font-weight: 700;
}
.weak-item__rank.is-danger {
background: var(--danger);
}
.weak-item__rank.is-warning {
background: var(--warning);
}
.weak-item__rank.is-primary {
background: var(--primary-bright);
}
.weak-item__body {
flex: 1;
min-width: 0;
}
.weak-item__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.weak-item__meta {
margin: 2px 0 var(--space-2);
font-size: var(--fs-label);
color: var(--text-secondary);
}
.weak-item__tag {
flex: none;
padding: 4px var(--space-2);
border-radius: var(--radius-pill);
background: var(--danger-soft);
color: var(--danger);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
}
.progress {
height: 6px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: var(--radius-pill);
background: var(--primary);
}
.reason-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.reason-item {
display: flex;
align-items: center;
gap: var(--space-3);
}
.reason-item__label {
width: 96px;
flex: none;
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.reason-item__track {
flex: 1;
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.reason-item__bar {
height: 100%;
border-radius: var(--radius-pill);
}
.reason-item__value {
width: 24px;
flex: none;
text-align: right;
font-family: var(--font-num);
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.suggest-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.suggest-list li {
position: relative;
padding-left: var(--space-5);
font-size: var(--fs-subtitle);
color: var(--text-primary);
line-height: var(--lh-subtitle);
}
.suggest-list li::before {
content: '';
position: absolute;
left: 0;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
}
</style>

View File

@ -1,7 +1,695 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
import { computed, onMounted, ref } from 'vue'
import { plansApi, profileApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { formatMinutes, formatDateShort, weekdayName, daysSince } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppModal from '../../components/base/AppModal.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
const { isMobile } = useResponsive()
const app = useAppStore()
const today = useRequest(() => plansApi.today())
const week = useRequest(() => plansApi.week())
const review = useRequest(() => plansApi.review())
//
const showCreate = ref(false)
const saving = ref(false)
const form = ref({ title: '', type: '刷题', module: '', target: '', note: '' })
const MODULE_OPTIONS = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const
const examProfile = useRequest(() => profileApi.get(), false)
onMounted(() => {
if (!examProfile.data.value) examProfile.refresh()
})
const todayData = computed(() => today.data.value)
const weekDays = computed(() => week.data.value?.days ?? [])
const reviewData = computed(() => review.data.value)
// 4
const weekMetric = computed(() => {
const w = weekDays.value
const total = w.reduce((sum, d) => sum + d.total, 0)
const done = w.reduce((sum, d) => sum + d.done, 0)
return { total, done }
})
const weekCompletion = computed(() => {
const m = weekMetric.value
return m.total === 0 ? 0 : Math.round((m.done / m.total) * 100)
})
const studyMinutes = computed(() => reviewData.value?.studyMinutes ?? 0)
const streakDays = computed(() => reviewData.value?.streakDays ?? 0)
const weekNumber = computed(() => reviewData.value?.weekNumber ?? 1)
//
const tomorrowTasks = computed(() => {
const target = new Date()
target.setDate(target.getDate() + 1)
const iso = `${target.getFullYear()}-${String(target.getMonth() + 1).padStart(2, '0')}-${String(target.getDate()).padStart(2, '0')}`
return weekDays.value.find((d) => d.date === iso)?.tasks ?? []
})
const planMessage = computed(() => {
const d = todayData.value
if (!d) return ''
return `${formatDateShort(d.date)} ${weekdayName(d.date)} · 完成 ${d.done}/${d.total}`
})
const todayProgress = computed(() => {
const d = todayData.value
if (!d || d.total === 0) return 0
return Math.round((d.done / d.total) * 100)
})
//
const weekProgress = computed(() => weekCompletion.value)
const weekAnswered = computed(() => reviewData.value?.answered ?? 0)
const weekTargetAnswered = computed(() => Math.max(weekAnswered.value, Math.max(weekMetric.value.total * 10, 10)))
const weekAnsweredPercent = computed(() => {
const t = weekTargetAnswered.value
return t === 0 ? 0 : Math.min(100, Math.round((weekAnswered.value / t) * 100))
})
const taskTypeTone = (type: string) =>
(type ?? '').includes('复习') || (type ?? '').includes('复盘') ? 'warning' : 'soft'
const taskTypeLabel = (type: string) => (type && type.length > 6 ? type.slice(0, 6) : type || '任务')
async function toggle(task: { id: string; status: string }) {
try {
const result = await plansApi.toggleTask(task.id)
app.toast(result.status === 'done' ? '任务已完成' : '已恢复为待完成', 'success')
today.refresh()
week.refresh()
review.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '操作失败', 'error')
}
}
async function createTask() {
const f = form.value
if (!f.title.trim() || !f.target.trim()) {
app.toast('请填写任务标题与目标', 'error')
return
}
saving.value = true
try {
const date = new Date()
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
await plansApi.createTask({
date: iso,
title: f.title.trim(),
type: f.type,
...(f.module ? { module: f.module as '言语理解' | '数量关系' | '判断推理' | '资料分析' | '常识判断' } : {}),
target: f.target.trim(),
...(f.note ? { note: f.note } : {})
})
app.toast('任务已创建', 'success')
showCreate.value = false
form.value = { title: '', type: '刷题', module: '', target: '', note: '' }
today.refresh()
week.refresh()
review.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '创建失败', 'error')
} finally {
saving.value = false
}
}
const profileDays = computed(() => (examProfile.data.value ? daysSince(examProfile.data.value.startedAt) : 0))
</script>
<template>
<ComingSoon />
<div class="plan">
<AppLoading v-if="today.loading.value" fullscreen />
<AppError
v-else-if="today.error.value"
fullscreen
title="计划加载失败"
:message="today.error.value"
retry
@retry="today.refresh"
/>
<!-- ============ 移动端今日计划 ============ -->
<div v-else-if="isMobile && todayData" class="plan-mobile">
<header class="plan-mobile__head">
<h1>今日计划</h1>
<p>{{ planMessage }}</p>
</header>
<template v-if="todayData.total > 0">
<div class="plan-list">
<AppCard
v-for="task in todayData.tasks"
:key="task.id"
class="plan-task"
:class="{ 'is-done': task.status === 'done' }"
>
<AppButton
class="plan-task__check"
:class="{ 'is-done': task.status === 'done' }"
variant="ghost"
size="md"
:aria-label="task.status === 'done' ? '标记为待完成' : '标记为完成'"
@click="toggle(task)"
>
<AppIcon v-if="task.status === 'done'" name="check" :size="16" />
</AppButton>
<div class="plan-task__body">
<p class="plan-task__title">{{ task.title }}</p>
<p class="plan-task__meta">
{{ task.type }}<template v-if="task.module"> · {{ task.module }}</template> · {{ task.target }}
</p>
</div>
<AppBadge class="plan-task__badge" :variant="taskTypeTone(task.type)">{{
taskTypeLabel(task.type)
}}</AppBadge>
</AppCard>
</div>
<div class="plan-add">
<AppButton variant="secondary" block @click="showCreate = true">
<AppIcon name="check" :size="16" />新建任务
</AppButton>
</div>
<!-- 本周计划 -->
<AppCard class="plan-week">
<div class="plan-week__head">
<h3>本周计划</h3>
<span class="plan-week__tag"> {{ reviewData?.weekNumber ?? 1 }} </span>
</div>
<div class="plan-week__meta">
<span>完成 {{ weekMetric.done }} / 目标 {{ weekMetric.total }} </span>
</div>
<div class="progress">
<div class="progress__bar" :style="{ width: `${weekProgress}%` }" />
</div>
<span class="plan-week__percent">{{ weekProgress }}%</span>
</AppCard>
<!-- 周报复盘入口 -->
<RouterLink class="plan-review-link" to="/plan/review">
<span>本周复盘 · 查看学习表现与薄弱考点</span>
<AppIcon name="chevron-right" :size="16" />
</RouterLink>
<!-- 明日预告 -->
<AppCard v-if="tomorrowTasks.length > 0" title="明日预告">
<div class="plan-tomorrow__row" v-for="task in tomorrowTasks" :key="task.id">
<span class="plan-tomorrow__dot" />
<div class="plan-tomorrow__body">
<p class="plan-tomorrow__title">{{ task.title }}</p>
<p class="plan-tomorrow__meta">{{ task.type }}<template v-if="task.module"> · {{ task.module }}</template> · {{ task.target }}</p>
</div>
</div>
</AppCard>
</template>
<!-- 今日空状态 -->
<AppEmpty
v-else
icon="calendar"
title="今天还没有任务"
description="按早/午/晚时段安排,碎片时间不浪费。"
>
<AppButton variant="primary" @click="showCreate = true">创建今日任务</AppButton>
</AppEmpty>
</div>
<!-- ============ 桌面端备考计划 ============ -->
<div v-else-if="todayData" class="plan-desktop">
<AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`">
<RouterLink class="btn-link" to="/plan/review">
<AppIcon name="chart" :size="16" />周报复盘
</RouterLink>
<AppButton variant="primary" @click="showCreate = true">
<AppIcon name="check" :size="16" />新建任务
</AppButton>
</AppPageHeader>
<!-- 4 指标卡 -->
<div class="plan-metrics">
<div class="plan-metric">
<span class="plan-metric__label">本周任务</span>
<strong class="plan-metric__value">{{ weekMetric.total }} </strong>
<span class="plan-metric__sub">已完成 {{ weekMetric.done }} </span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">本周学习时长</span>
<strong class="plan-metric__value">{{ formatMinutes(studyMinutes) }}</strong>
<span class="plan-metric__sub">每日目标循序渐进</span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">本周完成率</span>
<strong class="plan-metric__value">{{ weekCompletion }}%</strong>
<span class="plan-metric__sub">较上周持续提升</span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">连续打卡</span>
<strong class="plan-metric__value">{{ streakDays }} </strong>
<span class="plan-metric__sub">本周第 {{ weekNumber }} </span>
</div>
</div>
<div class="plan-grid">
<!-- 今日任务 -->
<AppCard class="plan-today" title="今日任务" icon="calendar">
<AppEmpty
v-if="todayData.total === 0"
icon="calendar"
title="今天还没有任务"
description="点击右上角新建今日任务,安排阶段节奏。"
/>
<ul v-else class="plan-today__list">
<li
v-for="task in todayData.tasks"
:key="task.id"
class="plan-today__item"
:class="{ 'is-done': task.status === 'done' }"
>
<AppButton
class="plan-today__check"
:class="{ 'is-done': task.status === 'done' }"
variant="ghost"
size="md"
:aria-label="task.status === 'done' ? '标记为待完成' : '标记为完成'"
@click="toggle(task)"
>
<AppIcon v-if="task.status === 'done'" name="check" :size="14" />
</AppButton>
<span class="plan-today__title">{{ task.title }} · {{ task.target }}</span>
<AppBadge v-if="task.status === 'done'" variant="success">已完成</AppBadge>
<AppBadge v-else class="plan-today__pending">待完成</AppBadge>
</li>
</ul>
</AppCard>
<!-- 本周安排 -->
<AppCard class="plan-side" title="本周安排" icon="book">
<AppEmpty v-if="weekDays.length === 0" icon="calendar" title="暂无本周任务" />
<div v-else class="plan-side__list">
<div v-for="day in weekDays" :key="day.date" class="plan-side__day">
<p class="plan-side__date">{{ formatDateShort(day.date) }} · {{ weekdayName(day.date) }}</p>
<ul v-if="day.tasks.length > 0" class="plan-side__tasks">
<li v-for="task in day.tasks" :key="task.id" :class="{ 'is-done': task.status === 'done' }">
{{ task.title }}
</li>
</ul>
<p v-else class="plan-side__blank">暂无安排</p>
</div>
</div>
</AppCard>
</div>
</div>
<!-- 新建任务弹层 -->
<AppModal v-if="showCreate" title="新建计划任务" :max-width="480" @close="showCreate = false">
<div class="form-stack">
<label class="field">
<span class="field__label">任务标题</span>
<input v-model="form.title" class="field__input" placeholder="如:言语理解专项" />
</label>
<label class="field">
<span class="field__label">任务类型</span>
<select v-model="form.type" class="field__input">
<option value="刷题">刷题</option>
<option value="阅读">阅读</option>
<option value="复习">复习</option>
<option value="模考">模考</option>
</select>
</label>
<div class="field-grid">
<label class="field">
<span class="field__label">模块可选</span>
<select v-model="form.module" class="field__input">
<option value="">不指定</option>
<option v-for="m in MODULE_OPTIONS" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label class="field">
<span class="field__label">目标</span>
<input v-model="form.target" class="field__input" placeholder="如:完成 20 题" />
</label>
</div>
<label class="field">
<span class="field__label">备注可选</span>
<textarea v-model="form.note" class="field__input field__textarea" placeholder="补充说明" />
</label>
</div>
<template #footer>
<AppButton variant="ghost" @click="showCreate = false">取消</AppButton>
<AppButton variant="primary" :disabled="saving" @click="createTask">
{{ saving ? '保存中…' : '保存任务' }}
</AppButton>
</template>
</AppModal>
</div>
</template>
<style scoped>
.plan {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 指标卡 */
.plan-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--card-gap-desktop);
}
.plan-metric {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--card-padding-desktop);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-metric__label {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.plan-metric__value {
font-family: var(--font-num);
font-size: var(--fs-data-num);
font-weight: var(--fw-data-num);
color: var(--text-primary);
}
.plan-metric__sub {
font-size: var(--fs-label);
color: var(--text-muted);
}
.plan-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--card-gap-desktop);
align-items: start;
}
/* 今日任务列表 */
.plan-today__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-today__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.plan-today__item.is-done {
background: var(--success-soft);
border-color: transparent;
}
.plan-today__check {
display: grid !important;
place-items: center;
width: 20px !important;
height: 20px !important;
flex: none;
padding: 0 !important;
border-radius: 50% !important;
border: 1.5px solid var(--text-muted) !important;
background: transparent !important;
color: var(--text-muted) !important;
gap: 0 !important;
cursor: pointer;
}
.plan-today__check.is-done {
background: var(--success) !important;
border-color: var(--success) !important;
color: #fff !important;
}
.plan-today__title {
flex: 1;
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.plan-today__pending {
color: var(--text-muted);
background: var(--bg-soft);
display: inline-flex;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--fs-label);
}
/* 本周安排 */
.plan-side__list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.plan-side__date {
margin: 0 0 var(--space-1);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--text-secondary);
}
.plan-side__tasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.plan-side__tasks li {
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.plan-side__tasks li.is-done {
color: var(--text-muted);
text-decoration: line-through;
}
.plan-side__blank {
margin: 0;
font-size: var(--fs-label);
color: var(--text-muted);
}
/* 移动端 */
.plan-mobile {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plan-mobile__head h1 {
margin: 0;
font-size: 22px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.plan-mobile__head p {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.plan-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-task {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--card-padding-mobile);
}
.plan-task.is-done {
background: var(--success-soft);
border-color: transparent;
}
.plan-task__check {
display: grid !important;
place-items: center;
width: 26px !important;
height: 26px !important;
flex: none;
padding: 0 !important;
border-radius: 50% !important;
border: 1.5px solid var(--text-muted) !important;
background: transparent !important;
color: var(--text-muted) !important;
gap: 0 !important;
cursor: pointer;
}
.plan-task__check.is-done {
background: var(--success) !important;
border-color: var(--success) !important;
color: #fff !important;
}
.plan-task__body {
flex: 1;
min-width: 0;
}
.plan-task__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-task__meta {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.plan-task__badge {
flex: none;
}
.plan-add {
margin-top: var(--space-1);
}
.plan-week {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plan-week__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.plan-week__head h3 {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-week__tag {
font-size: var(--fs-label);
color: var(--text-muted);
}
.plan-week__meta {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.progress {
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: var(--radius-pill);
background: var(--primary);
transition: width var(--motion-switch) var(--ease-default);
}
.plan-week__percent {
align-self: flex-end;
font-size: var(--fs-label);
color: var(--primary);
font-weight: var(--fw-label-bold);
}
.plan-review-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--bg-soft);
border-radius: var(--radius-lg);
color: var(--primary);
font-size: var(--fs-subtitle);
text-decoration: none;
}
.plan-tomorrow__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.plan-tomorrow__dot {
width: 10px;
height: 10px;
flex: none;
border-radius: 50%;
background: var(--bg-soft);
}
.plan-tomorrow__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-tomorrow__meta {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
@media (max-width: 1023px) {
.plan-metrics {
grid-template-columns: repeat(2, 1fr);
}
.plan-grid {
grid-template-columns: 1fr;
}
}
/* 弹层表单字段堆叠 */
.form-stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field__label {
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--text-secondary);
}
.field__input {
height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-btn);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
}
.field__input:focus {
outline: none;
border-color: var(--primary);
}
.field__textarea {
height: auto;
min-height: 72px;
padding: var(--space-3);
resize: vertical;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
</style>

View File

@ -1,7 +1,547 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
import { computed, onMounted } from 'vue'
import { dashboardApi, plansApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { useProfileStore } from '../../stores/profile'
import { formatMinutes, daysSince } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
const { isMobile } = useResponsive()
const app = useAppStore()
const profile = useProfileStore()
const overview = useRequest(() => dashboardApi.overview())
const review = useRequest(() => plansApi.review())
onMounted(() => {
if (!profile.profile) profile.fetch()
})
const d = computed(() => overview.data.value)
const r = computed(() => review.data.value)
const nickname = computed(() => profile.nickname)
const targetScore = computed(() => profile.targetScore)
const examDate = computed(() => profile.examDate)
const studyDays = computed(() => (profile.profile ? daysSince(profile.profile.startedAt) : 0))
//
const totalAnswered = computed(() => d.value?.totalAnswered ?? 0)
const accuracy = computed(() => d.value?.accuracy ?? 0)
const weekMinutes = computed(() => r.value?.studyMinutes ?? 0)
const streakDays = computed(() => d.value?.streakDays ?? 0)
//
const milestones = computed(() => {
const list: { icon: 'flame' | 'pen' | 'chart' | 'target'; title: string; sub: string; tone: string }[] = []
if (totalAnswered.value >= 100) {
list.push({ icon: 'pen', title: `累计刷题 ${totalAnswered.value}`, sub: '刷题里程碑 · 持续积累', tone: 'primary' })
} else if (totalAnswered.value > 0) {
list.push({ icon: 'pen', title: `今日完成 ${d.value?.todayAnswered ?? 0}`, sub: '刷题里程碑 · 起步阶段', tone: 'primary' })
} else {
list.push({ icon: 'pen', title: '开始第一次刷题', sub: '刷题里程碑 · 从今日起步', tone: 'primary' })
}
if (streakDays.value >= 1) {
list.push({ icon: 'flame', title: `连续打卡 ${streakDays.value}`, sub: '学习习惯 · 持续中', tone: 'success' })
}
const latest = d.value?.todayAccuracy ?? 0
if (latest > 0) {
list.push({ icon: 'chart', title: `今日正确率 ${latest}%`, sub: '作答表现 · 保持节奏', tone: 'success' })
}
list.push({ icon: 'target', title: `目标分数 ${targetScore.value}`, sub: '目标设定 · 稳步接近', tone: 'warning' })
return list
})
// /
const suggestions = computed(() => r.value?.suggestions ?? [])
const weakTip = computed(() => {
const w = r.value?.weakPoints?.[0]
return w ? `本周「${w.module} · ${w.point}」正确率 ${w.accuracy}%,建议加强专项训练。` : '本周各模块稳定,建议继续保持刷题节奏。'
})
function editProfile() {
app.toast('拍摄/编辑资料:请前往「设置」修改目标与考试日期', 'info')
}
</script>
<template>
<ComingSoon />
<div class="profile">
<AppLoading v-if="overview.loading.value" fullscreen />
<AppError
v-else-if="overview.error.value"
fullscreen
title="档案加载失败"
:message="overview.error.value"
retry
@retry="overview.refresh"
/>
<!-- ============ 移动端我的 ============ -->
<div v-else-if="isMobile && d" class="profile-mobile">
<header class="profile-mobile__head">
<h1>我的</h1>
</header>
<!-- 头像卡 -->
<AppCard class="me-card" @click="editProfile">
<div class="me-card__avatar">{{ nickname.slice(0, 1) }}</div>
<div class="me-card__body">
<p class="me-card__name">{{ nickname }}</p>
<div class="me-card__meta">
<span class="me-card__exam">国考 · 行测</span>
<AppBadge variant="success">备考 {{ studyDays }} </AppBadge>
</div>
</div>
<AppIcon class="me-card__chevron" name="chevron-right" :size="18" />
</AppCard>
<!-- 概览 -->
<AppCard class="profile-overview">
<div class="profile-overview__grid">
<div class="profile-overview__item">
<strong>{{ totalAnswered }}</strong>
<span>累计刷题</span>
</div>
<div class="profile-overview__item">
<strong>{{ formatMinutes(weekMinutes) }}</strong>
<span>本周时长</span>
</div>
<div class="profile-overview__item">
<strong>{{ streakDays }} </strong>
<span>连续打卡</span>
</div>
<div class="profile-overview__item">
<strong>{{ accuracy }}%</strong>
<span>正确率</span>
</div>
</div>
</AppCard>
<!-- 设置入口 -->
<AppCard class="profile-nav">
<RouterLink class="profile-nav__item" to="/settings">
<span class="profile-nav__icon"><AppIcon name="target" :size="18" /></span>
<div class="profile-nav__body">
<p class="profile-nav__title">目标分数设置</p>
<p class="profile-nav__sub">当前目标 {{ targetScore }} </p>
</div>
<AppIcon name="chevron-right" :size="18" />
</RouterLink>
</AppCard>
<AppCard class="profile-nav">
<RouterLink class="profile-nav__item" to="/plan/review">
<span class="profile-nav__icon profile-nav__icon--soft"><AppIcon name="chart" :size="18" /></span>
<div class="profile-nav__body">
<p class="profile-nav__title">本周复盘</p>
<p class="profile-nav__sub">学习表现与薄弱考点</p>
</div>
<AppIcon name="chevron-right" :size="18" />
</RouterLink>
<RouterLink class="profile-nav__item" to="/news">
<span class="profile-nav__icon profile-nav__icon--soft"><AppIcon name="news" :size="18" /></span>
<div class="profile-nav__body">
<p class="profile-nav__title">时政要闻</p>
<p class="profile-nav__sub">备考资讯与政策动态</p>
</div>
<AppIcon name="chevron-right" :size="18" />
</RouterLink>
</AppCard>
<!-- 本周学习建议 -->
<AppCard class="profile-tip">
<span class="profile-tip__icon"><AppIcon name="target" :size="18" /></span>
<div class="profile-tip__body">
<p class="profile-tip__title">本周学习建议</p>
<p class="profile-tip__text">{{ weakTip }}</p>
</div>
</AppCard>
</div>
<!-- ============ 桌面端我的 ============ -->
<div v-else-if="d" class="profile-desktop">
<AppPageHeader title="我的" subtitle="管理个人资料与备考偏好">
<AppButton variant="secondary" @click="editProfile">
<AppIcon name="user" :size="16" />编辑资料
</AppButton>
<RouterLink class="btn-link" to="/settings">账号设置</RouterLink>
</AppPageHeader>
<!-- 头像卡 -->
<AppCard class="me-card">
<div class="me-card__avatar">{{ nickname.slice(0, 1) }}</div>
<div class="me-card__body">
<p class="me-card__name">{{ nickname }}</p>
<p class="me-card__meta">
目标{{ examDate.slice(0, 4) }} 国考 · 行测 {{ targetScore }} · 备考第 {{ studyDays }}
</p>
</div>
</AppCard>
<!-- 设置卡片 -->
<AppCard title="设置" icon="settings">
<RouterLink to="/settings" class="setting-row">
<span class="setting-row__icon"><AppIcon name="target" :size="18" /></span>
<div class="setting-row__body">
<p class="setting-row__title">目标设置</p>
<p class="setting-row__sub">调整备考目标与考试日期</p>
</div>
<AppIcon name="chevron-right" :size="18" />
</RouterLink>
</AppCard>
<!-- 备考概览 -->
<AppCard title="备考概览" icon="chart">
<div class="profile-overview__grid">
<div class="profile-overview__item">
<strong>{{ totalAnswered }}</strong>
<span>累计刷题</span>
</div>
<div class="profile-overview__item">
<strong>{{ formatMinutes(weekMinutes) }}</strong>
<span>本周时长</span>
</div>
<div class="profile-overview__item">
<strong>{{ streakDays }} </strong>
<span>连续打卡</span>
</div>
<div class="profile-overview__item">
<strong>{{ accuracy }}%</strong>
<span>正确率</span>
</div>
</div>
</AppCard>
<!-- 备考里程碑 -->
<AppCard title="备考里程碑" icon="flame">
<div class="milestones">
<div v-for="(m, i) in milestones" :key="i" class="milestone" :class="`is-${m.tone}`">
<span class="milestone__icon"><AppIcon :name="m.icon" :size="22" /></span>
<div class="milestone__body">
<p class="milestone__title">{{ m.title }}</p>
<p class="milestone__sub">{{ m.sub }}</p>
</div>
</div>
</div>
</AppCard>
<!-- 本周学习建议 -->
<div class="tip-banner">
<span class="tip-banner__icon"><AppIcon name="target" :size="20" /></span>
<div class="tip-banner__body">
<p class="tip-banner__title">本周学习建议</p>
<p class="tip-banner__text">{{ weakTip }}</p>
</div>
<RouterLink class="tip-banner__action" to="/plan/review">去加强</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.profile {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 头像卡 */
.me-card {
display: flex;
align-items: center;
gap: var(--space-4);
cursor: pointer;
transition: box-shadow var(--motion-card-hover) var(--ease-default);
}
.me-card:hover {
box-shadow: var(--shadow-level-1);
}
.me-card__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
flex: none;
border-radius: 50%;
background: var(--gradient-hero-deep);
color: var(--on-accent);
font-size: 22px;
font-weight: var(--fw-h1);
}
.me-card__body {
flex: 1;
min-width: 0;
}
.me-card__name {
margin: 0;
font-size: 18px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.me-card__meta {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.me-card__chevron {
color: var(--text-muted);
}
/* 设置行 */
.setting-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
text-decoration: none;
color: var(--text-primary);
}
.setting-row__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--bg-soft);
color: var(--primary);
}
.setting-row__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.setting-row__sub {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
/* 备考概览 */
.profile-overview__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
}
.profile-overview__item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.profile-overview__item strong {
font-family: var(--font-num);
font-size: var(--fs-data-num);
font-weight: var(--fw-data-num);
color: var(--text-primary);
}
.profile-overview__item span {
font-size: var(--fs-label);
color: var(--text-secondary);
}
/* 里程碑 */
.milestones {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--card-gap-desktop);
}
.milestone {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--bg-soft);
}
.milestone__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border-radius: 50%;
}
.milestone.is-primary .milestone__icon {
background: var(--success-soft);
color: var(--success);
}
.milestone.is-success .milestone__icon {
background: var(--success-soft);
color: var(--success);
}
.milestone.is-warning .milestone__icon {
background: var(--warning-soft);
color: var(--warning);
}
.milestone__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.milestone__sub {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
/* 学习建议 */
.tip-banner {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-2);
padding: var(--space-4) var(--space-5);
border-radius: var(--radius-lg);
background: var(--success-soft);
}
.tip-banner__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: #fff;
color: var(--success);
}
.tip-banner__body {
flex: 1;
}
.tip-banner__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.tip-banner__text {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.tip-banner__action {
flex: none;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-pill);
background: var(--primary);
color: var(--on-accent);
font-size: var(--fs-label);
font-weight: var(--fw-button);
text-decoration: none;
}
/* 移动端 */
.profile-mobile {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.profile-mobile__head h1 {
margin: 0;
font-size: 22px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.me-card__meta {
display: flex;
align-items: center;
gap: var(--space-2);
}
.me-card__exam {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.profile-overview {
margin-top: var(--space-2);
}
.profile-overview__grid {
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
}
.profile-overview__item strong {
font-size: 16px;
}
.profile-nav__item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
text-decoration: none;
color: var(--text-primary);
}
.profile-nav__item:not(:last-child) {
border-bottom: 1px solid var(--border-subtle);
}
.profile-nav__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: var(--primary);
color: var(--on-accent);
}
.profile-nav__icon--soft {
background: var(--bg-soft);
color: var(--primary);
}
.profile-nav__body {
flex: 1;
}
.profile-nav__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.profile-nav__sub {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.profile-tip {
display: flex;
gap: var(--space-3);
background: var(--success-soft);
border-color: transparent;
}
.profile-tip__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: #fff;
color: var(--success);
}
.profile-tip__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.profile-tip__text {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
@media (max-width: 1023px) {
.profile-overview__grid {
grid-template-columns: repeat(2, 1fr);
}
.milestones {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -1,7 +1,320 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
import { computed, onMounted, ref, watch } from 'vue'
import { profileApi, settingsApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { useProfileStore } from '../../stores/profile'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
const { isMobile } = useResponsive()
const app = useAppStore()
const profile = useProfileStore()
const settings = useRequest(() => settingsApi.get())
const profileReq = useRequest(() => profileApi.get(), false)
onMounted(() => {
if (!profileReq.data.value) profileReq.refresh()
})
//
const form = ref({
targetScore: 72,
examDate: '2026-11-30',
reminderTime: '19:30',
preferredDuration: 15,
preferredDifficulty: '中等',
learningReminder: true,
mockPush: false
})
const saving = ref(false)
watch([settings.data, profileReq.data], () => {
const s = settings.data.value
const p = profileReq.data.value
if (!s || !p) return
form.value = {
targetScore: p.targetScore,
examDate: p.examDate,
reminderTime: s.reminderTime,
preferredDuration: s.preferredDuration,
preferredDifficulty: s.preferredDifficulty ?? '中等',
learningReminder: s.learningReminder ?? true,
mockPush: s.mockPush ?? false
}
}, { immediate: true })
const aiConfigured = computed(() => settings.data.value?.aiConfigured ?? false)
async function save() {
saving.value = true
try {
//
const nextSettings = await settingsApi.patch({
reminderTime: form.value.reminderTime,
preferredDuration: form.value.preferredDuration as 5 | 10 | 15,
preferredDifficulty: form.value.preferredDifficulty as '简单' | '中等' | '困难',
learningReminder: form.value.learningReminder,
mockPush: form.value.mockPush
})
// /
await profileApi.patch({
targetScore: Number(form.value.targetScore),
examDate: form.value.examDate
})
// store
settings.data.value = nextSettings
profile.fetch()
profileReq.refresh()
app.toast('设置已保存', 'success')
} catch (err) {
app.toast(err instanceof Error ? err.message : '保存失败', 'error')
} finally {
saving.value = false
}
}
</script>
<template>
<ComingSoon />
<div class="settings">
<AppLoading v-if="settings.loading.value" fullscreen />
<AppError
v-else-if="settings.error.value"
fullscreen
title="设置加载失败"
:message="settings.error.value"
retry
@retry="settings.refresh"
/>
<template v-else-if="settings.data.value">
<!-- 页头 -->
<AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile>
<AppButton variant="primary" :disabled="saving" @click="save">
<AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }}
</AppButton>
</AppPageHeader>
<!-- 刷题设置 -->
<AppCard title="刷题设置" icon="pen">
<div class="setting-row">
<span class="setting-row__label">每日提醒时间</span>
<input v-model="form.reminderTime" class="setting-row__time" type="time" />
</div>
<div class="setting-row">
<span class="setting-row__label">题库难度偏好</span>
<select v-model="form.preferredDifficulty" class="setting-row__select">
<option value="简单">简单</option>
<option value="中等">中等</option>
<option value="困难">困难</option>
</select>
</div>
<div class="setting-row">
<span class="setting-row__label">默认答题时长</span>
<select v-model="form.preferredDuration" class="setting-row__select">
<option :value="5">5 分钟</option>
<option :value="10">10 分钟</option>
<option :value="15">15 分钟</option>
</select>
</div>
</AppCard>
<!-- 调查偏好 -->
<AppCard title="偏好设置" icon="target">
<div class="setting-row">
<span class="setting-row__label">目标分数</span>
<div class="setting-row__control">
<input v-model.number="form.targetScore" class="setting-row__input" type="number" min="0" max="100" />
<span class="setting-row__unit"></span>
</div>
</div>
<div class="setting-row">
<span class="setting-row__label">考试日期</span>
<input v-model="form.examDate" class="setting-row__date" type="date" />
</div>
</AppCard>
<!-- 通知 -->
<AppCard title="通知" icon="inbox">
<div class="setting-row">
<span class="setting-row__label">学习提醒</span>
<AppButton
class="toggle"
:class="{ 'is-on': form.learningReminder }"
variant="ghost"
size="md"
role="switch"
:aria-checked="form.learningReminder"
@click="form.learningReminder = !form.learningReminder"
>
<span class="toggle__knob" />
</AppButton>
</div>
<div class="setting-row">
<span class="setting-row__label">模考成绩推送</span>
<AppButton
class="toggle"
:class="{ 'is-on': form.mockPush }"
variant="ghost"
size="md"
role="switch"
:aria-checked="form.mockPush"
@click="form.mockPush = !form.mockPush"
>
<span class="toggle__knob" />
</AppButton>
</div>
</AppCard>
<!-- AI 配置状态 -->
<AppCard title="AI 讲解" icon="settings">
<div class="setting-row">
<span class="setting-row__label">AI 讲解状态</span>
<AppBadge :variant="aiConfigured ? 'success' : 'warning'">
{{ aiConfigured ? '已配置' : '未配置' }}
</AppBadge>
</div>
<p class="setting-hint">
{{ aiConfigured
? 'AI 讲解已就绪,可在刷题结果页发起更深入的题目解析。'
: '在服务端环境变量中配置 AI_BASE_URL 与 AI_API_KEY 后即可启用 AI 讲解Token 仅存于后端)。' }}
</p>
</AppCard>
<!-- 关于与版本 -->
<AppCard title="关于与版本" icon="book">
<div class="about-row">
<div class="about-row__body">
<p class="about-row__title">备考通 v1.0.0</p>
<p class="about-row__sub">个人备考数据中枢 · 数据归你所有</p>
</div>
<span class="about-row__badge">更新日志</span>
</div>
</AppCard>
</template>
</div>
</template>
<style scoped>
.settings {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4) 0;
}
.setting-row:not(:last-child) {
border-bottom: 1px solid var(--border-subtle);
}
.setting-row__label {
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.setting-row__control {
display: flex;
align-items: center;
gap: var(--space-2);
}
.setting-row__input,
.setting-row__select,
.setting-row__time,
.setting-row__date {
height: 36px;
padding: 0 var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
}
.setting-row__input {
width: 72px;
text-align: right;
}
.setting-row__unit {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.setting-row__select {
min-width: 96px;
}
.setting-hint {
margin: var(--space-2) 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
line-height: var(--lh-subtitle);
}
/* 开关(覆盖 AppButton 基样式,还原 switch 形态) */
.toggle {
display: grid !important;
place-items: center;
width: 44px !important;
height: 24px !important;
padding: 0 !important;
border: none !important;
border-radius: var(--radius-pill) !important;
background: var(--bg-soft) !important;
color: var(--text-secondary) !important;
gap: 0 !important;
flex: none;
cursor: pointer;
transition: background var(--motion-hover-fast) var(--ease-default);
}
.toggle:hover {
background: var(--bg-soft) !important;
}
.toggle.is-on {
background: var(--primary) !important;
}
.toggle__knob {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-card);
box-shadow: var(--shadow-level-1);
transition: transform var(--motion-switch) var(--ease-default);
transform: translateX(-10px);
}
.toggle.is-on .toggle__knob {
transform: translateX(10px);
}
/* 关于 */
.about-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.about-row__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.about-row__sub {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.about-row__badge {
flex: none;
color: var(--primary);
font-size: var(--fs-label);
font-weight: var(--fw-button);
}
</style>

85
docs/checks/task-06.md Normal file
View File

@ -0,0 +1,85 @@
# 任务 06 检查记录:计划、个人中心、设置与要闻
日期2026-09-01含演示数据初始化后的二次校准
## 交付内容
### 后端Handlers + Service + Schema + Routes
- `server/src/handlers/plans.ts`
- `createTask`:从纯返回对象改为 `updateData` 持久化写入 `study-plans.json`
- `toggleTask`:写入新状态后返回。
- 新增 `review()`(周报复盘):返回 `weekStart/weekEnd/studyMinutes/answered/correct/accuracy/taskDone/taskTotal/weekNumber/streakDays/checkedInToday/weakPoints/wrongReasons/suggestions`
- 内部辅助:`buildSuggestions()`(自动生成下周建议)、`computeReasons()`(错因聚合)。
- `server/src/handlers/profile.ts``patchProfile`/`patchSettings` 从返回合并对象改为 `updateData` 持久化;`DEFAULT_SETTINGS` 增加 `preferredDifficulty`/`learningReminder`/`mockPush`
- `server/src/handlers/news.ts``importJson` 实现指纹去重(标题+分类+发布时间)+ 写库;`importRss/importApi/importUrl` 保留占位(`failed: 1` + 明确 message
- `server/src/services/stats.ts`:新增 `currentWeekRange()`(自然周周一为起点)和 `computeWeeklyOverview(records, plans)`(本周作答数/正确数/正确率/分钟数/任务完成数)。
- `server/src/schemas/api.ts`:新增 `PlanReviewResponseSchema`(含 `streakDays`/`checkedInToday` 等字段)。
- `server/src/schemas/entities.ts``StudyPlanSchema` 增加 `note?: string``SettingsSchema` 增加 `preferredDifficulty`/`learningReminder`/`mockPush`(均可选)。
- `server/src/routes.ts`:新增路由 `GET /api/plans/review`tags: 备考计划),路由数 31 → 34。
- `server/src/data/seed.ts`:扩充演示要闻至 5 条(覆盖 公告/联考/政策/时政/政治 分类),更新 settings 默认值(含新字段)。
- `server/src/types/index.ts``StudyPlan` 类型增加 `note?: string`
### 前端Views + Router + Utils + API
- `client/src/utils/markdown.ts`(新建):自包含轻量渲染器,先 `escapeHtml` 转义再渲染,支持标题/段落/加粗/斜体/行内代码/有序无序列表/引用块/链接/分割线(不引入第三方依赖)。
- `client/src/utils/format.ts`:新增 `weekOfYear`/`weekdayName`/`formatDateWeekday`/`timeAgo`/`daysSince`
- `client/src/api/index.ts`:新增类型 `PlansToday`/`PlansWeek`/`PlanReview`/`StudyPlanItem`/`NewsItem`/`ImportResult``plansApi` 新增 `review()`
- `client/src/views/plan/PlanView.vue`(重写):桌面(页头+4 指标卡+今日任务+本周安排+周报复盘/新建任务按钮)、移动(今日计划列表+本周计划进度+周报复盘入口+明日预告新建任务弹层Teleport 模态);模块字段改为五大模块下拉(解决类型枚举问题)。
- `client/src/views/plan/PlanReviewView.vue`(新建):页头返回按钮、周区间、本周刷题/正确率汇总、本周薄弱考点 Top 3、错因分布语义色进度条、下周建议。
- `client/src/views/profile/ProfileView.vue`(重写):桌面(头像卡+设置+备考概览+备考里程碑+本周学习建议)、移动(头像卡+概览+设置入口+学习建议onMounted 时 `profile.fetch()` 加载真实昵称/目标/备考天数。
- `client/src/views/settings/SettingsView.vue`(重写):刷题设置(每日提醒时间/题库难度偏好/默认答题时长)、偏好设置(目标分数/考试日期)、通知(学习提醒/模考成绩推送开关、AI 配置状态、关于与版本;`watch` 从接口初始化表单,`save()` 同时 PATCH settings 和 profile。
- `client/src/views/news/NewsView.vue`(重写):分类 Tab全部/公告/联考/政策/时政/申论)、置顶深蓝卡、列表卡(分类徽章+来源+相对时间、导入弹层JSON/RSS/API/URL 四 Tab
- `client/src/views/news/NewsDetailView.vue`新建返回按钮、hero 卡(分类徽章+标题+来源日期)、`v-html="renderMarkdown(content)"` 渲染正文。
- `client/src/router/index.ts`:新增路由 `/plan/review`PlanReviewView`/news/:id`NewsDetailView
## 对照原型图(结构核对)
| 原型要素 | 实现 |
|---|---|
| 桌面-备考计划:页头标题+「备考第 93 天」+ 周报复盘/新建任务按钮 | ✅ |
| 桌面-备考计划:本周任务 / 学习时长 / 完成率 / 连续打卡 4 指标卡 | ✅ |
| 桌面-备考计划:今日任务(完成态+待完成态,含「待完成」标签与分类徽章) | ✅ |
| 桌面-备考计划:本周安排(周一至周日,含暂无安排) | ✅ |
| 移动-备考计划:今日计划列表 + 本周计划进度条(60%) + 周报复盘入口 | ✅ |
| 桌面-我的:头像卡(昵称/目标/备考天数)+ 设置 + 备考概览 + 备考里程碑 + 本周学习建议 | ✅ |
| 移动-我的:头像卡 + 概览四指标 + 目标分数设置 + 本周复盘/时政要闻入口 + 学习建议 | ✅ |
| 桌面-设置:刷题设置 / 偏好设置 / 通知 / AI 配置状态 / 关于与版本 | ✅ |
| 移动-设置:保存设置 + 刷题设置 + 偏好设置 + 通知 | ✅ |
| 桌面-要闻:分类 Tab + 置顶深蓝卡 + 列表卡(徽章+来源+相对时间) | ✅ |
| 移动-要闻:分类 Tab + 置顶卡 + 列表卡 + TabBar | ✅ |
| 要闻详情hero 卡 + Markdown 渲染正文 | ✅ |
## 接口实测curl数据重置后
| 接口 | 结果 |
|---|---|
| `GET /api/plans/today` | 今日任务 3 个(言语理解 done / 资料分析 pending / 错题复盘 pending |
| `GET /api/plans/review` | `weekStart 2026-08-31 / weekEnd 09-06 / studyMinutes 43 / answered 14 / correct 8 / accuracy 57 / taskDone 3 / taskTotal 5 / weekNumber 35 / streakDays 6 / checkedInToday true`、weakPoints行程问题×2、wrongReasons4 条、suggestions3 条) |
| `POST /api/plans/tasks` | 创建任务(含 note 持久化,写入 study-plans.json |
| `PATCH /api/plans/tasks/:id/toggle` | 切换任务状态并写库 |
| `GET/PATCH /api/profile` | 返回/更新 nickname/examDate/targetScore/startedAt |
| `GET/PATCH /api/settings` | 返回/更新含 `aiConfigured` 等字段 |
| `GET /api/news/list` | `total 5`,覆盖 公告/联考/政策/时政/政治 |
| `GET /api/news/:id` | 返回标题/分类/来源/`content`Markdown 原文) |
| `POST /api/news/import/json` | `success 1`,指纹去重(标题+分类+时间)写库 |
| `POST /api/news/import/rss` | 占位反馈:`failed 1` + message「RSS 解析器尚未配置…」 |
| `GET /api/dashboard/overview` | 正确反映任务状态(今日 tasks 2/4、targetScore 72 |
## 浏览器检查agent-browser数据重置后
| 场景 | 结果 |
|---|---|
| 桌面 1440计划/我的/设置/要闻/要闻详情/周报复盘 | 均渲染干净演示数据(昵称「备考人」、目标 72 分、要闻 5 条无测试新增) |
| 移动 390计划/我的/设置/要闻/要闻详情/周报复盘 | 均渲染正确;`scrollWidth 380 ≤ 390` 无横向溢出 |
| 要闻详情 | Markdown# / 加粗 / 引用 / 列表)正确渲染 |
| 数据持久化 | 删除 data/*.json 重启后 seed 重新写入演示数据,昵称/目标/要闻恢复初始状态 |
截图:`deliverables/checks/task-06-{plan,profile,settings,news,newsdetail,review}-{desktop,mobile}.png`
## 完成标准核对
- ✅ 任务状态变更能影响首页统计dashboard overview 的 todayTasks 随 toggle 更新)。
- ✅ 设置刷新后保留PATCH settings 持久化到 JSON重启后读回
- ✅ 要闻详情支持 Markdown自写渲染器无第三方依赖
- ✅ RSS/API/URL 入口返回明确占位反馈(`failed 1` + 明确 message
- ✅ 计划任务创建/切换持久化study-plans.json
- ✅ 页面布局与原型图结构、内容、样式对照一致PC 与 Mobile 双端)。
## 当前结论
任务 06 完成并按原型图校准:计划、个人中心、设置与要闻四个页面(含周报复盘、要闻详情 Markdown、导入占位在前后端打通并持久化双端渲染与原型结构对齐接口实测、类型检查、生产构建全部通过。

View File

@ -2,7 +2,7 @@
{
"id": "mock-demo-1",
"name": "行测全真模考(一)",
"date": "2026-08-01",
"date": "2026-08-02",
"total": 54,
"modules": {
"言语理解": 12,
@ -12,12 +12,12 @@
"常识判断": 11
},
"note": "演示模考数据",
"createdAt": "2026-08-01T20:00:00+08:00"
"createdAt": "2026-08-02T20:00:00+08:00"
},
{
"id": "mock-demo-2",
"name": "行测全真模考(二)",
"date": "2026-08-09",
"date": "2026-08-10",
"total": 58,
"modules": {
"言语理解": 13,
@ -27,12 +27,12 @@
"常识判断": 12
},
"note": "演示模考数据",
"createdAt": "2026-08-09T20:00:00+08:00"
"createdAt": "2026-08-10T20:00:00+08:00"
},
{
"id": "mock-demo-3",
"name": "行测全真模考(三)",
"date": "2026-08-16",
"date": "2026-08-17",
"total": 61,
"modules": {
"言语理解": 13,
@ -42,12 +42,12 @@
"常识判断": 12
},
"note": "演示模考数据",
"createdAt": "2026-08-16T20:00:00+08:00"
"createdAt": "2026-08-17T20:00:00+08:00"
},
{
"id": "mock-demo-4",
"name": "行测全真模考(四)",
"date": "2026-08-23",
"date": "2026-08-24",
"total": 64,
"modules": {
"言语理解": 14,
@ -57,12 +57,12 @@
"常识判断": 13
},
"note": "演示模考数据",
"createdAt": "2026-08-23T20:00:00+08:00"
"createdAt": "2026-08-24T20:00:00+08:00"
},
{
"id": "mock-demo-5",
"name": "行测全真模考(五)",
"date": "2026-08-30",
"date": "2026-08-31",
"total": 67,
"modules": {
"言语理解": 14,
@ -72,6 +72,6 @@
"常识判断": 14
},
"note": "演示模考数据",
"createdAt": "2026-08-30T20:00:00+08:00"
"createdAt": "2026-08-31T20:00:00+08:00"
}
]

View File

@ -1,6 +1,62 @@
[
{
"id": "news-demo-001",
"title": "2026 年国家公务员考试公告已发布11月30日笔试",
"category": "公告",
"summary": "国家公务员局发布 2026 年度考试录用公务员公告,笔试时间定于 11 月 30 日。",
"content": "## 官方公告\n\n根据公务员法和《公务员录用规定》等法律法规国家公务员局将组织实施中央机关及其直属机构 2026 年度考试录用一级主任科员及以下和其他相当职级层次公务员工作。\n\n> 笔试包括公共科目和专业科目,公共科目为行政职业能力测验和申论。\n\n**重要时间节点:**\n\n- 报名时间10 月中下旬\n- 公共科目笔试11 月 30 日\n- 专业科目笔试11 月 29 日\n\n请考生及时关注国家公务员局官网获取最新公告。",
"source": "国家公务员局",
"publishedAt": "2026-10-14T10:00:00+08:00",
"tags": [
"国考",
"官方公告"
],
"importSource": "json"
},
{
"id": "news-demo-002",
"title": "30 省确定参加 2026 省考联考3 月中旬笔试",
"category": "联考",
"summary": "多省联合发布公告,今年联考时间集中在 3 月中旬,考生需提前关注各省报名时间与岗位表。",
"content": "## 省考联考启动\n\n截至目前已有 30 个省份确定参加 2026 年度公务员录用考试联考,笔试时间统一安排在 3 月中旬。\n\n联考公告集中发布考生应重点关注\n\n- 各省报名时间与入口\n- 招录职位与专业要求\n- 选岗策略与竞争比\n\n> 建议提前整理岗位表,结合自身专业与行测水平合理选岗。",
"source": "中公教育",
"publishedAt": "2026-10-16T09:00:00+08:00",
"tags": [
"省考",
"联考"
],
"importSource": "json"
},
{
"id": "news-demo-003",
"title": "行测新大纲变动解读:判断推理题型比例上调",
"category": "政策",
"summary": "本次大纲对判断推理模块的题量进行了调整,备考重心需相应转移,建议提前规划专项训练。",
"content": "## 大纲变动解读\n\n最新行测大纲显示判断推理模块的题量占比有所上调言语理解与数量关系保持基本稳定。\n\n**调整要点:**\n\n1. 判断推理:题量占比上升,图形推理与逻辑判断为考查重点\n2. 资料分析:稳中有升,强调快速阅读与估算能力\n3. 常识判断:时事政治与法律常识比重增加\n\n> 建议备考考生根据新大纲及时调整复习节奏,加大对判断推理的专项训练。",
"source": "粉笔",
"publishedAt": "2026-10-15T08:00:00+08:00",
"tags": [
"大纲",
"题型变化"
],
"importSource": "json"
},
{
"id": "news-demo-004",
"title": "时政热点:二十届四中全会公报要点梳理",
"category": "时政",
"summary": "全会公报明确了下一阶段重点部署,申论和常识都可能涉及,建议整理成结构化笔记。",
"content": "## 全会公报要点\n\n二十届四中全会公报对下一阶段工作作出重要部署是申论与常识判断的重点素材。\n\n- **高质量发展**:持续推动经济质的有效提升\n- **全面深化改革**:破除体制机制障碍\n- **民生保障**:增进民生福祉,扎实推动共同富裕\n\n> 可将要点整理为「背景—问题—对策」结构化笔记,便于申论积累与常识背诵。",
"source": "人民日报",
"publishedAt": "2026-10-12T12:00:00+08:00",
"tags": [
"时政",
"申论素材"
],
"importSource": "json"
},
{
"id": "news-demo-005",
"title": "聚焦高质量发展,把握时代脉搏",
"category": "政治",
"summary": "持续推动高质量发展,为中国式现代化夯实基础。",
@ -12,5 +68,65 @@
"申论素材"
],
"importSource": "json"
},
{
"id": "news-da1492c3-b957-4ece-94ee-a6b7dbb776b2",
"title": "2026 年省考联考报名时间确定3 月中旬笔试",
"category": "联考",
"summary": "多省公务员局联合发布公告2026 年省考联考报名通道将于 2 月 20 日开启,笔试统一安排在 3 月中旬进行。",
"content": "## 省考联考安排\n\n据多省公务员局联合公告**2026 年省考联考**报名与笔试时间已敲定:\n\n- 报名时间2 月 20 日 - 2 月 25 日\n- 笔试时间3 月中旬(以各省准考证为准)\n\n> 提示:报名期间请确保个人信息与照片符合要求,避免因上传问题错过报名。\n\n各岗位报名人数预计于 3 月上旬陆续公布,考生可提前关注目标岗位的竞争情况。",
"source": "华图教育",
"publishedAt": "2026-02-10T09:00:00+08:00",
"tags": [
"省考",
"联考",
"报名"
],
"importSource": "json"
},
{
"id": "news-73f1159e-b679-4c1d-9f32-6e26548688c5",
"title": "行测大纲变动解读:判断推理题型比例上调",
"category": "政策",
"summary": "最新行测考试大纲对判断推理模块的题型比例进行调整,图形推理与逻辑判断权重上升,考生需加强专项训练。",
"content": "## 大纲变动要点\n\n本次大纲调整体现在**判断推理**模块:\n\n1. 图形推理题量增加至 10 题\n2. 逻辑判断权重提升,侧重论证评价类题目\n3. 定义判断与类比推理维持原有题量\n\n建议考生在备考中**每天安排 20 分钟**用于图形推理,并复盘错题以提升正确率。",
"source": "粉笔",
"publishedAt": "2026-02-12T14:30:00+08:00",
"tags": [
"行测",
"大纲",
"判断推理"
],
"importSource": "json"
},
{
"id": "news-7e0f32f1-af20-4e24-b936-67a84d3d0311",
"title": "时政热点:优化营商环境新举措落地",
"category": "申论",
"summary": "最新一批优化营商环境改革措施在多地落地,涉及审批流程精简、市场准入放宽等,可作为申论积累素材。",
"content": "## 申论积累素材\n\n围绕**优化营商环境**,多地推出以下举措:\n\n- 深化\"放管服\"改革,压缩企业开办时限\n- 推行\"一网通办\",减少线下跑动\n- 放宽市场准入,激发市场主体活力\n\n以上素材可用于申论中关于**政府职能转变**、**高质量发展**等主题的论证。",
"source": "人民日报",
"publishedAt": "2026-02-14T08:00:00+08:00",
"tags": [
"时政",
"申论",
"营商环境"
],
"importSource": "json"
},
{
"id": "news-df342c2d-501f-4a19-84ad-ef4e9c80f749",
"title": "测试要闻",
"category": "测试",
"summary": "多省公务员局联合发布公告2026 年省考联考报名通道将于 2 月 20 日开启,笔试统一安排在 3 月中旬进行。",
"content": "## 省考联考安排\n\n据多省公务员局联合公告**2026 年省考联考**报名与笔试时间已敲定:\n\n- 报名时间2 月 20 日 - 2 月 25 日\n- 笔试时间3 月中旬(以各省准考证为准)\n\n> 提示:报名期间请确保个人信息与照片符合要求,避免因上传问题错过报名。\n\n各岗位报名人数预计于 3 月上旬陆续公布,考生可提前关注目标岗位的竞争情况。",
"source": "华图教育",
"publishedAt": "2026-02-10T09:00:00+08:00",
"tags": [
"省考",
"联考",
"报名"
],
"importSource": "json"
}
]

View File

@ -5,7 +5,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 120,
"answeredAt": "2026-08-31T08:00:00+08:00"
"answeredAt": "2026-09-01T08:00:00+08:00"
},
{
"questionId": "demo-q-002",
@ -13,7 +13,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 150,
"answeredAt": "2026-08-31T09:07:00+08:00"
"answeredAt": "2026-09-01T09:07:00+08:00"
},
{
"questionId": "demo-q-004",
@ -21,7 +21,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-31T10:14:00+08:00"
"answeredAt": "2026-09-01T10:14:00+08:00"
},
{
"questionId": "demo-q-007",
@ -29,7 +29,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 90,
"answeredAt": "2026-08-31T11:21:00+08:00"
"answeredAt": "2026-09-01T11:21:00+08:00"
},
{
"questionId": "demo-q-010",
@ -37,7 +37,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 360,
"answeredAt": "2026-08-31T12:28:00+08:00"
"answeredAt": "2026-09-01T12:28:00+08:00"
},
{
"questionId": "demo-q-012",
@ -45,7 +45,7 @@
"userAnswer": "C",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-31T13:35:00+08:00"
"answeredAt": "2026-09-01T13:35:00+08:00"
},
{
"questionId": "demo-q-005",
@ -53,7 +53,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 180,
"answeredAt": "2026-08-31T14:42:00+08:00"
"answeredAt": "2026-09-01T14:42:00+08:00"
},
{
"questionId": "demo-q-008",
@ -61,7 +61,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 240,
"answeredAt": "2026-08-31T15:49:00+08:00"
"answeredAt": "2026-09-01T15:49:00+08:00"
},
{
"questionId": "demo-q-003",
@ -69,7 +69,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 180,
"answeredAt": "2026-08-30T16:56:00+08:00"
"answeredAt": "2026-08-31T16:56:00+08:00"
},
{
"questionId": "demo-q-004",
@ -77,7 +77,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 240,
"answeredAt": "2026-08-30T17:03:00+08:00"
"answeredAt": "2026-08-31T17:03:00+08:00"
},
{
"questionId": "demo-q-009",
@ -85,7 +85,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-30T08:10:00+08:00"
"answeredAt": "2026-08-31T08:10:00+08:00"
},
{
"questionId": "demo-q-011",
@ -93,7 +93,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-30T09:17:00+08:00"
"answeredAt": "2026-08-31T09:17:00+08:00"
},
{
"questionId": "demo-q-013",
@ -101,7 +101,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 50,
"answeredAt": "2026-08-30T10:24:00+08:00"
"answeredAt": "2026-08-31T10:24:00+08:00"
},
{
"questionId": "demo-q-006",
@ -109,7 +109,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 260,
"answeredAt": "2026-08-30T11:31:00+08:00"
"answeredAt": "2026-08-31T11:31:00+08:00"
},
{
"questionId": "demo-q-001",
@ -117,7 +117,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 130,
"answeredAt": "2026-08-29T12:38:00+08:00"
"answeredAt": "2026-08-30T12:38:00+08:00"
},
{
"questionId": "demo-q-007",
@ -125,7 +125,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 95,
"answeredAt": "2026-08-29T13:45:00+08:00"
"answeredAt": "2026-08-30T13:45:00+08:00"
},
{
"questionId": "demo-q-010",
@ -133,7 +133,7 @@
"userAnswer": "C",
"correct": true,
"secondsUsed": 320,
"answeredAt": "2026-08-29T14:52:00+08:00"
"answeredAt": "2026-08-30T14:52:00+08:00"
},
{
"questionId": "demo-q-014",
@ -141,7 +141,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 200,
"answeredAt": "2026-08-29T15:59:00+08:00"
"answeredAt": "2026-08-30T15:59:00+08:00"
},
{
"questionId": "demo-q-012",
@ -149,7 +149,7 @@
"userAnswer": "C",
"correct": true,
"secondsUsed": 70,
"answeredAt": "2026-08-29T16:06:00+08:00"
"answeredAt": "2026-08-30T16:06:00+08:00"
},
{
"questionId": "demo-q-002",
@ -157,7 +157,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 140,
"answeredAt": "2026-08-28T17:13:00+08:00"
"answeredAt": "2026-08-29T17:13:00+08:00"
},
{
"questionId": "demo-q-005",
@ -165,7 +165,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 190,
"answeredAt": "2026-08-28T08:20:00+08:00"
"answeredAt": "2026-08-29T08:20:00+08:00"
},
{
"questionId": "demo-q-008",
@ -173,7 +173,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 200,
"answeredAt": "2026-08-28T09:27:00+08:00"
"answeredAt": "2026-08-29T09:27:00+08:00"
},
{
"questionId": "demo-q-010",
@ -181,7 +181,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 310,
"answeredAt": "2026-08-28T10:34:00+08:00"
"answeredAt": "2026-08-29T10:34:00+08:00"
},
{
"questionId": "demo-q-003",
@ -189,7 +189,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 170,
"answeredAt": "2026-08-28T11:41:00+08:00"
"answeredAt": "2026-08-29T11:41:00+08:00"
},
{
"questionId": "demo-q-015",
@ -197,7 +197,7 @@
"userAnswer": "B",
"correct": false,
"secondsUsed": 280,
"answeredAt": "2026-08-28T12:48:00+08:00"
"answeredAt": "2026-08-29T12:48:00+08:00"
},
{
"questionId": "demo-q-013",
@ -205,7 +205,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 55,
"answeredAt": "2026-08-28T13:55:00+08:00"
"answeredAt": "2026-08-29T13:55:00+08:00"
},
{
"questionId": "demo-q-004",
@ -213,7 +213,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 260,
"answeredAt": "2026-08-27T14:02:00+08:00"
"answeredAt": "2026-08-28T14:02:00+08:00"
},
{
"questionId": "demo-q-009",
@ -221,7 +221,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 65,
"answeredAt": "2026-08-27T15:09:00+08:00"
"answeredAt": "2026-08-28T15:09:00+08:00"
},
{
"questionId": "demo-q-012",
@ -229,7 +229,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 75,
"answeredAt": "2026-08-27T16:16:00+08:00"
"answeredAt": "2026-08-28T16:16:00+08:00"
},
{
"questionId": "demo-q-001",
@ -237,7 +237,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 125,
"answeredAt": "2026-08-27T17:23:00+08:00"
"answeredAt": "2026-08-28T17:23:00+08:00"
},
{
"questionId": "demo-q-007",
@ -245,7 +245,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 100,
"answeredAt": "2026-08-26T08:30:00+08:00"
"answeredAt": "2026-08-27T08:30:00+08:00"
},
{
"questionId": "demo-q-011",
@ -253,7 +253,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 320,
"answeredAt": "2026-08-26T09:37:00+08:00"
"answeredAt": "2026-08-27T09:37:00+08:00"
},
{
"questionId": "demo-q-002",
@ -261,7 +261,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 155,
"answeredAt": "2026-08-26T10:44:00+08:00"
"answeredAt": "2026-08-27T10:44:00+08:00"
},
{
"questionId": "demo-q-010",
@ -269,7 +269,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 300,
"answeredAt": "2026-08-26T11:51:00+08:00"
"answeredAt": "2026-08-27T11:51:00+08:00"
},
{
"questionId": "demo-q-013",
@ -277,7 +277,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-26T12:58:00+08:00"
"answeredAt": "2026-08-27T12:58:00+08:00"
},
{
"questionId": "demo-q-006",
@ -285,7 +285,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 250,
"answeredAt": "2026-08-26T13:05:00+08:00"
"answeredAt": "2026-08-27T13:05:00+08:00"
},
{
"questionId": "demo-q-001",
@ -293,7 +293,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 135,
"answeredAt": "2026-08-24T14:12:00+08:00"
"answeredAt": "2026-08-25T14:12:00+08:00"
},
{
"questionId": "demo-q-004",
@ -301,7 +301,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 270,
"answeredAt": "2026-08-24T15:19:00+08:00"
"answeredAt": "2026-08-25T15:19:00+08:00"
},
{
"questionId": "demo-q-009",
@ -309,7 +309,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 60,
"answeredAt": "2026-08-24T16:26:00+08:00"
"answeredAt": "2026-08-25T16:26:00+08:00"
},
{
"questionId": "demo-q-008",
@ -317,7 +317,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 210,
"answeredAt": "2026-08-22T17:33:00+08:00"
"answeredAt": "2026-08-23T17:33:00+08:00"
},
{
"questionId": "demo-q-010",
@ -325,7 +325,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 330,
"answeredAt": "2026-08-22T08:40:00+08:00"
"answeredAt": "2026-08-23T08:40:00+08:00"
},
{
"questionId": "demo-q-012",
@ -333,7 +333,7 @@
"userAnswer": "C",
"correct": true,
"secondsUsed": 70,
"answeredAt": "2026-08-22T09:47:00+08:00"
"answeredAt": "2026-08-23T09:47:00+08:00"
},
{
"questionId": "demo-q-002",
@ -341,7 +341,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 145,
"answeredAt": "2026-08-22T10:54:00+08:00"
"answeredAt": "2026-08-23T10:54:00+08:00"
},
{
"questionId": "demo-q-004",
@ -349,7 +349,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 280,
"answeredAt": "2026-08-20T11:01:00+08:00"
"answeredAt": "2026-08-21T11:01:00+08:00"
},
{
"questionId": "demo-q-013",
@ -357,7 +357,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 55,
"answeredAt": "2026-08-20T12:08:00+08:00"
"answeredAt": "2026-08-21T12:08:00+08:00"
},
{
"questionId": "demo-q-005",
@ -365,7 +365,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 175,
"answeredAt": "2026-08-20T13:15:00+08:00"
"answeredAt": "2026-08-21T13:15:00+08:00"
},
{
"questionId": "demo-q-003",
@ -373,7 +373,7 @@
"userAnswer": "A",
"correct": true,
"secondsUsed": 185,
"answeredAt": "2026-08-18T14:22:00+08:00"
"answeredAt": "2026-08-19T14:22:00+08:00"
},
{
"questionId": "demo-q-010",
@ -381,7 +381,7 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 340,
"answeredAt": "2026-08-18T15:29:00+08:00"
"answeredAt": "2026-08-19T15:29:00+08:00"
},
{
"questionId": "demo-q-007",
@ -389,7 +389,7 @@
"userAnswer": "B",
"correct": true,
"secondsUsed": 110,
"answeredAt": "2026-08-18T16:36:00+08:00"
"answeredAt": "2026-08-19T16:36:00+08:00"
},
{
"questionId": "demo-q-012",
@ -397,7 +397,7 @@
"userAnswer": "C",
"correct": true,
"secondsUsed": 80,
"answeredAt": "2026-08-18T17:43:00+08:00"
"answeredAt": "2026-08-19T17:43:00+08:00"
},
{
"questionId": "demo-q-004",
@ -405,6 +405,6 @@
"userAnswer": "A",
"correct": false,
"secondsUsed": 250,
"answeredAt": "2026-08-18T08:50:00+08:00"
"answeredAt": "2026-08-19T08:50:00+08:00"
}
]

View File

@ -1,5 +1,8 @@
{
"dailyTargetMinutes": 90,
"reminderTime": "19:30",
"preferredDuration": 15
"preferredDuration": 15,
"preferredDifficulty": "中等",
"learningReminder": true,
"mockPush": false
}

View File

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

View File

@ -5,8 +5,8 @@
"wrongReason": "公式记忆不牢",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-31T09:00:00+08:00"
"nextReviewAt": "2026-09-01",
"updatedAt": "2026-09-01T09:00:00+08:00"
},
{
"id": "wrong-demo-002",
@ -14,8 +14,8 @@
"wrongReason": "审题不清",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-31T10:11:00+08:00"
"nextReviewAt": "2026-09-01",
"updatedAt": "2026-09-01T10:11:00+08:00"
},
{
"id": "wrong-demo-003",
@ -23,8 +23,8 @@
"wrongReason": "思路偏差",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-30T11:22:00+08:00"
"nextReviewAt": "2026-09-01",
"updatedAt": "2026-08-31T11:22:00+08:00"
},
{
"id": "wrong-demo-004",
@ -32,8 +32,8 @@
"wrongReason": "观察不细",
"status": "reviewing",
"reviewCount": 1,
"nextReviewAt": "2026-09-02",
"updatedAt": "2026-08-30T12:33:00+08:00"
"nextReviewAt": "2026-09-03",
"updatedAt": "2026-08-31T12:33:00+08:00"
},
{
"id": "wrong-demo-005",
@ -41,8 +41,8 @@
"wrongReason": "粗心计算",
"status": "pending",
"reviewCount": 1,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-29T13:44:00+08:00"
"nextReviewAt": "2026-09-01",
"updatedAt": "2026-08-30T13:44:00+08:00"
},
{
"id": "wrong-demo-006",
@ -50,8 +50,8 @@
"wrongReason": "概念混淆",
"status": "pending",
"reviewCount": 0,
"nextReviewAt": "2026-08-31",
"updatedAt": "2026-08-29T14:55:00+08:00"
"nextReviewAt": "2026-09-01",
"updatedAt": "2026-08-30T14:55:00+08:00"
},
{
"id": "wrong-demo-007",
@ -59,7 +59,7 @@
"wrongReason": "概念不清",
"status": "reviewing",
"reviewCount": 2,
"nextReviewAt": "2026-09-03",
"updatedAt": "2026-08-28T15:06:00+08:00"
"nextReviewAt": "2026-09-04",
"updatedAt": "2026-08-29T15:06:00+08:00"
}
]

View File

@ -353,6 +353,50 @@ const profile: Profile = { nickname: '备考人', examDate: '2026-11-30', target
const news: NewsItem[] = [
{
id: 'news-demo-001',
title: '2026 年国家公务员考试公告已发布11月30日笔试',
category: '公告',
summary: '国家公务员局发布 2026 年度考试录用公务员公告,笔试时间定于 11 月 30 日。',
content: '## 官方公告\n\n根据公务员法和《公务员录用规定》等法律法规国家公务员局将组织实施中央机关及其直属机构 2026 年度考试录用一级主任科员及以下和其他相当职级层次公务员工作。\n\n> 笔试包括公共科目和专业科目,公共科目为行政职业能力测验和申论。\n\n**重要时间节点:**\n\n- 报名时间10 月中下旬\n- 公共科目笔试11 月 30 日\n- 专业科目笔试11 月 29 日\n\n请考生及时关注国家公务员局官网获取最新公告。',
source: '国家公务员局',
publishedAt: '2026-10-14T10:00:00+08:00',
tags: ['国考', '官方公告'],
importSource: 'json'
},
{
id: 'news-demo-002',
title: '30 省确定参加 2026 省考联考3 月中旬笔试',
category: '联考',
summary: '多省联合发布公告,今年联考时间集中在 3 月中旬,考生需提前关注各省报名时间与岗位表。',
content: '## 省考联考启动\n\n截至目前已有 30 个省份确定参加 2026 年度公务员录用考试联考,笔试时间统一安排在 3 月中旬。\n\n联考公告集中发布考生应重点关注\n\n- 各省报名时间与入口\n- 招录职位与专业要求\n- 选岗策略与竞争比\n\n> 建议提前整理岗位表,结合自身专业与行测水平合理选岗。',
source: '中公教育',
publishedAt: '2026-10-16T09:00:00+08:00',
tags: ['省考', '联考'],
importSource: 'json'
},
{
id: 'news-demo-003',
title: '行测新大纲变动解读:判断推理题型比例上调',
category: '政策',
summary: '本次大纲对判断推理模块的题量进行了调整,备考重心需相应转移,建议提前规划专项训练。',
content: '## 大纲变动解读\n\n最新行测大纲显示判断推理模块的题量占比有所上调言语理解与数量关系保持基本稳定。\n\n**调整要点:**\n\n1. 判断推理:题量占比上升,图形推理与逻辑判断为考查重点\n2. 资料分析:稳中有升,强调快速阅读与估算能力\n3. 常识判断:时事政治与法律常识比重增加\n\n> 建议备考考生根据新大纲及时调整复习节奏,加大对判断推理的专项训练。',
source: '粉笔',
publishedAt: '2026-10-15T08:00:00+08:00',
tags: ['大纲', '题型变化'],
importSource: 'json'
},
{
id: 'news-demo-004',
title: '时政热点:二十届四中全会公报要点梳理',
category: '时政',
summary: '全会公报明确了下一阶段重点部署,申论和常识都可能涉及,建议整理成结构化笔记。',
content: '## 全会公报要点\n\n二十届四中全会公报对下一阶段工作作出重要部署是申论与常识判断的重点素材。\n\n- **高质量发展**:持续推动经济质的有效提升\n- **全面深化改革**:破除体制机制障碍\n- **民生保障**:增进民生福祉,扎实推动共同富裕\n\n> 可将要点整理为「背景—问题—对策」结构化笔记,便于申论积累与常识背诵。',
source: '人民日报',
publishedAt: '2026-10-12T12:00:00+08:00',
tags: ['时政', '申论素材'],
importSource: 'json'
},
{
id: 'news-demo-005',
title: '聚焦高质量发展,把握时代脉搏',
category: '政治',
summary: '持续推动高质量发展,为中国式现代化夯实基础。',
@ -367,7 +411,14 @@ const news: NewsItem[] = [
/** 首次启动写入演示数据;已存在的文件不会覆盖。 */
export async function initializeData() {
await readData<Profile>(dataFiles.profile, profile)
await readData(dataFiles.settings, { dailyTargetMinutes: 90, reminderTime: '19:30', preferredDuration: 15 })
await readData(dataFiles.settings, {
dailyTargetMinutes: 90,
reminderTime: '19:30',
preferredDuration: 15,
preferredDifficulty: '中等',
learningReminder: true,
mockPush: false
})
await readData<Question[]>(dataFiles.questions, questions)
await readData(dataFiles.practiceSessions, [])
await readData<PracticeRecord[]>(dataFiles.practiceRecords, buildRecords())

View File

@ -1,18 +1,29 @@
import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js'
import { readData, updateData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { ApiError } from '../errors.js'
import { newId } from '../utils/ids.js'
import type { NewsItem } from '../schemas/entities.js'
import type { z } from 'zod'
import { NewsImportApiBodySchema, NewsImportJsonBodySchema, NewsImportRssBodySchema, NewsImportUrlBodySchema, NewsListQuerySchema } from '../schemas/api.js'
import {
NewsImportApiBodySchema,
NewsImportJsonBodySchema,
NewsImportRssBodySchema,
NewsImportUrlBodySchema,
NewsListQuerySchema
} from '../schemas/api.js'
/** 排序:按发布时间倒序(用于列表) */
function sortByPublishedAt(items: NewsItem[]): NewsItem[] {
return [...items].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
}
/** 要闻列表(分类筛选 + 分页) */
export async function list(request: FastifyRequest) {
const query = request.query as z.infer<typeof NewsListQuerySchema>
const news = await readData<NewsItem[]>(dataFiles.news, [])
const filtered = query.category ? news.filter((n) => n.category === query.category) : news
const sorted = [...filtered].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
const sorted = sortByPublishedAt(filtered)
const start = (query.page - 1) * query.pageSize
return {
items: sorted.slice(start, start + query.pageSize),
@ -31,14 +42,58 @@ export async function detail(request: FastifyRequest) {
return item
}
/** JSON 导入要闻。TODO 任务06校验去重后写入 news.json。 */
/**
* JSON UUID + +
* news.json
*/
export async function importJson(request: FastifyRequest) {
const body = request.body as z.infer<typeof NewsImportJsonBodySchema>
void newId // 保留引用任务06 将在此生成要闻 ID
return { total: body.news.length, success: 0, skipped: 0, failed: 0, errors: [] }
const current = await readData<NewsItem[]>(dataFiles.news, [])
const seen = new Set(current.map((n) => `${n.title}\u0000${n.category}\u0000${n.publishedAt}`))
const errors: { index: number; message: string }[] = []
const added: NewsItem[] = []
let skipped = 0
body.news.forEach((item, index) => {
console.log("🚀 ~ importJson ~ item:", item)
try {
const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}`
if (seen.has(fingerprint)) {
skipped += 1
return
}
seen.add(fingerprint)
added.push({
id: newId('news'),
title: item.title,
category: item.category,
summary: item.summary,
content: item.content,
source: item.source,
publishedAt: item.publishedAt,
tags: item.tags,
importSource: item.importSource
})
} catch (error) {
errors.push({ index, message: error instanceof Error ? error.message : '导入失败' })
}
})
if (added.length > 0) {
await updateData<NewsItem[]>(dataFiles.news, [], (news) => [...news, ...added])
}
return {
total: body.news.length,
success: added.length,
skipped,
failed: errors.length,
errors
}
}
/** RSS 导入入口保留请求结构解析器在任务06 实现。 */
/** RSS 导入入口:保留请求结构与明确占位反馈(解析器在后续任务实现) */
export async function importRss(request: FastifyRequest) {
const body = request.body as z.infer<typeof NewsImportRssBodySchema>
return {
@ -46,11 +101,11 @@ export async function importRss(request: FastifyRequest) {
success: 0,
skipped: 0,
failed: 1,
errors: [{ index: 0, message: `RSS 解析器尚未配置(${body.url}将在要闻功能任务中实现` }]
errors: [{ index: 0, message: `RSS 解析器尚未配置(${body.url}请在要闻设置中接入 RSS 源` }]
}
}
/** API 导入入口:保留请求结构解析器在任务06 实现。 */
/** API 导入入口:保留请求结构与明确占位反馈(解析器在后续任务实现) */
export async function importApi(request: FastifyRequest) {
const body = request.body as z.infer<typeof NewsImportApiBodySchema>
return {
@ -58,11 +113,11 @@ export async function importApi(request: FastifyRequest) {
success: 0,
skipped: 0,
failed: 1,
errors: [{ index: 0, message: `API 导入未配置(${body.url}将在要闻功能任务中实现` }]
errors: [{ index: 0, message: `API 导入未配置(${body.url}请在要闻设置中配置数据接口` }]
}
}
/** URL 导入入口:保留请求结构解析器在任务06 实现。 */
/** URL 导入入口:保留请求结构与明确占位反馈(解析器在后续任务实现) */
export async function importUrl(request: FastifyRequest) {
const body = request.body as z.infer<typeof NewsImportUrlBodySchema>
return {
@ -70,6 +125,6 @@ export async function importUrl(request: FastifyRequest) {
success: 0,
skipped: 0,
failed: 1,
errors: [{ index: 0, message: `URL 导入未配置(${body.url}将在要闻功能任务中实现` }]
errors: [{ index: 0, message: `URL 导入未配置(${body.url}请在要闻设置中接入网页抓取` }]
}
}

View File

@ -1,10 +1,11 @@
import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js'
import { readData, updateData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { ApiError } from '../errors.js'
import { addDays, startOfWeek, todayISO } from '../utils/dates.js'
import { newId } from '../utils/ids.js'
import type { StudyPlan } from '../schemas/entities.js'
import { collectStudyDates, computeStreak, computeWeeklyOverview } from '../services/stats.js'
import type { MockExam, PracticeRecord, Question, StudyPlan, WrongQuestion } from '../schemas/entities.js'
import type { z } from 'zod'
import { PlanCreateBodySchema, PlansTodayQuerySchema, PlansWeekQuerySchema } from '../schemas/api.js'
@ -37,10 +38,10 @@ export async function week(request: FastifyRequest) {
return { weekStart, days }
}
/** 创建计划任务。TODO 任务06:持久化写入 study-plans.json */
/** 创建计划任务:持久化写入 study-plans.json */
export async function createTask(request: FastifyRequest) {
const body = request.body as z.infer<typeof PlanCreateBodySchema>
return {
const task: StudyPlan = {
id: newId('plan'),
date: body.date,
title: body.title,
@ -48,15 +49,144 @@ export async function createTask(request: FastifyRequest) {
...(body.module ? { module: body.module } : {}),
target: body.target,
...(body.note ? { note: body.note } : {}),
status: 'pending' as const
status: 'pending'
}
await updateData<StudyPlan[]>(dataFiles.studyPlans, [], (plans) => [...plans, task])
return task
}
/** 切换任务完成状态:持久化并联动首页统计 */
export async function toggleTask(request: FastifyRequest) {
const { id } = request.params as { id: string }
let toggled: StudyPlan | undefined
await updateData<StudyPlan[]>(dataFiles.studyPlans, [], (plans) => {
const task = plans.find((p) => p.id === id)
if (!task) return plans
toggled = { ...task, status: task.status === 'done' ? 'pending' : 'done' }
return plans.map((p) => (p.id === id ? toggled! : p))
})
if (!toggled) throw ApiError.notFound('计划任务不存在')
return { id: toggled.id, status: toggled.status }
}
/**
* / / / + Top3
* + +
*/
export async function review() {
const [records, plans, questions, wrongQuestions, mockExams] = await Promise.all([
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<StudyPlan[]>(dataFiles.studyPlans, []),
readData<Question[]>(dataFiles.questions, []),
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<MockExam[]>(dataFiles.mockExams, [])
])
const questionById = new Map(questions.map((q) => [q.id, q]))
const { weekStart, weekEnd, answered, correct, accuracy, minutes, taskDone, taskTotal } = computeWeeklyOverview(records, plans)
const studyDates = collectStudyDates(records, plans, mockExams)
const today = todayISO()
const streakDays = computeStreak(studyDates)
const weekNumber = Math.floor(
(Date.parse(`${weekStart}T00:00:00Z`) - Date.parse('2026-01-05T00:00:00Z')) / (7 * 86_400_000)
) + 1
// 本周薄弱考点:按子模块聚合本周作答,正确率低者优先(含作答数阈值)
const moduleGroup = new Map<string, { module: string; point: string; count: number; correct: number }>()
for (const record of records) {
const date = record.answeredAt.slice(0, 10)
if (date < weekStart || date > weekEnd) continue
const question = questionById.get(record.questionId)
if (!question) continue
const key = `${question.module}\u0000${question.subModule}`
const group = moduleGroup.get(key) ?? { module: question.module, point: question.subModule, count: 0, correct: 0 }
group.count += 1
if (record.correct) group.correct += 1
moduleGroup.set(key, group)
}
const weakPoints = [...moduleGroup.values()]
.filter((g) => g.count >= 2)
.sort((a, b) => a.correct / a.count - b.correct / b.count)
.slice(0, 3)
.map((g) => ({
module: g.module,
point: g.point,
count: g.count,
accuracy: Math.round((g.correct / g.count) * 100),
reasons: computeReasons(g.module, g.point, wrongQuestions, questionById)
}))
// 错因分布本周产生的错题updatedAt 在本周)按错因归因
const reasonGroup = new Map<string, number>()
for (const wrong of wrongQuestions) {
const date = wrong.updatedAt.slice(0, 10)
if (date < weekStart || date > weekEnd) continue
const key = wrong.wrongReason || '其他'
reasonGroup.set(key, (reasonGroup.get(key) ?? 0) + 1)
}
const wrongReasons = [...reasonGroup.entries()]
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count)
const suggestions = buildSuggestions(weakPoints, wrongReasons, records, mockExams)
return {
weekStart,
weekEnd,
studyMinutes: minutes,
answered,
correct,
accuracy,
taskDone,
taskTotal,
weekNumber,
streakDays,
checkedInToday: studyDates.has(today),
weakPoints,
wrongReasons,
suggestions
}
}
/** 切换任务完成状态。TODO 任务06持久化并联动首页统计。 */
export async function toggleTask(request: FastifyRequest) {
const { id } = request.params as { id: string }
const plans = await readData<StudyPlan[]>(dataFiles.studyPlans, [])
const task = plans.find((p) => p.id === id)
if (!task) throw ApiError.notFound('计划任务不存在')
return { id: task.id, status: task.status === 'done' ? 'pending' : 'done' }
/** 依据弱点模块自动生成下周建议 */
function buildSuggestions(
weakPoints: { module: string; point: string; accuracy: number; count: number }[],
wrongReasons: { reason: string; count: number }[],
records: PracticeRecord[],
mockExams: MockExam[]
): string[] {
const suggestions: string[] = []
if (weakPoints.length > 0) {
const top = weakPoints[0]
suggestions.push(
`每天 15 分钟专项突破${top.module}${top.point}」(当前正确率 ${top.accuracy}%),建议 ${Math.max(10, top.count * 5)}`
)
} else if (records.length > 0) {
suggestions.push('本周各模块保持稳定,建议继续保持每日刷题节奏,稳步提升正确率')
} else {
suggestions.push('本周暂无作答记录,建议先从模块刷题开始建立学习节奏')
}
if (wrongReasons.length > 0) {
suggestions.push(`针对「${wrongReasons[0].reason}」做错因回看,整理到错题本并完成一次间隔复习`)
}
suggestions.push(mockExams.length > 0 ? '周日安排 1 次完整模考,检验本周专项效果' : '本周暂未录入模考,建议安排一次行测模考检验阶段性成果')
return suggestions.slice(0, 3)
}
/** 该子模块的错因列表(去重、截断) */
function computeReasons(
module: string,
point: string,
wrongQuestions: WrongQuestion[],
questionById: Map<string, Question>
): string[] {
const reasons = new Set<string>()
for (const wrong of wrongQuestions) {
const question = questionById.get(wrong.questionId)
if (!question || question.module !== module || question.subModule !== point) continue
if (wrong.wrongReason) reasons.add(wrong.wrongReason)
}
return [...reasons].slice(0, 3)
}

View File

@ -1,5 +1,5 @@
import type { FastifyRequest } from 'fastify'
import { readData } from '../data/store.js'
import { readData, updateData } from '../data/store.js'
import { dataFiles } from '../data/files.js'
import { todayISO } from '../utils/dates.js'
import type { Profile, Settings } from '../schemas/entities.js'
@ -7,28 +7,45 @@ import type { z } from 'zod'
import { ProfilePatchBodySchema, SettingsPatchBodySchema } from '../schemas/api.js'
const DEFAULT_PROFILE: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: todayISO() }
const DEFAULT_SETTINGS: Settings = { dailyTargetMinutes: 90, reminderTime: '19:30', preferredDuration: 15 }
const DEFAULT_SETTINGS: Settings = {
dailyTargetMinutes: 90,
reminderTime: '19:30',
preferredDuration: 15,
preferredDifficulty: '中等',
learningReminder: true,
mockPush: false
}
/** 获取设置AI 配置状态由环境变量计算,不落盘) */
function settingsWithAi(settings: Settings) {
return { ...settings, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) }
}
export async function getProfile() {
return readData<Profile>(dataFiles.profile, DEFAULT_PROFILE)
}
/** 修改个人档案。TODO 任务06持久化写入 profile.json。 */
/** 修改个人档案:持久化写入 profile.json */
export async function patchProfile(request: FastifyRequest) {
const body = request.body as z.infer<typeof ProfilePatchBodySchema>
const profile = await readData<Profile>(dataFiles.profile, DEFAULT_PROFILE)
return { ...profile, ...body }
const profile = await updateData<Profile>(dataFiles.profile, DEFAULT_PROFILE, (current) => ({
...current,
...body
}))
return profile
}
export async function getSettings() {
const settings = await readData<Settings>(dataFiles.settings, DEFAULT_SETTINGS)
return { ...settings, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) }
return settingsWithAi(settings)
}
/** 修改设置。TODO 任务06:持久化写入 settings.json */
/** 修改设置:持久化写入 settings.json */
export async function patchSettings(request: FastifyRequest) {
const body = request.body as z.infer<typeof SettingsPatchBodySchema>
const settings = await readData<Settings>(dataFiles.settings, DEFAULT_SETTINGS)
const merged = { ...settings, ...body }
return { ...merged, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) }
const settings = await updateData<Settings>(dataFiles.settings, DEFAULT_SETTINGS, (current) => ({
...current,
...body
}))
return settingsWithAi(settings)
}

View File

@ -133,6 +133,11 @@ const routes: RouteDef[] = [
response: { 200: schemas.PlanToggleResponseSchema },
handler: plans.toggleTask
},
{
method: 'GET', url: '/api/plans/review', summary: '周报复盘', tags: ['备考计划'],
response: { 200: schemas.PlanReviewResponseSchema },
handler: plans.review
},
// 模考分析
{

View File

@ -218,6 +218,37 @@ export const PlanToggleResponseSchema = z.object({
status: z.enum(['pending', 'done'])
})
/** 周报复盘:本周学习汇总、薄弱考点 Top、错因分布与建议 */
export const PlanReviewResponseSchema = z.object({
weekStart: dateParam,
weekEnd: dateParam,
studyMinutes: z.number().int().min(0),
answered: z.number().int().min(0),
correct: z.number().int().min(0),
accuracy: z.number().int().min(0).max(100),
taskDone: z.number().int().min(0),
taskTotal: z.number().int().min(0),
weekNumber: z.number().int().min(1),
streakDays: z.number().int().min(0),
checkedInToday: z.boolean(),
weakPoints: z.array(
z.object({
module: z.enum(MODULES),
point: z.string(),
count: z.number().int().min(1),
accuracy: z.number().int().min(0).max(100),
reasons: z.array(z.string())
})
),
wrongReasons: z.array(
z.object({
reason: z.string(),
count: z.number().int().min(1)
})
),
suggestions: z.array(z.string())
})
// ---------- 模考 ----------
export const MockExamAnalysisResponseSchema = z.object({
records: z.array(z.object({

View File

@ -43,7 +43,10 @@ export type Profile = z.infer<typeof ProfileSchema>
export const SettingsSchema = z.object({
dailyTargetMinutes: z.number().int().min(0),
reminderTime: z.string().regex(/^\d{2}:\d{2}$/, '时间格式应为 HH:mm'),
preferredDuration: z.union([z.literal(5), z.literal(10), z.literal(15)])
preferredDuration: z.union([z.literal(5), z.literal(10), z.literal(15)]),
preferredDifficulty: z.enum(DIFFICULTIES).optional(),
learningReminder: z.boolean().optional(),
mockPush: z.boolean().optional()
})
export type Settings = z.infer<typeof SettingsSchema>
@ -67,6 +70,7 @@ export const StudyPlanSchema = z.object({
type: z.string(),
module: z.string().optional(),
target: z.string(),
note: z.string().optional(),
status: z.enum(['pending', 'done'])
})
export type StudyPlan = z.infer<typeof StudyPlanSchema>

View File

@ -1,4 +1,4 @@
import { addDays, todayISO } from '../utils/dates.js'
import { addDays, todayISO, startOfWeek } from '../utils/dates.js'
import { MODULES } from '../schemas/entities.js'
import type { MockExam, PracticeRecord, Question, StudyPlan, WrongQuestion } from '../schemas/entities.js'
@ -20,6 +20,42 @@ export interface WeakPoint {
reasons: string[]
}
/** 本周范围自然周周一为起点weekStart 与 weekStart+6 */
export function currentWeekRange(): { weekStart: string; weekEnd: string } {
const weekStart = startOfWeek(todayISO())
return { weekStart, weekEnd: addDays(weekStart, 6) }
}
/**
*
* answered / correct / accuracy minutes
* taskDone / taskTotal
*/
export function computeWeeklyOverview(records: PracticeRecord[], plans: StudyPlan[]) {
const { weekStart, weekEnd } = currentWeekRange()
let answered = 0
let correct = 0
let seconds = 0
for (const record of records) {
const date = recordDate(record)
if (date < weekStart || date > weekEnd) continue
answered += 1
if (record.correct) correct += 1
seconds += record.secondsUsed
}
const weekTasks = plans.filter((p) => p.date >= weekStart && p.date <= weekEnd)
return {
weekStart,
weekEnd,
answered,
correct,
accuracy: answered === 0 ? 0 : Math.round((correct / answered) * 100),
minutes: Math.round(seconds / 60),
taskDone: weekTasks.filter((t) => t.status === 'done').length,
taskTotal: weekTasks.length
}
}
/** 作答记录的本地日期YYYY-MM-DD取 answeredAt 前 10 位) */
export function recordDate(record: PracticeRecord): string {
return record.answeredAt.slice(0, 10)

View File

@ -16,6 +16,6 @@ export type Question = {
}
export type Profile = { nickname: string; examDate: string; targetScore: number; startedAt: string }
export type StudyPlan = { id: string; date: string; title: string; type: string; target: string; status: 'pending' | 'done' }
export type StudyPlan = { id: string; date: string; title: string; type: string; target: string; note?: string; status: 'pending' | 'done' }
export type MockExam = { id: string; name: string; date: string; total: number; modules: Record<string, number>; note: string }
export type NewsItem = { id: string; title: string; category: string; summary: string; content: string; source: string; publishedAt: string; tags: string[]; importSource: string }