diff --git a/.gitignore b/.gitignore index 9f1082e..8bb7693 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,17 @@ build/ *.db *.sqlite *.sqlite3 +*.log +.superpowers/ tmp/ temp/ .cache/ # Local git worktrees used for parallel AI development. .worktrees/ + +# WeChat Mini Program — API key config (do not commit) +wx-miniprogram/config.js + +# WeChat Mini Program — generated data bundle (rebuilt from content/) +wx-miniprogram/data/all-chapters.js diff --git a/app/fonts.css b/app/fonts.css new file mode 100644 index 0000000..3afa196 --- /dev/null +++ b/app/fonts.css @@ -0,0 +1,218 @@ +/* 自托管字体 —— 由 scripts/fetch-fonts.mjs 生成,请勿手改。 + 中文走本地宋体/思源回退(见 globals.css 字体栈)。 */ + +@font-face { + font-family: 'Spectral'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/fonts/spectral-400-italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/fonts/spectral-400-italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Spectral'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('/fonts/spectral-500-italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('/fonts/spectral-500-italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/spectral-400-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/spectral-400-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/spectral-500-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/spectral-500-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/spectral-600-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/spectral-600-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/spectral-700-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Spectral'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/spectral-700-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/fonts/source-serif-4-400-italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/fonts/source-serif-4-400-italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/source-serif-4-400-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/source-serif-4-400-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/source-serif-4-500-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/source-serif-4-500-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/source-serif-4-600-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/source-serif-4-600-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-400-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-400-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-mono-500-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-mono-500-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/app/globals.css b/app/globals.css index 678944d..d37b7d2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -462,7 +462,9 @@ a { /* 教学图示卡:像贴在书页上的插图 */ .diagram-grid { display: grid; - gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px 18px; + counter-reset: fig; } .diagram-card { @@ -487,6 +489,14 @@ a { color: var(--ink); } +/* 教科书式图号:图 1 / 图 2 … */ +.diagram-card figcaption strong::before { + counter-increment: fig; + content: "图 " counter(fig) " "; + color: var(--gold); + font-variant-numeric: lining-nums tabular-nums; +} + .diagram-card svg { width: 100%; height: auto; @@ -502,6 +512,50 @@ a { font-style: italic; } +.diagram-card--concept { + background: var(--paper-page); + border-left: 3px solid var(--gold); + border-radius: 0 var(--radius) var(--radius) 0; +} + +.diagram-card--concept .diagram-prompt { + margin-top: 14px; +} + +.diagram-svg { + max-width: 560px; +} + +.diagram-table-wrap { + margin-top: 12px; + overflow-x: auto; +} + +.diagram-table { + width: 100%; + border-collapse: collapse; + font-family: var(--serif-body); + font-size: 14px; + color: var(--ink); +} + +.diagram-table th, +.diagram-table td { + padding: 7px 12px; + border: 1px solid var(--rule); + text-align: left; +} + +.diagram-table thead th { + background: var(--accent-wash); + color: var(--accent-deep); + font-weight: 600; +} + +.diagram-table tbody tr:nth-child(even) td { + background: color-mix(in srgb, var(--paper-page) 70%, transparent); +} + /* ---------------------------------------------------------------- 练习区 ----------------------------------------------------------------- */ @@ -558,6 +612,19 @@ button:disabled { cursor: progress; } +button.is-primary { + background: var(--accent); + border-color: var(--accent-deep); + color: white; + font-weight: 600; +} + +button.is-primary:hover:not(:disabled) { + background: var(--accent-deep); + border-color: var(--accent-deep); + color: white; +} + textarea { width: 100%; min-height: 150px; @@ -669,6 +736,20 @@ select:focus { flex-wrap: wrap; gap: 10px; margin: 16px 0; + align-items: center; + padding: 12px 0; + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); +} + +.exercise-action-label { + width: 100%; + font-family: var(--serif-display); + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-faint); } .grading-error { @@ -748,6 +829,28 @@ select:focus { color: var(--gold); } +.mistake-list { + margin: 0; + padding: 0; + list-style: none; +} + +.mistake-list li { + position: relative; + margin: 8px 0; + padding-left: 20px; + line-height: 1.65; +} + +.mistake-list li::before { + content: "✕"; + position: absolute; + left: 0; + color: var(--accent); + font-size: 11px; + top: 4px; +} + details { margin-top: 14px; border-top: 1px solid var(--rule); @@ -995,7 +1098,7 @@ details summary { min-width: 0; display: flex; flex-direction: column; - gap: 4px; + gap: 5px; } .exercise-card-preview { @@ -1008,6 +1111,18 @@ details summary { white-space: nowrap; } +.exercise-card-title { + margin: 0; + font-size: 14px; + line-height: 1.45; + color: var(--ink); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--serif-body); +} + .difficulty-pill { align-self: flex-start; padding: 2px 8px; @@ -1042,6 +1157,54 @@ details summary { border-top: 1px solid var(--rule); } +.problem-statement { + margin: 0 0 20px; + padding: 18px 20px 16px; + background: var(--paper-page); + border: 1px solid var(--rule); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius) var(--radius) 0; + box-shadow: var(--shadow-page); +} + +.problem-title { + margin: 0 0 10px; + font-family: var(--serif-display); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-deep); + opacity: 0.8; +} + +.problem-body { + font-family: var(--serif-body); + font-size: 16px; + line-height: 1.85; + color: var(--ink); +} + +.answer-label { + margin: 0 0 8px; + font-family: var(--serif-display); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.exercise-num-label { + margin: 0 0 12px; + font-size: 11.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); + font-family: var(--serif-display); + font-weight: 600; +} + details summary:hover { color: var(--accent-deep); } @@ -1112,7 +1275,10 @@ details ul { margin: 0 0 10px; } -.chapter-header-kp-count { +.chapter-header-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; font-size: 12px; color: var(--ink-faint); margin: 0; @@ -1120,6 +1286,21 @@ details ul { letter-spacing: 0.06em; } +.chapter-header-stats span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chapter-header-stats span::before { + content: ""; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--gold); + opacity: 0.8; +} + /* ---------------------------------------------------------------- KP 导航:前后箭头 ----------------------------------------------------------------- */ @@ -1193,6 +1374,13 @@ details ul { margin: 0; } +.tutor-context { + margin: 5px 0 0; + color: var(--ink-soft); + font-size: 13.5px; + line-height: 1.45; +} + .settings-toggle { font-size: 12.5px; padding: 5px 11px; diff --git a/app/layout.tsx b/app/layout.tsx index 96bdf9a..af09fbc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "katex/dist/katex.min.css"; +import "./fonts.css"; import "./globals.css"; export const metadata: Metadata = { @@ -10,14 +11,6 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - - -
diff --git a/components/course/ChapterHeader.tsx b/components/course/ChapterHeader.tsx index 203d46d..e0688c4 100644 --- a/components/course/ChapterHeader.tsx +++ b/components/course/ChapterHeader.tsx @@ -1,3 +1,4 @@ +import { MathText } from "@/components/math/MathText"; import type { ChapterContent } from "@/lib/course/types"; const statusLabel: Record = { @@ -7,6 +8,9 @@ const statusLabel: Record = { }; export function ChapterHeader({ chapter }: { chapter: ChapterContent }) { + const diagramCount = new Set(chapter.knowledgePoints.flatMap((kp) => kp.diagramIds)).size; + const exerciseCount = new Set(chapter.knowledgePoints.flatMap((kp) => kp.exerciseIds)).size; + return (
@@ -14,10 +18,12 @@ export function ChapterHeader({ chapter }: { chapter: ChapterContent }) { {statusLabel[chapter.status] ?? chapter.status}

{chapter.title}

- {chapter.overview &&

{chapter.overview}

} -

- {chapter.knowledgePoints.length} 个知识点 -

+ {chapter.overview &&

} +
+ {chapter.knowledgePoints.length} 个知识点 + {diagramCount > 0 && {diagramCount} 张教学图示} + {exerciseCount > 0 && {exerciseCount} 道练习} +
); } diff --git a/components/course/ExercisePanel.tsx b/components/course/ExercisePanel.tsx index 0574698..e8ff9c1 100644 --- a/components/course/ExercisePanel.tsx +++ b/components/course/ExercisePanel.tsx @@ -33,13 +33,14 @@ function difficultyClass(d: string) { return d === "basic" ? "basic" : d === "medium" ? "medium" : "advanced"; } -function previewText(text: string, max = 72): string { - const stripped = text - .replace(/\$\$[\s\S]*?\$\$/g, "[式]") - .replace(/\$[^$\n]+\$/g, "[式]") +function cardLabel(exercise: Exercise): string { + if (exercise.title) return exercise.title; + const stripped = exercise.prompt + .replace(/\$\$[\s\S]*?\$\$/g, "") + .replace(/\$[^$\n]+\$/g, "") .replace(/\n+/g, " ") .trim(); - return stripped.length > max ? stripped.slice(0, max) + "…" : stripped; + return stripped.slice(0, 64) + (stripped.length > 64 ? "…" : ""); } export function ExercisePanel({ @@ -66,6 +67,7 @@ export function ExercisePanel({ const [gradeResult, setGradeResult] = useState(null); const [masteryState, setMasteryState] = useState("not_started"); const attachmentsRef = useRef([]); + const rubricRef = useRef(null); const activeExercise = selectedIndex != null ? exercises[selectedIndex] : undefined; const visibleHints = useMemo(() => { @@ -77,6 +79,13 @@ export function ExercisePanel({ attachmentsRef.current = attachments; }, [attachments]); + useEffect(() => { + setSelectedIndex(exercises.length > 0 ? 0 : null); + resetAnswerState(); + // Reset when the learning context changes; resetAnswerState is a local helper. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [knowledgePoint.id, exercises.length]); + useEffect(() => { const progress = loadCourseProgress(courseId, volumeId); const record = getKnowledgePointProgress(progress, knowledgePoint.id); @@ -193,7 +202,7 @@ export function ExercisePanel({

练习

{exercises.length > 0 ? `${exercises.length} 题` : "暂无练习"}
- {getMasteryLabel(masteryState)} + 当前掌握状态 · {getMasteryLabel(masteryState)} {exercises.length === 0 ? ( @@ -212,21 +221,28 @@ export function ExercisePanel({ > {index + 1}
+

{cardLabel(exercise)}

{difficultyLabel(exercise.difficulty)} -

{previewText(exercise.prompt)}

))} )} - {activeExercise && ( + {activeExercise && selectedIndex != null && (
-

- -

+

第 {selectedIndex + 1} 题 · 共 {exercises.length} 题

+
+ {activeExercise.title && ( +

{activeExercise.title}

+ )} +
+ +
+
+

解答