feat: 增加要闻/设置/主页
This commit is contained in:
parent
9f5c9a6366
commit
2d428f1be0
@ -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'))
|
||||
}
|
||||
|
||||
// ---- 模考 ----
|
||||
|
||||
@ -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);
|
||||
|
||||
93
client/src/components/base/AppModal.vue
Normal file
93
client/src/components/base/AppModal.vue
Normal 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>
|
||||
57
client/src/components/base/AppPageHeader.vue
Normal file
57
client/src/components/base/AppPageHeader.vue
Normal 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>
|
||||
@ -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' } }
|
||||
|
||||
@ -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>
|
||||
@ -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: '设置' } },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
115
client/src/utils/markdown.ts
Normal file
115
client/src/utils/markdown.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 轻量 Markdown 渲染器(自包含,无第三方依赖)。
|
||||
* 覆盖要闻详情所需的基础语法:标题 / 段落 / 加粗 / 斜体 / 行内代码 /
|
||||
* 无序与有序列表 / 引用块 / 链接 / 分割线。返回安全的 HTML 字符串。
|
||||
*
|
||||
* 说明:为保持零依赖与离线可用,采用「先转义 HTML→再渲染标记」的顺序,
|
||||
* 转义后再生成的 <em>/<strong>/<code>/<a 等标签均为本地白名单结构。
|
||||
*/
|
||||
|
||||
/** 转义 HTML 特殊字符,防止原始内容注入 */
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 渲染行内元素:粗体、斜体、行内代码、链接 */
|
||||
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')
|
||||
}
|
||||
@ -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;
|
||||
|
||||
184
client/src/views/news/NewsDetailView.vue
Normal file
184
client/src/views/news/NewsDetailView.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
305
client/src/views/plan/PlanReviewView.vue
Normal file
305
client/src/views/plan/PlanReviewView.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
85
docs/checks/task-06.md
Normal 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)、wrongReasons(4 条)、suggestions(3 条) |
|
||||
| `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、导入占位)在前后端打通并持久化,双端渲染与原型结构对齐,接口实测、类型检查、生产构建全部通过。
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"dailyTargetMinutes": 90,
|
||||
"reminderTime": "19:30",
|
||||
"preferredDuration": 15
|
||||
"preferredDuration": 15,
|
||||
"preferredDifficulty": "中等",
|
||||
"learningReminder": true,
|
||||
"mockPush": false
|
||||
}
|
||||
|
||||
@ -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 分钟",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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}),请在要闻设置中接入网页抓取` }]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
// 模考分析
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user