diff --git a/CLAUDE.md b/CLAUDE.md index 8a36f38..b0f6938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,9 +28,10 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1 - `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs - `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home - `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar - - `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors. No `color-mix()` (Chrome 105) + - `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on ``; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode - `src/icons.ts` — `` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`) - - `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code::`); the tutorial page still uses the shared `store/editors.ts` + - `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code::`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time) + - `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page - `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — keys: J/K next/prev student, 1–5 rate, P present; `components/review/PeersDrawer.vue` is the students' “同学作品” - `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard` - `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets diff --git a/bun.lock b/bun.lock index f110987..a71f16a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,8 @@ "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "6.12.3", + "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.41.1", "@fontsource/jetbrains-mono": "^5.3.0", "@vueuse/core": "^15.0.0", @@ -295,6 +297,8 @@ "@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + "@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], "@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="], diff --git a/client/package.json b/client/package.json index aebd17d..6d52795 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,8 @@ "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "6.12.3", + "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.41.1", "@fontsource/jetbrains-mono": "^5.3.0", "@vueuse/core": "^15.0.0", diff --git a/client/src/App.vue b/client/src/App.vue index b989fe1..73f4b5b 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,15 +1,17 @@ @@ -20,6 +22,7 @@ onMounted(loadUser) :locale="zhCN" :date-locale="dateZhCN" :hljs="hljs" + :theme="theme" :theme-overrides="themeOverrides" > diff --git a/client/src/components/ai/GuidancePanel.vue b/client/src/components/ai/GuidancePanel.vue index cac023c..dfbf73c 100644 --- a/client/src/components/ai/GuidancePanel.vue +++ b/client/src/components/ai/GuidancePanel.vue @@ -160,11 +160,11 @@ watch([() => messages.value.length, streamingContent], () => { font-size: 12px; font-weight: bold; margin-bottom: 4px; - color: #888; + color: var(--wp-muted); } .guidance-msg.user .msg-role { - color: #2080f0; + color: var(--wp-css); } .guidance-msg.assistant .msg-role { @@ -172,8 +172,8 @@ watch([() => messages.value.length, streamingContent], () => { } .guidance-msg.assistant .msg-content { - background: #f0f7ff; - border-left: 3px solid #2080f0; + background: var(--wp-css-soft); + border-left: 3px solid var(--wp-css); padding: 8px 12px; border-radius: 0 6px 6px 0; font-size: 14px; @@ -183,7 +183,7 @@ watch([() => messages.value.length, streamingContent], () => { .guidance-msg.user .msg-content { font-size: 14px; line-height: 1.6; - color: #333; + color: var(--wp-ink); } .msg-content :deep(p) { @@ -198,9 +198,9 @@ watch([() => messages.value.length, streamingContent], () => { font-size: 13px; font-weight: 500; padding: 6px 10px; - background: #f0fff4; + background: var(--wp-brand-soft); border-radius: 6px; - border: 1px solid #b8e8cc; + border: 1px solid var(--wp-brand-soft); } .typing-indicator { @@ -213,7 +213,7 @@ watch([() => messages.value.length, streamingContent], () => { width: 7px; height: 7px; border-radius: 50%; - background: #2080f0; + background: var(--wp-css); animation: bounce 1.4s ease-in-out infinite; } diff --git a/client/src/components/ai/PromptHistoryPanel.vue b/client/src/components/ai/PromptHistoryPanel.vue index 9f6ec45..db8ed2a 100644 --- a/client/src/components/ai/PromptHistoryPanel.vue +++ b/client/src/components/ai/PromptHistoryPanel.vue @@ -283,7 +283,7 @@ onMounted(() => { .history-toolbar { height: 42px; padding: 0 12px; - border-bottom: 1px solid var(--n-border-color, #efeff5); + border-bottom: 1px solid var(--n-border-color, var(--wp-line)); flex-shrink: 0; } @@ -307,12 +307,12 @@ onMounted(() => { .history-card.is-selected { - --n-color: #f7fffa; + --n-color: var(--wp-brand-soft); box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14); } .history-card.is-selected .history-main { - background: #eefaf3; + background: var(--wp-brand-soft); } .history-main { @@ -323,7 +323,7 @@ onMounted(() => { .prompt-markdown { margin: 0; padding: 0 12px 12px; - color: #333; + color: var(--wp-ink); font-size: 14px; line-height: 1.6; word-break: break-word; @@ -356,8 +356,8 @@ onMounted(() => { .thumbnail { height: 160px; - border-top: 1px solid var(--n-border-color, #efeff5); - background: #f7f7f9; + border-top: 1px solid var(--n-border-color, var(--wp-line)); + background: var(--wp-paper); overflow: hidden; display: flex; align-items: center; @@ -365,15 +365,15 @@ onMounted(() => { } .history-card.is-selected .thumbnail { - border-top-color: #d8f1e2; - background: #f4fbf6; + border-top-color: var(--wp-brand-soft); + background: var(--wp-brand-soft); } .thumbnail iframe { width: 100%; height: 100%; border: 0; - background: #fff; + background: var(--wp-surface); pointer-events: none; } diff --git a/client/src/components/ai/PromptPanel.vue b/client/src/components/ai/PromptPanel.vue index 33214d7..a4280e7 100644 --- a/client/src/components/ai/PromptPanel.vue +++ b/client/src/components/ai/PromptPanel.vue @@ -274,11 +274,11 @@ watch([() => messages.value.length, streamingContent], () => { font-size: 12px; font-weight: bold; margin-bottom: 4px; - color: #666; + color: var(--wp-muted); } .message.user .message-role { - color: #2080f0; + color: var(--wp-css); } .message.assistant .message-role { @@ -321,7 +321,7 @@ watch([() => messages.value.length, streamingContent], () => { } .message-content :deep(pre) { - background: #f5f5f5; + background: var(--wp-paper); padding: 8px 12px; border-radius: 4px; overflow-x: auto; @@ -379,7 +379,7 @@ watch([() => messages.value.length, streamingContent], () => { .streaming-hint { font-size: 11px; - color: #aaa; + color: var(--wp-faint); margin-top: 4px; animation: pulse 1.5s ease-in-out infinite; } @@ -387,7 +387,7 @@ watch([() => messages.value.length, streamingContent], () => { .input-area { flex-shrink: 0; padding: 12px; - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--wp-line); } .message-pair { @@ -405,11 +405,11 @@ watch([() => messages.value.length, streamingContent], () => { top: 0; right: 0; background: none; - border: 1px solid #e0e0e0; + border: 1px solid var(--wp-line); border-radius: 4px; padding: 3px 6px; cursor: pointer; - color: #bbb; + color: var(--wp-faint); line-height: 1; transition: color 0.15s, @@ -417,8 +417,8 @@ watch([() => messages.value.length, streamingContent], () => { } .pair-delete-btn:hover { - color: #e03e3e; - border-color: #e03e3e; + color: var(--wp-bad); + border-color: var(--wp-bad); } .message-pair.has-delete:hover .pair-delete-btn { diff --git a/client/src/components/dashboard/MarkdownEditor.vue b/client/src/components/dashboard/MarkdownEditor.vue index 19b632d..2fae502 100644 --- a/client/src/components/dashboard/MarkdownEditor.vue +++ b/client/src/components/dashboard/MarkdownEditor.vue @@ -1,5 +1,10 @@ diff --git a/client/src/components/editor/Editor.vue b/client/src/components/editor/Editor.vue index e353a49..beeaa1d 100644 --- a/client/src/components/editor/Editor.vue +++ b/client/src/components/editor/Editor.vue @@ -5,6 +5,9 @@ import { css } from "@codemirror/lang-css" import { javascript } from "@codemirror/lang-javascript" import { html } from "@codemirror/lang-html" import { computed } from "vue" +import { syntaxHighlighting } from "@codemirror/language" +import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark" +import { isDark } from "../../store/theme" const styleTheme = EditorView.baseTheme({ "& .cm-scroller": { @@ -44,13 +47,24 @@ const lang = computed(() => { const bgTheme = computed(() => { const bg = bgColors[props.language] ?? "var(--wp-surface)" - return EditorView.theme({ - "&": { backgroundColor: bg }, - ".cm-gutters": { backgroundColor: bg }, - }) + return EditorView.theme( + { + "&": { backgroundColor: bg, color: "var(--wp-ink)" }, + ".cm-gutters": { backgroundColor: bg, color: "var(--wp-faint)", borderColor: "var(--wp-line)" }, + ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "rgba(127, 127, 127, 0.08)" }, + ".cm-cursor": { borderLeftColor: "var(--wp-ink)" }, + }, + { dark: isDark.value }, + ) }) -const extensions = computed(() => [styleTheme, bgTheme.value, lang.value]) +// 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的) +const extensions = computed(() => [ + styleTheme, + bgTheme.value, + ...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []), + lang.value, +])