diff --git a/CLAUDE.md b/CLAUDE.md index e08feb4..cfa91e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1 - `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 (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 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) + - `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; `SubmitModal` first asks an optional one-line reflection (最满意的改动和理由), stored in `submission_reflection` (`ON DELETE CASCADE`, returned by `GET /submission/:id` only to staff and the author, shown as “学生说” in `ReviewPanel`). In `PromptPanel`, “出问题了” is a three-field form (做了什么 / 想看到什么 / 实际看到了什么) joined into one prompt; a short vague complaint (“还是不行”) gets a one-time hint offering it. Pasted external-AI prompts need at least 8 characters 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) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `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` diff --git a/client/src/api.ts b/client/src/api.ts index e489a5c..e072e56 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -264,6 +264,7 @@ export const Submission = { prompt?: string }, messageId?: number, + reflection?: string, ) { const { prompt, ...rest } = code const data = { @@ -271,6 +272,7 @@ export const Submission = { ...rest, prompt: prompt || null, message_id: messageId ?? null, + reflection: reflection || null, } const res = await http.post("/submission/", data) return res.data diff --git a/client/src/components/ai/PromptPanel.vue b/client/src/components/ai/PromptPanel.vue index 23c94d5..49999fa 100644 --- a/client/src/components/ai/PromptPanel.vue +++ b/client/src/components/ai/PromptPanel.vue @@ -77,63 +77,105 @@
- - - - - AI 教练会问你几个问题,帮你把想要的效果描述清楚 - - - - - - 停止 - - +
+ 说清楚哪里不对,AI 更容易一次修好 + + 说清楚 + 直接发 + +
+
+
出问题了?说清楚这三件事
+ + + + + + + + 取消 + + 发给 AI + + +
+
+ diff --git a/client/src/components/workbench/Workbench.vue b/client/src/components/workbench/Workbench.vue index f276491..0f7e934 100644 --- a/client/src/components/workbench/Workbench.vue +++ b/client/src/components/workbench/Workbench.vue @@ -103,6 +103,12 @@ + (null) const busy = ref(false) const briefOpen = ref(true) const showPaste = ref(false) +const showSubmit = ref(false) const showHistory = ref(false) const historyKey = ref(0) @@ -308,21 +316,46 @@ function openHistory() { } async function submit() { + if (props.kind === "challenge") { + showSubmit.value = true + return + } + busy.value = true + try { + await props.saveWork?.({ ...code }) + message.success("作品已保存") + } catch (err: any) { + message.error(err.response?.data?.detail ?? "保存失败,请重试") + } finally { + busy.value = false + } +} + +const submitTitle = computed(() => { + if (modified.value || !selectedId.value) return "交当前修改" + const n = versions.value.find((v) => v.id === selectedId.value)?.n + return n ? `交 v${n}` : "交这一版" +}) + +// 要交的这一版之前还有版本(当前修改会存成新的一版) +const submitHasEarlier = computed(() => { + if (modified.value || !selectedId.value) return versions.value.length > 0 + return (versions.value.find((v) => v.id === selectedId.value)?.n ?? 1) > 1 +}) + +// 挑战:先在弹框里写一句反思(可以不写)再交;出错时抛出去,弹框不关 +async function submitChallenge(reflection: string) { busy.value = true try { - if (props.kind === "creation") { - await props.saveWork?.({ ...code }) - message.success("作品已保存") - return - } const id = modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value - await Submission.create(props.taskId, { ...code }, id) + await Submission.create(props.taskId, { ...code }, id, reflection) markMessageSubmitted(id) const n = versions.value.find((v) => v.id === id)?.n message.success(n ? `已提交 v${n}` : "已提交") } catch (err: any) { message.error(err.response?.data?.detail ?? "提交失败,请重试") + throw err } finally { busy.value = false } diff --git a/client/src/icons.ts b/client/src/icons.ts index 765bc3e..3e20c03 100644 --- a/client/src/icons.ts +++ b/client/src/icons.ts @@ -4,6 +4,7 @@ import { ArrowRight, Award, BookOpen, + Bug, ChartNoAxesColumn, Check, ChevronDown, @@ -49,6 +50,7 @@ const registry: Record = { "arrow-right": ArrowRight, award: Award, "book-open": BookOpen, + bug: Bug, "bar-chart-2": ChartNoAxesColumn, check: Check, "chevron-down": ChevronDown, diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index 0813ff4..7d0dce3 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -167,6 +167,8 @@ export interface SubmissionAll { view_count: number created: Date modified: Date + // 交的时候写的反思,只有老师和作者本人拿得到 + reflection?: string | null } export interface UserTag { diff --git a/server/src/db.ts b/server/src/db.ts index bb207e7..fa6dff2 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -51,6 +51,9 @@ CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_mes CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level"); CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL; +-- 学生交作品时写的一句反思(最满意的改动和理由);可以不写。删提交时跟着删 +CREATE TABLE IF NOT EXISTS "submission_reflection" ("submission_id" char(32) NOT NULL PRIMARY KEY REFERENCES "submission_submission" ("id") ON DELETE CASCADE, "content" text NOT NULL, "modified" datetime NOT NULL); + -- 登录用 JWT:access token 不落库,refresh token 只存 sha256 DROP TABLE IF EXISTS "auth_session"; CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL); diff --git a/server/src/routes/submission.ts b/server/src/routes/submission.ts index a55f91c..159ab12 100644 --- a/server/src/routes/submission.ts +++ b/server/src/routes/submission.ts @@ -120,6 +120,7 @@ router.post("/", async (c) => { js: z.string().nullable().optional(), prompt: z.string().nullable().optional(), message_id: z.number().int().nullable().optional(), // 关联的 assistant message + reflection: z.string().max(1000).nullable().optional(), // 交的时候写的一句反思,可以不写 }), ) const task = orNotFound( @@ -131,6 +132,8 @@ router.post("/", async (c) => { const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null } + const reflection = p.reflection?.trim() || null + const { submissionId, newUserMsgId } = tx(() => { let manualAsstMsgId: number | null = null let linkedMsg: MessageRow | null = null @@ -203,6 +206,15 @@ router.post("/", async (c) => { return { submissionId: id, newUserMsgId } }) + // 同一版再交时没写就留着上次写的 + if (reflection) { + run( + `INSERT INTO submission_reflection (submission_id, content, modified) VALUES ($id, $content, $ts) + ON CONFLICT (submission_id) DO UPDATE SET content = excluded.content, modified = excluded.modified`, + { id: submissionId, content: reflection, ts: now() }, + ) + } + // 只对新增的提示词做层级分类 if ( newUserMsgId !== null && @@ -540,7 +552,13 @@ router.get("/:id", (c) => { const id = uuidParam(c, "id") requireView(user, id) const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id })) - const out = submissionOut(s) + // 反思只给老师和作者本人看 + const reflection = + isStaff(user) || s.user_id === user.id + ? (get<{ content: string }>(`SELECT content FROM submission_reflection WHERE submission_id = $id`, { id }) + ?.content ?? null) + : null + const out = { ...submissionOut(s), reflection } // 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名) if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) { return c.json({ ...out, userid: null, username: null, score: null }) diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index a76abeb..442df1e 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -276,6 +276,34 @@ describe("提交与删除", () => { expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }]) }) + test("交的时候写的反思:再交没写就保留,只有老师和作者能看,删提交时一起删", async () => { + makeUser("stu") + makeUser("mate") + makeUser("teacher", "admin") + const task = makeTask("T") + const stu = await client("stu") + const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "

1", css: "", js: "" }) + const sub = await stu("POST", "/api/submission/", { + task_id: task, + html: "

1", + message_id: edit.json.message_id, + reflection: " 按钮改成三个,因为五个太乱 ", + }) + const id = sub.json.id + await stu("POST", "/api/submission/", { task_id: task, html: "

1!", message_id: edit.json.message_id, reflection: "" }) + expect((await stu("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱") + expect((await (await client("teacher"))("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱") + + const mate = await client("mate") + await mate("POST", "/api/submission/", { task_id: task, html: "

m" }) + const peer = await mate("GET", `/api/submission/${id}`) + expect(peer.status).toBe(200) + expect(peer.json.reflection).toBeNull() + + expect((await stu("DELETE", `/api/submission/${id}`)).status).toBe(200) + expect(all(`SELECT * FROM submission_reflection`)).toEqual([]) + }) + test("被评分的提交只有超管能删", async () => { const author = makeUser("author") const other = makeUser("other")