From 2d428f1be0bb687d18771a032e481f6663cd17cd Mon Sep 17 00:00:00 2001 From: liyy <18435186204@163.com> Date: Tue, 1 Sep 2026 11:36:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=A6=81=E9=97=BB/?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE/=E4=B8=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/index.ts | 9 +- client/src/components/base/AppButton.vue | 8 +- client/src/components/base/AppModal.vue | 93 +++ client/src/components/base/AppPageHeader.vue | 57 ++ client/src/components/base/icons.ts | 4 + .../src/components/charts/AppTrendChart.vue | 199 ----- client/src/router/index.ts | 4 + client/src/styles/tokens.css | 48 ++ client/src/utils/format.ts | 40 + client/src/utils/markdown.ts | 115 +++ client/src/views/dashboard/DashboardView.vue | 30 +- client/src/views/news/NewsDetailView.vue | 184 +++++ client/src/views/news/NewsView.vue | 404 +++++++++- client/src/views/plan/PlanReviewView.vue | 305 ++++++++ client/src/views/plan/PlanView.vue | 692 +++++++++++++++++- client/src/views/profile/ProfileView.vue | 544 +++++++++++++- client/src/views/settings/SettingsView.vue | 317 +++++++- docs/checks/task-06.md | 85 +++ server/data/mock-exams.json | 20 +- server/data/news.json | 116 +++ server/data/practice-records.json | 102 +-- server/data/settings.json | 5 +- server/data/study-plans.json | 10 +- server/data/wrong-questions.json | 28 +- server/src/data/seed.ts | 53 +- server/src/handlers/news.ts | 79 +- server/src/handlers/plans.ts | 154 +++- server/src/handlers/profile.ts | 37 +- server/src/routes.ts | 5 + server/src/schemas/api.ts | 31 + server/src/schemas/entities.ts | 6 +- server/src/services/stats.ts | 38 +- server/src/types/index.ts | 2 +- 33 files changed, 3472 insertions(+), 352 deletions(-) create mode 100644 client/src/components/base/AppModal.vue create mode 100644 client/src/components/base/AppPageHeader.vue delete mode 100644 client/src/components/charts/AppTrendChart.vue create mode 100644 client/src/utils/markdown.ts create mode 100644 client/src/views/news/NewsDetailView.vue create mode 100644 client/src/views/plan/PlanReviewView.vue create mode 100644 docs/checks/task-06.md diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 1e1401c..bf49d01 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -34,6 +34,12 @@ export type DashboardWeakPoints = SuccessBody export type Profile = SuccessBody export type Settings = SuccessBody +export type PlansToday = SuccessBody +export type PlansWeek = SuccessBody +export type PlanReview = SuccessBody +export type StudyPlanItem = SuccessBody['tasks'][number] +export type NewsItem = SuccessBody['items'][number] +export type ImportResult = SuccessBody // ---- 数据中枢 ---- 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')) } // ---- 模考 ---- diff --git a/client/src/components/base/AppButton.vue b/client/src/components/base/AppButton.vue index 69ae6c3..354b836 100644 --- a/client/src/components/base/AppButton.vue +++ b/client/src/components/base/AppButton.vue @@ -23,7 +23,7 @@ withDefaults( :class="[`btn--${variant}`, `btn--${size}`, { 'btn--block': block }]" > - + @@ -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); diff --git a/client/src/components/base/AppModal.vue b/client/src/components/base/AppModal.vue new file mode 100644 index 0000000..af88407 --- /dev/null +++ b/client/src/components/base/AppModal.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/client/src/components/base/AppPageHeader.vue b/client/src/components/base/AppPageHeader.vue new file mode 100644 index 0000000..015d5ca --- /dev/null +++ b/client/src/components/base/AppPageHeader.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/client/src/components/base/icons.ts b/client/src/components/base/icons.ts index d1be3c8..25d74f9 100644 --- a/client/src/components/base/icons.ts +++ b/client/src/components/base/icons.ts @@ -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 = { ], 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' } } diff --git a/client/src/components/charts/AppTrendChart.vue b/client/src/components/charts/AppTrendChart.vue deleted file mode 100644 index bbd307b..0000000 --- a/client/src/components/charts/AppTrendChart.vue +++ /dev/null @@ -1,199 +0,0 @@ - - - - - diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 4d1bb0c..429a5f8 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -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: '设置' } }, diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css index 9633465..55c0c27 100644 --- a/client/src/styles/tokens.css +++ b/client/src/styles/tokens.css @@ -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); diff --git a/client/src/utils/format.ts b/client/src/utils/format.ts index 37964f9..cea9132 100644 --- a/client/src/utils/format.ts +++ b/client/src/utils/format.ts @@ -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) +} diff --git a/client/src/utils/markdown.ts b/client/src/utils/markdown.ts new file mode 100644 index 0000000..4876a55 --- /dev/null +++ b/client/src/utils/markdown.ts @@ -0,0 +1,115 @@ +/** + * 轻量 Markdown 渲染器(自包含,无第三方依赖)。 + * 覆盖要闻详情所需的基础语法:标题 / 段落 / 加粗 / 斜体 / 行内代码 / + * 无序与有序列表 / 引用块 / 链接 / 分割线。返回安全的 HTML 字符串。 + * + * 说明:为保持零依赖与离线可用,采用「先转义 HTML→再渲染标记」的顺序, + * 转义后再生成的 ////g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** 渲染行内元素:粗体、斜体、行内代码、链接 */ +function renderInline(text: string): string { + return text + // 行内代码(先于其他标记处理,避免代码内标记被误解析) + .replace(/`([^`]+)`/g, (_m, code: string) => `${code}`) + // 加粗 + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + // 斜体 + .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2') + // 链接 [text](url) + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label: string, url: string) => { + const safeUrl = /^https?:\/\//i.test(url) ? url : '#' + return `${label}` + }) +} + +/** + * 将 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(`

${renderInline(paragraph.join(' '))}

`) + paragraph = [] + } + } + const flushList = () => { + if (list) { + const tag = list.ordered ? 'ol' : 'ul' + blocks.push(`<${tag}>${list.items.map((i) => `
  • ${renderInline(i)}
  • `).join('')}`) + 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(`${renderInline(heading[2])}`) + continue + } + // 水平分割线 + if (/^(-{3,}|\*{3,})$/.test(trimmed)) { + flushParagraph() + flushList() + blocks.push('
    ') + continue + } + // 引用 + if (trimmed.startsWith('>')) { + flushParagraph() + flushList() + const quote = trimmed.replace(/^>\s?/, '') + blocks.push(`

    ${renderInline(quote)}

    `) + 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') +} diff --git a/client/src/views/dashboard/DashboardView.vue b/client/src/views/dashboard/DashboardView.vue index 950116b..c332172 100644 --- a/client/src/views/dashboard/DashboardView.vue +++ b/client/src/views/dashboard/DashboardView.vue @@ -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(() => {
    -
    -
    -

    数据中枢

    -

    备考第 {{ d.studyDays }} 天:距离国考还有 {{ d.daysToExam }} 天:已连续打卡 {{ d.streakDays }} 天

    -
    + 今日已打卡 -
    +
    @@ -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; diff --git a/client/src/views/news/NewsDetailView.vue b/client/src/views/news/NewsDetailView.vue new file mode 100644 index 0000000..a69b265 --- /dev/null +++ b/client/src/views/news/NewsDetailView.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/client/src/views/news/NewsView.vue b/client/src/views/news/NewsView.vue index 553e1ca..4026f6c 100644 --- a/client/src/views/news/NewsView.vue +++ b/client/src/views/news/NewsView.vue @@ -1,7 +1,407 @@