Compare commits

...
23 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 489274ba0e feat: 加 prompt-stats 脚本,对比某天前后学生和 AI 对话的情况
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
轮数分布、层级分布、第 2 轮起的 L1、只粘贴外部代码的比例、“出问题了”“帮我写清楚”
和交作品反思的使用情况。只读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:16:33 +08:00
xuyueandClaude Opus 5.5 e9127318cb feat: “出问题了”三栏说清问题,交作品时写一句反思,外部提示词至少 8 个字
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 对话框加“出问题了”:做了什么 / 想看到什么 / 实际看到了什么拼成一句发给 AI;
  只说“还是不行”这类的提醒一次,可以直接发
- “交这一版”先弹框问最满意的改动和理由(可以不写),存在 submission_reflection,
  批改面板显示为“学生说”,只有老师和作者本人看得到
- 粘贴外部 AI 代码时提示词至少 8 个字,可以把几轮都贴进来

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:10:48 +08:00
xuyueandClaude Opus 5.5 5bdacc438d feat: 挑战的题目要求发给 AI 当对照清单,学生没提到的不替他做,在“想一想”里问
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
学生不用再整段贴题目;AI 只做学生说了的,从没提到的要求里挑一项问他打算怎么做。
整段贴了题目的照做。自由创作的题目是学生自己写的,不发。输入框提示改成“用你自己的话说说”。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:40:04 +08:00
xuyueandClaude Opus 5.5 43c18cdb88 feat: AI 回复说明替学生定了什么并问一个问题,“帮我写清楚”能看到题目和之前的对话
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 生成回复分“做了什么 / 我替你定的 / 想一想”三项;只问问题时直接回答、不给代码
- 没有代码的回复发 code: null,不再清空网页;上下文保留最近一份带代码的回复
- 教练带上题目要求、最近的提问和 AI 上一次的说明,照搬题目不算写清楚

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:36:06 +08:00
xuyueandClaude Opus 5.5 9fee688d95 fix: AI 对话和引导里的提问回复完后自动打层级
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
旧后端在消息保存后自动分类,改写成 Bun 时漏了:只有外部 AI 版本和手动提交带的提示词会分类,
学生在 AI 对话和引导里发的提问都没有层级。现在回复存库后在后台分类(失败或断开的提问不分类)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:13:46 +08:00
xuyueandClaude Opus 5.5 9f3f7187e1 feat: AI 对话等待时显示构思了多久,代码不在对话里展开
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
deepseek-flash 生成代码前要思考,复杂的题第一个字要等一两分钟,之前只有三个跳动的点;
出字后又把几万字的代码一段段渲染在对话里,学生并不看代码,机房的旧机器也吃力。

- 出字前显示“AI 正在构思… 已 N 秒”,超过 20 秒提示复杂的网页要想一两分钟
- 生成中只显示文字说明和“正在写网页… 已写 N 行”;写完的回复里代码块换成
  “网页代码 N 行,在右边‘代码’里看”

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:09:47 +08:00
xuyueandClaude Opus 5.5 6b2ffbb3ec fix: 按 DeepSeek 文档为每种调用指定是否思考,修复分类器拿不到结果
deepseek-flash 默认先思考(reasoning_effort high),思考也占输出 token,思考模式下 temperature 无效。
分类器只给 10 个 token,全被思考用掉,正文为空,切到新后端后的提问都没有层级。

- 生成代码:开思考;引导:关思考,回得快;分类:关思考,temperature 0 才生效
- thinking 只发给 DeepSeek,豆包请求不变
- 实测 deepseek-flash:分类约 1.2s,引导约 1.5s;固定 68 条样本两次一致 65 条

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:53:30 +08:00
xuyueandClaude Opus 5.5 b765cbf1aa fix: 理清 L1/L2 的含义,并写死几条容易判错的边界
- L2 理解按布鲁姆的本义改成“想弄懂原理”:问为什么、是什么意思、怎么做到的;
  只报结果(“没反应”“进不去”“有 bug”)归 L1
- 标准改成按顺序判断,并写明:只说风格和功能没有理由是 L3 不是 L5,
  说明要改成什么样是 L3 不是 L4,整段贴题目要求是 L3 不是 L1
- 生产库副本固定抽 70 条(含 10 条“为什么”提问),两次结果一致 66/68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:45:44 +08:00
xuyueandClaude Opus 5.5 e06930c8f7 feat: 提示词链显示层级走势、每轮改了多少行;课堂总结加“怎么问的 AI”
- 提示词弹框:顶部每轮一根层级柱子,点了跳到那一轮;每轮标出代码比上一版 +/− 多少行
  (没改标“代码没变”),和前面某轮一字不差的提问标“重复提问”
- 课堂总结:每人最新一版平均几轮、一轮就交、最后一版用外部 AI、问过引导的比例,
  以及各层级提问次数
- prompt_message 加两个覆盖索引,统计只读小列(像素画板全部班级 197 份约 0.3 ms)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:35:33 +08:00
xuyueandClaude Opus 5.5 f41fda3c19 feat: 提示词分类按“指挥 AI 做网页”的场景重写标准,并带上题目和上一轮
- 以前只看单句、例子是学前端知识,把题目要求原样贴过去也判成 L6(首轮近一半 L6)。
  现在把题目要求和同一对话里上一轮的提问一起发给模型,只看学生自己想的部分:
  照搬题目功能清单算 L3,重复上一轮不高于上一轮
- L1–L6 说明跟着改成新的标准
- 生产库副本抽 60 条:新标准两次结果一致 57 条;L3 占 75%,L6 从 25 条降到 4 条

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:35:33 +08:00
xuyueandClaude Opus 5.5 30630e8125 fix: 提示词链里的引导提问不算轮数,分类器固定结果、跳过过短输入
- 引导对话和 AI 对话存在同一个对话里,提示词链把它当成一轮“AI 对话”,显示“没有网页”。
  现在标成“引导”,淡化显示,不计入轮数,也不能选中(↑↓ 跳过)
- 分类器 temperature 设为 0:同一句提示词以前约两成会被打成不同层级
- 不到 4 个字的提问(“1”“,”等)不打层级,--force 重标时清掉旧标签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:26:29 +08:00
xuyueandClaude Opus 5.5 bc9da688ba feat: 重新设计“全部历史”抽屉
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 最新的一轮在最上面,序号按时间先后;顶部说明“点一条就把那一版放回编辑器”
- 来源、等级标签和提示词链弹框统一(加“L3 应用”这类等级和“交过”标记)
- 删除前确认,交过的那一轮说明交的那一份会一起删掉;以前点一下就删
- 缩略图改用 PREVIEW_SANDBOX,暗色下也保持白底;去掉写死的阴影颜色

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:08:27 +08:00
xuyueandClaude Opus 5.5 915b48ebc0 feat: 批改页加“课堂总结”入口
- 作品墙顶部“开始讲评”旁边加“课堂总结”,用批改页当前的班级和任务打开
- 在批改页里打开时不显示“去批改”;点高分作品直接在批改页里打开,
  那个学生不在当前班就先切过去(地址栏跟着切,关掉作品回到那个班)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:06:53 +08:00
xuyueandClaude Opus 5.5 54003fd404 fix: 预览里 normalize.css 盖掉了学生自己写的样式
normalize 放在学生的 <style> 后面,同样写法的规则后加载的赢,学生写的 body { margin: 20px }、
h1 { margin: 0 } 等都不生效(实测 body 边距始终是 0)。挪到学生样式前面。
挑战预览、批改、作品展、提示词链、下载的文件都走 buildPreviewDocument,一起修好。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:04:53 +08:00
xuyueandClaude Opus 5.5 6f0dd33de3 feat: 重新设计自由创作列表
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 和挑战列表同一种样式:一行一个主题,显示描述、“已有作品 / 还没开始”和更新时间
- 一个都没有时给 4 个主题点子,点了带着名称和描述打开新建框
- 删除改用页面内确认;新建框打开时光标在名称上

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:51:06 +08:00
xuyueandClaude Opus 5.5 81e1b233ee feat: 重新设计登录框
- 记住上次选的班级(机房一节课一个班),打开就带出这个班的名单;不记姓名
- 姓名可以打字筛选,选好后光标自动跳到密码框
- 三个报错框合成一处;“管理员登录”改成“老师”;密码可以点眼睛看;加“忘了密码找老师重置”

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:23:56 +08:00
xuyueandClaude Opus 5.5 1ba31844a9 feat: 重新设计“帮我写清楚”弹框
- 标题从“调教提示词”改成“帮我把要求写清楚”,开头说明怎么用
- 学生每次发的标成“你的要求 · 第 N 版”,AI 的回复标成“AI 的建议”
- 输入框明确是“你的要求”;按钮改成“让 AI 再看看”和“用这段生成网页”,
  没改过的要求不会重复发;AI 回复时不能直接生成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:22:53 +08:00
xuyueandClaude Opus 5.5 51ee246a6f feat: 重新设计出题页的图片素材
- 出题页只放一个“图片素材(N)”按钮,素材在弹框里管理,不再挤在保存那一排
- 选图片(可多选)或拖进来就直接上传,文件名按服务端规则自动整理,不用先手填文件名
- 说明在代码里直接写文件名引用;每张图能复制文件名;删除前确认
- 同名替换后给图片地址加时间戳,避免还显示旧图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:21:48 +08:00
xuyueandClaude Opus 5.5 951a6e0826 feat: 重新设计作品展管理
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 作品改成缩略图卡片:手动排序时用 ← → 调整顺序,有新提交的显示“换成最新的”,
  点缩略图在抽屉里打开批改面板,撤下用页面内确认
- 奖项列表可以上下调整顺序(不用再填排序数字);设置有改动才能保存;删除奖项说明会撤下几件作品
- 去掉按提交 ID 添加作品(前端、类型和 /manage/submissions 接口),改为提示在批改面板里“加入作品展”
- 管理接口返回作品代码,画缩略图用

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:02:53 +08:00
xuyueandClaude Opus 5.5 4c9617caf1 feat: 重新设计作品展,点开能看大图、获奖和提示词
- 点作品不再跳到只有网页的全屏页,而是弹出大图:作者、题目、所获奖项、得分、浏览次数,
  有提示词的能看“是怎么问出来的”(作品展接口),← → 在同一奖项里切换
- 卡片缩略图改用 buildPreviewDocument,题目素材能加载;未评分的不显示星标
- 顶部加奖项跳转;标题和导航统一叫“作品展”
- 作品展接口返回 task_type(拼素材地址要用)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:59:56 +08:00
xuyueandClaude Opus 5.5 3fb23dc3f3 feat: 重新设计提示词链弹框,批改面板也能逐轮看网页
- 左边每轮提问:来源(AI 对话 / 手改 / 外部 AI)、等级写成“L3 应用”,标出交的这一版和没有网页的轮次;
  右边是这一轮生成的网页,↑ ↓ 切换
- 预览改用 buildPreviewDocument:题目素材能加载,暗色下也保持白底
- 批改面板加“逐轮看网页”;弹框开着时接管按键,批改页的 Esc / J / K 和面板的 1–5 / P 不会被触发
- 查看器里不再能删除对话轮次(学生在做题页的对话和历史里删)
- 预留作品展的接口参数(fromShowcase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:54:44 +08:00
xuyueandClaude Opus 5.5 6ad9be2b30 feat: “我的提交”和“全部提交”拆成两个页面
- 学生的“我的提交”(MySubmissions):左边按教程 / 挑战列出交过的任务,右边看每一次交的版本,
  标出计入成绩的那一版(分数最高的一次);可以看提示词、放回编辑器、删除这一版
- 超管的“全部提交”:按学生、类型、分区筛选,按时间或得分排序;点一行在右边打开批改面板,
  批改面板加了超管专用的“删除这一版”
- 删掉只给旧提交页用的展开子表、看代码弹框、任务标题组件和全局 submission store,
  编辑器预览里随之不用的打分、看代码、复制链接一起去掉

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:46:55 +08:00
xuyueandClaude Opus 5.5 726906e3f6 feat: 重新设计成绩册
左右固定列从 11 列减到 6 列,1366 宽一屏能看到全部任务;任务按教程 / 挑战分组,
表头显示题目名和交的人数;单元格只区分缺交、未评和分数;考核等级和分数合成一列,
教程 / 挑战合计和平均放到总分的悬停提示里;名次、考核、总分、已交可以排序;
加“怎么算的”说明;默认打开批改页当前的班级。计算规则和 CSV 不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:42:21 +08:00
43 changed files with 4364 additions and 2550 deletions

No files matched your search

+4 -3
View File
@@ -17,9 +17,9 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall — no `classname` means all classes —, students' peer works with `scope=class|all`), `prompt`
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or any student who has submitted the same task (any class). Students' `GET /submission/` only lists their own. Peer works and the random-rating item are anonymous until the viewer rates them (`PUT /:id/score` returns the author). `flag` now only carries `present` (teacher's in-class presentation queue); the old four colors are no longer shown
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with code kept only in the newest reply that has code. Chat replies are written as **做了什么 / 我替你定的 / 想一想** — the decisions the AI made for the student and one open question, to prompt the next round; a reply to a plain question has no code and the WebSocket sends `code: null`. For challenges the AI also gets the task brief (`challengeBrief`) as a checklist only (`BRIEF_RULES`): it builds just what the student asked for and asks about one missing requirement in “想一想”; creations get no brief. The “帮我写清楚” coach gets the task brief, recent prompts and the last AI explanation from `guidanceContext` in `services/prompt.ts`, and judges by what the student added beyond the brief, like the classifier), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`, `prompt-stats.ts` (read-only before/after comparison of AI-chat behavior: rounds, levels, external-code share, “出问题了” / reflection usage)
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
@@ -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 `<html>`; `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` — `<Icon icon="lucide:name" />` 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:<user>:<task>`); 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:<user>:<task>`); 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`
@@ -62,6 +62,7 @@ bun run typecheck
bun scripts/create-superuser.ts <username> <password>
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
bun scripts/recalculate-scores.ts [--dry-run]
bun scripts/prompt-stats.ts <split-date> [--from date] [--to date] # dates in Beijing time
```
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
+2 -10
View File
@@ -17,7 +17,6 @@ import type {
AwardItemManageOut,
GradebookOut,
GradebookQuery,
ShowcaseSubmissionLookupOut,
ShowcaseDetail,
PromptRound,
RandomRatingItem,
@@ -265,6 +264,7 @@ export const Submission = {
prompt?: string
},
messageId?: number,
reflection?: string,
) {
const { prompt, ...rest } = code
const data = {
@@ -272,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
@@ -519,15 +520,6 @@ export const Showcase = {
return res.data
},
async findSubmissionForAward(
submissionId: string,
): Promise<ShowcaseSubmissionLookupOut> {
const res = await http.get(
`/submission/showcase/manage/submissions/${submissionId}`,
)
return res.data
},
async addAwardItem(
id: number,
payload: AwardItemIn,
+63 -49
View File
@@ -2,12 +2,13 @@
<n-modal
preset="card"
title="登录"
style="width: 420px"
style="width: min(400px, 92vw)"
:bordered="false"
v-model:show="loginModal"
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form>
<n-tab-pane name="student" tab="学生">
<n-form :show-feedback="false" class="form">
<n-form-item label="班级">
<n-select
v-model:value="selectedClass"
@@ -21,37 +22,25 @@
<n-select
v-model:value="selectedUsername"
:options="nameOptions"
placeholder="选择姓名"
filterable
:placeholder="selectedClass ? '选择或输入姓名' : '先选班级'"
:loading="namesLoading"
:disabled="!selectedClass || namesLoading"
@update:value="focusPassword"
/>
</n-form-item>
<n-form-item label="密码">
<n-input
ref="studentPasswordRef"
type="password"
show-password-on="click"
v-model:value="studentPassword"
name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitStudent"
/>
</n-form-item>
<n-alert
type="error"
v-if="classesError"
class="message"
title="加载班级列表失败,请刷新重试"
/>
<n-alert
type="error"
v-if="namesError"
class="message"
title="加载姓名列表失败,请重新选择班级"
/>
<n-alert
type="error"
v-if="studentError"
class="message"
:title="studentError"
/>
<div v-if="studentError" class="error">{{ studentError }}</div>
<n-button
block
type="primary"
@@ -61,28 +50,26 @@
>
登录
</n-button>
<div class="note">忘了密码找老师重置</div>
</n-form>
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录">
<n-form>
<n-tab-pane name="admin" tab="老师">
<n-form :show-feedback="false" class="form">
<n-form-item label="用户名">
<n-input v-model:value="adminName" name="username" />
<n-input v-model:value="adminName" name="username" placeholder="用户名" />
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
show-password-on="click"
v-model:value="adminPassword"
name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitAdmin"
/>
</n-form-item>
<n-alert
type="error"
v-if="adminError"
class="message"
:title="adminError"
/>
<div v-if="adminError" class="error">{{ adminError }}</div>
<n-button
block
type="primary"
@@ -99,14 +86,17 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue"
import { ref, computed, nextTick, onMounted } from "vue"
import { Account } from "../api"
import { loginModal } from "../store/modal"
import { user } from "../store/user"
import { router } from "../router"
// Tab state
// 机房的电脑一节课一个班轮流用:记住班级,不记姓名
const CLASS_KEY = "login-class"
const activeTab = ref("student")
const studentPasswordRef = ref<{ focus: () => void } | null>(null)
// Student tab state
const selectedClass = ref<string | null>(null)
@@ -115,10 +105,8 @@ const studentPassword = ref("")
const studentLoading = ref(false)
const studentError = ref("")
// Classes data
const classes = ref<string[]>([])
const classesLoading = ref(false)
const classesError = ref(false)
const classOptions = computed(() =>
classes.value.map((c) => ({ label: c, value: c })),
)
@@ -126,7 +114,6 @@ const classOptions = computed(() =>
// Names data
const names = ref<{ name: string; username: string }[]>([])
const namesLoading = ref(false)
const namesError = ref(false)
const nameOptions = computed(() =>
names.value.map((n) => ({ label: n.name, value: n.username })),
)
@@ -137,28 +124,31 @@ const adminPassword = ref("")
const adminLoading = ref(false)
const adminError = ref("")
// Load classes on mount (cached — not reloaded on tab switch)
// 班级列表只在打开页面时取一次
onMounted(async () => {
classesLoading.value = true
classesError.value = false
try {
classes.value = await Account.listClasses()
} catch {
classesError.value = true
studentError.value = "班级列表没加载出来,请刷新页面重试"
return
} finally {
classesLoading.value = false
}
let saved: string | null = null
try {
saved = localStorage.getItem(CLASS_KEY)
} catch {}
if (saved && classes.value.includes(saved)) {
selectedClass.value = saved
onClassChange(saved)
}
})
// 换标签页时清掉输入的密码和错误;选好的班级和姓名留着
function onTabChange() {
// Reset student tab state
selectedClass.value = null
selectedUsername.value = null
studentPassword.value = ""
studentError.value = ""
namesError.value = false
names.value = []
// Reset admin tab state
adminName.value = ""
adminPassword.value = ""
adminError.value = ""
@@ -167,17 +157,24 @@ function onTabChange() {
async function onClassChange(classname: string) {
selectedUsername.value = null
names.value = []
namesError.value = false
studentError.value = ""
try {
localStorage.setItem(CLASS_KEY, classname)
} catch {}
namesLoading.value = true
try {
names.value = await Account.listNamesByClass(classname)
} catch {
namesError.value = true
studentError.value = "姓名列表没加载出来,请重新选一次班级"
} finally {
namesLoading.value = false
}
}
function focusPassword() {
nextTick(() => studentPasswordRef.value?.focus())
}
// 失败次数太多被暂时禁止登录时,显示服务端给的提示
function loginError(err: any, fallback: string) {
return err.response?.status === 429 ? err.response.data.detail : fallback
@@ -228,7 +225,24 @@ async function submitAdmin() {
</script>
<style scoped>
.message {
margin-bottom: 12px;
.form {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 4px;
}
.error {
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--wp-bad);
background: var(--wp-bad-soft);
}
.note {
font-size: 12px;
text-align: center;
color: var(--wp-faint);
}
</style>
+132 -104
View File
@@ -2,82 +2,74 @@
<n-modal
:show="isOpen"
preset="card"
title="调教提示词"
style="width: min(560px, 92vw)"
title="帮我把要求写清楚"
style="width: min(640px, 92vw)"
:mask-closable="!streaming"
@update:show="onModalClose"
>
<div class="guidance-body">
<div class="guidance-messages" ref="messagesRef">
<p class="intro">
AI 会看看你的要求哪里还不够清楚,给你提建议。照着建议改好下面的要求,再让它看看;
觉得可以了就点“用这段生成网页”。
</p>
<div ref="messagesRef" class="messages">
<div v-for="(msg, i) in messages" :key="msg.id ?? i" :class="['msg', msg.role]">
<div class="who">
{{ msg.role === "user" ? `你的要求 · 第 ${versionOf(i)} 版` : "AI 的建议" }}
</div>
<div class="content markdown-body" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="msg assistant">
<div class="who">AI 的建议</div>
<div v-if="!displayStreamingContent" class="typing">
<span></span><span></span><span></span>
</div>
<div
v-for="(msg, i) in messages"
:key="msg.id ?? i"
class="guidance-msg"
:class="msg.role"
>
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="guidance-msg assistant">
<div class="msg-role">AI</div>
<div v-if="!displayStreamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="msg-content"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
v-else
class="content markdown-body"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
</div>
<div v-if="isReady" class="ready-hint">
<Icon icon="lucide:check-circle" :width="16" />
提示词已优化,可以生成代码了!
</div>
<div v-if="isReady" class="ready">
<Icon icon="lucide:check-circle" :width="16" />
AI 觉得要求已经写清楚了,可以生成网页了
</div>
<div class="guidance-input">
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="修改你的提示词后发送..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<div>
<n-button
v-if="streaming"
type="error"
size="small"
@click="stopGuidance"
>
停止
</n-button>
<n-button
v-else
size="small"
:disabled="!draftPrompt.trim() || !connected"
@click="send"
>
发送
</n-button>
</div>
<div class="draft">
<div class="draft-label">你的要求</div>
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="按 AI 的建议把要求改得更具体:要什么内容、什么颜色和布局、点了以后会怎样……"
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" class="draft-actions">
<n-flex align="center" :size="8">
<n-button v-if="streaming" size="small" @click="stopGuidance">停止</n-button>
<n-button
:type="isReady ? 'primary' : 'default'"
v-else
size="small"
:disabled="!draftPrompt.trim()"
@click="generate"
:disabled="!draftPrompt.trim() || !connected || draftPrompt.trim() === lastSent"
@click="send"
>
<template #icon>
<Icon icon="lucide:play" :width="14" />
</template>
生成代码
让 AI 再看看
</n-button>
<span class="muted">Enter 发送,Shift + Enter 换行</span>
</n-flex>
</div>
<n-button
:type="isReady ? 'primary' : 'default'"
:disabled="!draftPrompt.trim() || streaming"
@click="generate"
>
<template #icon><Icon icon="lucide:play" :width="14" /></template>
用这段生成网页
</n-button>
</n-flex>
</div>
</n-modal>
</template>
@@ -106,13 +98,23 @@ const emit = defineEmits<{
const draftPrompt = ref("")
const messagesRef = ref<HTMLElement>()
// 第几版要求:只数学生发的
function versionOf(index: number) {
return messages.value.slice(0, index + 1).filter((m) => m.role === "user").length
}
// 没改过就不用再发一遍
const lastSent = computed(
() => [...messages.value].reverse().find((m) => m.role === "user")?.content.trim() ?? "",
)
const displayStreamingContent = computed(() =>
streamingContent.value.replace(/^\[READY\]\n?/, "")
)
function send() {
const text = draftPrompt.value.trim()
if (!text || streaming.value) return
if (!text || streaming.value || text === lastSent.value) return
sendGuidance(text)
}
@@ -141,87 +143,113 @@ watch([() => messages.value.length, streamingContent], () => {
</script>
<style scoped>
.guidance-body {
display: flex;
flex-direction: column;
gap: 12px;
.intro {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--wp-muted);
}
.guidance-messages {
min-height: 160px;
max-height: 360px;
.messages {
min-height: 120px;
max-height: 45vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.guidance-msg .msg-role {
font-size: 12px;
font-weight: bold;
.who {
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: var(--wp-muted);
}
.guidance-msg.user .msg-role {
color: var(--wp-css);
}
.guidance-msg.assistant .msg-role {
.msg.assistant .who {
color: var(--wp-brand);
}
.guidance-msg.assistant .msg-content {
background: var(--wp-css-soft);
border-left: 3px solid var(--wp-css);
.content {
padding: 8px 12px;
border-radius: 0 6px 6px 0;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
}
.guidance-msg.user .msg-content {
font-size: 14px;
line-height: 1.6;
line-height: 1.7;
color: var(--wp-ink);
}
.msg-content :deep(p) {
margin: 0;
.msg.user .content {
border: 1px solid var(--wp-line);
background: var(--wp-paper);
}
.ready-hint {
.msg.assistant .content {
border-left: 3px solid var(--wp-brand);
border-radius: 0 8px 8px 0;
background: var(--wp-brand-soft);
}
.content :deep(p) {
margin: 0 0 6px;
}
.content :deep(:last-child) {
margin-bottom: 0;
}
.ready {
display: flex;
align-items: center;
gap: 6px;
color: var(--wp-brand);
font-size: 13px;
font-weight: 500;
margin-top: 12px;
padding: 6px 10px;
background: var(--wp-brand-soft);
border-radius: 6px;
border: 1px solid var(--wp-brand-soft);
font-size: 13px;
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.typing-indicator {
.draft {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--wp-line);
}
.draft-label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.draft-actions {
margin-top: 8px;
}
.muted {
font-size: 12px;
color: var(--wp-faint);
}
.typing {
display: flex;
gap: 5px;
padding: 8px 0;
padding: 10px 12px;
}
.typing-indicator span {
.typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--wp-css);
background: var(--wp-brand);
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
.typing span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
.typing span:nth-child(3) {
animation-delay: 0.4s;
}
+190 -182
View File
@@ -1,143 +1,82 @@
<template>
<div class="history-panel">
<n-flex
class="history-toolbar"
align="center"
justify="space-between"
:wrap="false"
>
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
<div class="toolbar">
<span class="muted">共 {{ items.length }} 轮 · 点一条就把那一版放回编辑器</span>
<n-tooltip>
<template #trigger>
<n-button
quaternary
circle
size="small"
:loading="loading"
aria-label="刷新历史对话"
@click="load(true)"
>
<template #icon>
<Icon icon="lucide:refresh-cw" />
</template>
<n-button quaternary size="small" :loading="loading" @click="load(true)">
<template #icon><Icon icon="lucide:refresh-cw" /></template>
</n-button>
</template>
刷新历史对话
刷新
</n-tooltip>
</n-flex>
</div>
<n-spin v-if="loading" class="state" />
<n-empty
v-else-if="!items.length"
class="state"
description="暂无历史对话"
/>
<n-scrollbar v-else class="history-scrollbar">
<n-flex vertical :size="12" class="history-list">
<n-card
v-for="(item, index) in items"
<n-spin v-if="loading && !items.length" class="state" />
<n-empty v-else-if="!items.length" class="state" description="还没有和 AI 对话过" />
<n-scrollbar v-else class="scroll">
<ol class="list">
<li
v-for="item in newestFirst"
:key="item.assistant_message_id"
class="history-card"
:class="{
'is-selected':
selectedAssistantMessageId === item.assistant_message_id,
}"
size="small"
:bordered="true"
hoverable
:embedded="selectedAssistantMessageId === item.assistant_message_id"
:content-style="{ padding: 0 }"
:class="['item', { on: selectedAssistantMessageId === item.assistant_message_id }]"
@click="selectItem(item)"
>
<n-flex
class="history-main"
align="center"
justify="space-between"
:wrap="false"
>
<n-flex align="center" :wrap="false" :size="6">
<n-tag
round
size="small"
:bordered="false"
:type="
selectedAssistantMessageId === item.assistant_message_id
? 'success'
: 'default'
"
>
#{{ index + 1 }}
</n-tag>
<n-tag
size="small"
:type="item.source === 'edit' ? 'warning' : item.source === 'manual' ? 'info' : 'success'"
>
{{ sourceLabel(item.source) }}
</n-tag>
</n-flex>
<n-flex align="center" :wrap="false" :size="4">
<n-tag
v-if="selectedAssistantMessageId === item.assistant_message_id"
size="small"
type="success"
:bordered="false"
>
正在预览
</n-tag>
<n-text depth="3">
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
</n-text>
<n-tooltip placement="top">
<template #trigger>
<n-button
quaternary
circle
size="small"
:loading="deletingId === item.assistant_message_id"
:disabled="deletingId !== null"
@click="deleteItem(item, $event)"
>
<template #icon>
<Icon icon="lucide:trash-2" />
</template>
</n-button>
</template>
删除这条历史对话
</n-tooltip>
</n-flex>
</n-flex>
<div
class="prompt-markdown markdown-body"
v-html="renderMarkdown(item.prompt)"
/>
<div class="thumbnail" aria-label="页面缩略图">
<div class="head">
<span class="num">{{ item.index }}</span>
<span :class="['src', `src-${item.source || 'conversation'}`]">
{{ sourceLabel(item.source) }}
</span>
<span v-if="item.prompt_level" class="level">{{ levelLabel(item.prompt_level) }}</span>
<span v-if="item.submission_id" class="submitted">交过</span>
<span class="time">{{ parseTime(item.created, "MM-DD HH:mm") }}</span>
<n-popconfirm @positive-click="deleteItem(item)">
<template #trigger>
<n-button
quaternary
size="tiny"
class="del"
:loading="deletingId === item.assistant_message_id"
:disabled="deletingId !== null"
@click.stop
>
<template #icon><Icon icon="lucide:trash-2" :width="14" /></template>
</n-button>
</template>
<template v-if="item.submission_id">
这一版交过,删掉这轮对话后交的那一份也会一起删掉(有人评过分的删不了)。
</template>
<template v-else>删掉这一轮对话和它生成的网页?</template>
</n-popconfirm>
</div>
<div class="q markdown-body" v-html="renderMarkdown(item.prompt)"></div>
<div class="thumb">
<iframe
v-if="item.hasPage"
title="页面缩略图"
loading="lazy"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
:sandbox="PREVIEW_SANDBOX"
tabindex="-1"
:srcdoc="item.previewDoc"
/>
<n-empty v-else size="small" description="未生成页面" />
></iframe>
<span v-else class="muted">这一轮没有生成网页</span>
</div>
</n-card>
</n-flex>
</li>
</ol>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui"
import { renderMarkdown } from "../../utils/markdown"
import { sourceLabel } from "../../utils/helper"
import { levelLabel, sourceLabel } from "../../utils/helper"
import { Prompt } from "../../api"
import type { PromptHistoryItem } from "../../utils/type"
import { parseTime } from "../../utils/helper"
import { buildPreviewDocument } from "../../utils/previewDocument"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
const props = defineProps<{
taskId: number
@@ -152,6 +91,8 @@ const emit = defineEmits<{
}>()
type HistoryViewItem = PromptHistoryItem & {
// 按时间先后的序号(从 1 开始)
index: number
hasPage: boolean
previewDoc: string
}
@@ -165,12 +106,16 @@ let pendingRefresh = false
const naiveMessage = useMessage()
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
// 最新的放最上面,序号还是按时间先后
const newestFirst = computed(() => [...items.value].reverse())
function toViewItem(item: PromptHistoryItem, i: number): HistoryViewItem {
const html = item.code_html ?? ""
const css = item.code_css ?? ""
const js = item.code_js ?? ""
return {
...item,
index: i + 1,
hasPage: !!(html.trim() || css.trim() || js.trim()),
previewDoc: buildPreviewDocument({
html,
@@ -181,15 +126,14 @@ function toViewItem(item: PromptHistoryItem): HistoryViewItem {
}
}
async function deleteItem(item: HistoryViewItem, e: Event) {
e.stopPropagation()
async function deleteItem(item: HistoryViewItem) {
if (deletingId.value !== null) return
deletingId.value = item.assistant_message_id
try {
await Prompt.deleteMessagePair(item.assistant_message_id)
items.value = items.value.filter(
(i) => i.assistant_message_id !== item.assistant_message_id,
)
items.value = items.value
.filter((i) => i.assistant_message_id !== item.assistant_message_id)
.map((i, idx) => ({ ...i, index: idx + 1 }))
if (selectedAssistantMessageId.value === item.assistant_message_id) {
selectedAssistantMessageId.value = null
}
@@ -225,7 +169,7 @@ async function load(force = true) {
try {
const taskChanged = loadedTaskId !== props.taskId
const data = await Prompt.listHistory(props.taskId)
items.value = data.map(toViewItem)
items.value = data.map((item, i) => toViewItem(item, i))
if (
taskChanged ||
!items.value.some(
@@ -280,100 +224,164 @@ onMounted(() => {
overflow: hidden;
}
.history-toolbar {
.toolbar {
height: 42px;
padding: 0 12px;
border-bottom: 1px solid var(--n-border-color, var(--wp-line));
flex-shrink: 0;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 14px;
border-bottom: 1px solid var(--wp-line);
}
.muted {
font-size: 12px;
color: var(--wp-muted);
}
.state {
margin: auto;
}
.history-scrollbar {
.scroll {
flex: 1;
min-height: 0;
}
.history-list {
padding: 12px;
}
.history-card {
cursor: pointer;
overflow: hidden;
}
.history-card.is-selected {
--n-color: var(--wp-brand-soft);
box-shadow: 0 10px 24px rgba(13, 122, 82, 0.14);
}
.history-card.is-selected .history-main {
background: var(--wp-brand-soft);
}
.history-main {
padding: 10px 12px;
transition: background-color 0.18s ease;
}
.prompt-markdown {
.list {
margin: 0;
padding: 0 12px 12px;
color: var(--wp-ink);
font-size: 14px;
line-height: 1.6;
word-break: break-word;
background: transparent;
padding: 12px;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
.item {
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
background: var(--wp-surface);
cursor: pointer;
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
.item:hover {
border-color: var(--wp-faint);
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
.item.on {
border-color: var(--wp-brand);
}
.head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 6px 4px 10px;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
word-break: break-word;
.num {
min-width: 18px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.thumbnail {
height: 160px;
border-top: 1px solid var(--n-border-color, var(--wp-line));
.src,
.level,
.submitted {
padding: 0 6px;
border-radius: 4px;
line-height: 18px;
white-space: nowrap;
}
.src {
color: var(--wp-css);
background: var(--wp-css-soft);
}
.src-edit {
color: var(--wp-js);
background: var(--wp-js-soft);
}
.src-manual {
color: var(--wp-html);
background: var(--wp-html-soft);
}
.level {
color: var(--wp-ink);
background: var(--wp-paper);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--wp-line);
}
.history-card.is-selected .thumbnail {
border-top-color: var(--wp-brand-soft);
.submitted {
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.thumbnail iframe {
width: 100%;
height: 100%;
.time {
margin-left: auto;
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.del {
color: var(--wp-faint);
}
.q {
max-height: 66px;
overflow: hidden;
padding: 0 10px 8px;
background: transparent;
color: var(--wp-ink);
font-size: 13px;
line-height: 1.6;
word-break: break-word;
-webkit-mask-image: linear-gradient(#000 60%, transparent);
mask-image: linear-gradient(#000 60%, transparent);
}
.q :deep(p),
.q :deep(ul),
.q :deep(ol),
.q :deep(pre),
.q :deep(blockquote) {
margin: 0 0 4px;
}
.q :deep(pre) {
padding: 6px 8px;
font-size: 12px;
}
.q :deep(code) {
white-space: pre-wrap;
}
.thumb {
position: relative;
height: 140px;
display: grid;
place-items: center;
border-top: 1px solid var(--wp-line);
background: var(--wp-paper);
overflow: hidden;
}
/* 按 2 倍大小渲染再缩小 */
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
border: 0;
background: var(--wp-surface);
transform: scale(0.5);
transform-origin: 0 0;
pointer-events: none;
}
</style>
+232 -62
View File
@@ -52,77 +52,130 @@
<!-- Streaming indicator -->
<div v-if="streaming" class="message assistant">
<div class="message-role">AI</div>
<div v-if="!streamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="message-content"
v-html="renderMarkdown(streamingContent)"
></div>
<div class="streaming-hint">AI 正在生成中…</div>
<template v-if="!streamingContent">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div class="streaming-hint">
AI 正在构思… 已 {{ elapsedText }}<template v-if="elapsed >= 20">,复杂的网页要想一两分钟</template>
</div>
</template>
<template v-else>
<div
v-if="streamParts.prose"
class="message-content"
v-html="renderMarkdown(streamParts.prose)"
></div>
<div v-if="streamParts.lines >= 0" class="code-progress">
<Icon icon="lucide:sparkles" :width="14" />
正在写网页… 已写 {{ streamParts.lines }} 行
</div>
<div v-else class="streaming-hint">AI 正在回复…</div>
</template>
</div>
<RatingCard v-if="rating" :active="streaming" />
</div>
<GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area">
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="描述你想要的网页效果..."
:disabled="streaming"
style="flex: 1; min-width: 0"
@keydown.enter.exact.prevent="send"
/>
<n-tooltip>
<template #trigger>
<n-button
secondary
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
style="flex-shrink: 0"
@click="startGuidance"
>
<template #icon>
<Icon icon="lucide:lightbulb" :width="16" />
</template>
帮我写清楚
</n-button>
</template>
AI 教练会问你几个问题,帮你把想要的效果描述清楚
</n-tooltip>
</n-flex>
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
停止
</n-button>
<n-button
v-else
type="primary"
:disabled="!input.trim() || !connected"
@click="send"
<!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
<div v-if="vagueHint" class="vague-hint">
<span>说清楚哪里不对,AI 更容易一次修好</span>
<n-flex :size="4" :wrap="false">
<n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
<n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
</n-flex>
</div>
<div v-if="problemOpen" class="problem">
<div class="problem-title">出问题了?说清楚这三件事</div>
<label>我做了什么</label>
<n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
<label>想看到什么</label>
<n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
<label>实际看到了什么</label>
<n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
<n-flex justify="end" :size="8" style="margin-top: 10px">
<n-button @click="problemOpen = false">取消</n-button>
<n-button
type="primary"
:disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
@click="sendProblem"
>
发给 AI
</n-button>
</n-flex>
</div>
<template v-else>
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
>
发送
</n-button>
</n-flex>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="用你自己的话说说想做成什么样…"
:disabled="streaming"
style="flex: 1; min-width: 0"
@keydown.enter.exact.prevent="send"
/>
<n-tooltip>
<template #trigger>
<n-button
secondary
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
style="flex-shrink: 0"
@click="startGuidance"
>
<template #icon>
<Icon icon="lucide:lightbulb" :width="16" />
</template>
帮我写清楚
</n-button>
</template>
AI 教练会问你几个问题,帮你把想要的效果描述清楚
</n-tooltip>
</n-flex>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<n-tooltip>
<template #trigger>
<n-button quaternary :disabled="!hasVersion || streaming" @click="openProblem">
<template #icon>
<Icon icon="lucide:bug" :width="16" />
</template>
出问题了
</n-button>
</template>
网页哪里不对?说清楚做了什么、想看到什么、实际看到了什么
</n-tooltip>
<n-flex align="center" :size="8">
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
停止
</n-button>
<n-button
v-else
type="primary"
:disabled="!input.trim() || !connected"
@click="send"
>
发送
</n-button>
</n-flex>
</n-flex>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed } from "vue"
import { ref, reactive, watch, nextTick, computed, onUnmounted } from "vue"
import { useStorage } from "@vueuse/core"
import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
@@ -133,6 +186,7 @@ import {
messages,
streaming,
streamingContent,
streamStartedAt,
connected,
sendPrompt,
stopPrompt,
@@ -221,6 +275,46 @@ async function deletePair(assistantMsgId: number) {
async function send() {
const text = input.value.trim()
if (!text || streaming.value) return
// 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
vagueHint.value = true
return
}
input.value = ""
await props.beforeSend?.()
sendPrompt(text, selectedModel.value)
}
// “出问题了”:做了什么、想看到什么、实际看到了什么,拼成一句发给 AI
const VAGUE =
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
const vagueHint = ref(false)
let skipVagueCheck = false
const problemOpen = ref(false)
const problem = reactive({ did: "", want: "", saw: "" })
watch(input, () => (vagueHint.value = false))
const hasVersion = computed(() => messages.value.some((m) => m.role === "assistant" && hasCode(m)))
function openProblem() {
vagueHint.value = false
problemOpen.value = true
}
function sendAnyway() {
skipVagueCheck = true
vagueHint.value = false
send()
}
async function sendProblem() {
const part = (s: string) => s.trim().replace(/[。,,.!!;;]+$/, "")
const did = part(problem.did).replace(/^我/, "")
const want = part(problem.want)
const text = `我${did},${want ? `想看到${want},` : ""}但实际${part(problem.saw)}。`
Object.assign(problem, { did: "", want: "", saw: "" })
problemOpen.value = false
input.value = ""
await props.beforeSend?.()
sendPrompt(text, selectedModel.value)
@@ -238,10 +332,47 @@ async function onGuidanceGenerate(finalPrompt: string) {
sendPrompt(finalPrompt, selectedModel.value)
}
function renderContent(msg: { role: string; content: string }): string {
return renderMarkdown(msg.content)
// 学生只关心成品:AI 回复里的代码块不在对话里展开(右边“代码”里能看),只说写了多少行。
// 按 ``` 切开,奇数段是代码;段数是偶数说明最后一个代码块还没写完
function splitCode(text: string) {
const parts = text.split("```")
const prose = parts.filter((_, i) => i % 2 === 0).join("\n\n").trim()
const code = parts.filter((_, i) => i % 2 === 1)
const lines = code.reduce((n, c) => n + c.split("\n").length - 1, 0)
return { prose, lines: code.length ? lines : -1 }
}
const streamParts = computed(() => splitCode(streamingContent.value))
function renderContent(msg: { role: string; content: string }): string {
if (msg.role !== "assistant") return renderMarkdown(msg.content)
const { prose, lines } = splitCode(msg.content)
const note = lines >= 0 ? `<p class="code-note">网页代码 ${lines} 行,在右边“代码”里看</p>` : ""
return renderMarkdown(prose) + note
}
// 等 AI 的计时:每秒刷新一次
const now = ref(Date.now())
let ticker: ReturnType<typeof setInterval> | undefined
watch(
streaming,
(on) => {
clearInterval(ticker)
now.value = Date.now()
if (on) ticker = setInterval(() => (now.value = Date.now()), 1000)
},
{ immediate: true },
)
onUnmounted(() => clearInterval(ticker))
const elapsed = computed(() =>
streamStartedAt.value ? Math.max(0, Math.floor((now.value - streamStartedAt.value) / 1000)) : 0,
)
const elapsedText = computed(() => {
const s = elapsed.value
return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`
})
watch([() => messages.value.length, streamingContent], () => {
nextTick(() => {
if (messagesRef.value) {
@@ -377,6 +508,20 @@ watch([() => messages.value.length, streamingContent], () => {
}
}
.code-progress {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
color: var(--wp-muted);
}
.message-content :deep(.code-note) {
font-size: 12px;
color: var(--wp-muted);
}
.streaming-hint {
font-size: 11px;
color: var(--wp-faint);
@@ -384,6 +529,31 @@ watch([() => messages.value.length, streamingContent], () => {
animation: pulse 1.5s ease-in-out infinite;
}
.vague-hint {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border-radius: 6px;
background: var(--wp-brand-soft);
font-size: 13px;
}
.problem-title {
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
}
.problem label {
display: block;
margin: 8px 0 4px;
font-size: 12px;
color: var(--wp-muted);
}
.input-area {
flex-shrink: 0;
padding: 12px;
+13 -75
View File
@@ -2,9 +2,6 @@
<n-flex align="center" justify="space-between" class="title">
<div>
<slot name="title"><n-text class="titleText">预览</n-text></slot>
<n-text v-if="!!submission.id" depth="3">
({{ submission.view_count || 0 }})
</n-text>
</div>
<n-flex>
<n-tooltip>
@@ -19,26 +16,6 @@
</n-tooltip>
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</n-button>
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
<n-button
quaternary
v-if="props.showCodeButton"
@click="emits('showCode')"
>
代码
</n-button>
<n-button quaternary v-if="props.submissionId" @click="copyLink">
链接
</n-button>
<n-flex v-if="!!submission.id" align="center">
<n-button quaternary @click="emits('showCode')">代码</n-button>
<n-popover v-if="submission.my_score === 0">
<template #trigger>
<n-button secondary type="primary">打分</n-button>
</template>
<n-rate :size="30" @update:value="updateScore" />
</n-popover>
</n-flex>
</n-flex>
</n-flex>
<div class="iframe-wrapper" :style="iframeWrapperStyle">
@@ -54,12 +31,7 @@
<script lang="ts" setup>
import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRouter } from "vue-router"
import { Submission } from "../../api"
import { submission } from "../../store/submission"
import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
import copy from "copy-text-to-clipboard"
import {
buildPreviewDocument,
PREVIEW_SANDBOX,
@@ -75,15 +47,11 @@ interface Props {
css: string
js: string
assetBaseUrl?: string
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
// 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
storageTaskId?: number
}
const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode", "clear"])
type Layout = "desktop" | "mobile" | "tablet"
const layouts: Layout[] = ["desktop", "mobile", "tablet"]
@@ -121,9 +89,6 @@ function cycleLayout() {
layoutIndex.value = (layoutIndex.value + 1) % layouts.length
}
const message = useMessage()
const router = useRouter()
const showDL = computed(() => props.html || props.css || props.js)
const previewContent = ref("")
@@ -162,48 +127,21 @@ function download() {
}
function open() {
if (props.submissionId) {
const data = router.resolve({
name: "submission",
params: { id: props.submissionId },
})
window.open(data.href, "_blank")
} else {
const newTab = window.open("about:blank", "_blank")
if (!newTab) return
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
const doc = newTab.document
doc.open()
doc.write(
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
)
doc.close()
const tabFrame = doc.querySelector("iframe")!
// 全屏页里的写入也存到同一个地方
listenPreviewStorage(newTab, () => tabFrame, storageScope)
tabFrame.srcdoc = getContent()
}
const newTab = window.open("about:blank", "_blank")
if (!newTab) return
// 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
const doc = newTab.document
doc.open()
doc.write(
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
)
doc.close()
const tabFrame = doc.querySelector("iframe")!
// 全屏页里的写入也存到同一个地方
listenPreviewStorage(newTab, () => tabFrame, storageScope)
tabFrame.srcdoc = getContent()
}
function clear() {
emits("clear")
}
function copyLink() {
copy(`${document.location.origin}/submission/${props.submissionId}`)
message.success("该提交的链接已复制")
}
async function updateScore(score: number) {
try {
const res = await Submission.updateScore(submission.value.id, score)
message.success(res.message)
submission.value.my_score = score
emits("afterScore")
} catch (err: any) {
message.error(err.response.data.detail)
}
}
watchDebounced(
() => [props.html, props.css, props.js, props.assetBaseUrl],
+1 -5
View File
@@ -121,11 +121,7 @@ const userMenu = [
async function clickUserMenu(key: string) {
if (key === "submissions") {
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
router.push({ name: "submissions" })
} else if (key === "logout") {
await logout()
// 留在需要登录或特定角色的页面上没有意义
+76 -4
View File
@@ -111,16 +111,32 @@
</n-dropdown>
</n-flex>
<div class="muted hint">P 键:加入 / 取消课上展示</div>
<n-popconfirm v-if="canDelete" @positive-click="remove">
<template #trigger>
<n-button size="small" quaternary type="error" class="delete">删除这一版</n-button>
</template>
删除后找不回来,评分和作品展里的这一版也会一起删掉;它对应的那轮 AI 对话也会删掉。
</n-popconfirm>
</section>
<section v-if="sub.reflection">
<h4>学生说</h4>
<div class="reflection">{{ sub.reflection }}</div>
</section>
<section>
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
<n-flex justify="space-between" align="center" class="chain-head">
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
<n-button v-if="chain.length" size="tiny" quaternary @click="showChain = true">
逐轮看网页
</n-button>
</n-flex>
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
<ol class="chain">
<li v-for="(r, i) in chain" :key="i">
<li v-for="(r, i) in chain" :key="i" :class="{ guide: r.source === 'guidance' }">
<n-flex :size="6" align="center">
<span class="src">{{ sourceLabel(r.source) }}</span>
<span v-if="r.prompt_level" class="level">L{{ r.prompt_level }}</span>
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
</n-flex>
<div class="q">{{ r.question }}</div>
</li>
@@ -129,6 +145,13 @@
</div>
</div>
</n-spin>
<ChainModal
v-if="sub"
v-model:show="showChain"
:submission-id="sub.id"
:title="`${sub.username} · ${sub.task_title}`"
:asset-base-url="`/media/tasks/${sub.task_type}/${sub.task_display}/`"
/>
</div>
</template>
<script lang="ts" setup>
@@ -137,7 +160,8 @@ import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import { parseTime, sourceLabel } from "../../utils/helper"
import { levelLabel, parseTime, sourceLabel } from "../../utils/helper"
import ChainModal from "../submissions/ChainModal.vue"
import type {
AwardManageOut,
FlagType,
@@ -155,6 +179,8 @@ const props = defineProps<{
canWiden?: boolean
// 作品墙里用:能不能切到上一个 / 下一个学生
nav?: { prev: boolean; next: boolean }
// 全部提交页(超管):能删除这一版
canDelete?: boolean
}>()
const emit = defineEmits<{
prev: []
@@ -163,6 +189,7 @@ const emit = defineEmits<{
"toggle-wide": []
// 让作品墙同步更新这张卡片
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
deleted: [id: string]
}>()
const message = useMessage()
@@ -172,6 +199,9 @@ const sub = ref<SubmissionAll | null>(null)
const versions = ref<SubmissionOut[]>([])
const chain = ref<PromptRound[]>([])
const rating = ref(false)
const showChain = ref(false)
// 引导对话不生成网页,不算轮数
const chainSteps = computed(() => chain.value.filter((r) => r.source !== "guidance").length)
let awardsCache: Promise<AwardManageOut[]> | null = null
const awards = ref<AwardManageOut[]>([])
@@ -255,6 +285,19 @@ async function addToAward(awardId: number) {
}
}
async function remove() {
if (!sub.value) return
const id = sub.value.id
try {
await Submission.delete(id)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "删除失败")
return
}
message.success("已删除")
emit("deleted", id)
}
function openFull() {
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
}
@@ -397,6 +440,18 @@ h4 {
font-size: 13px;
}
.chain-head {
margin-bottom: 8px;
}
.chain-head h4 {
margin: 0;
}
.delete {
margin-top: 8px;
}
.rates {
display: flex;
gap: 6px;
@@ -429,6 +484,17 @@ h4 {
flex-wrap: wrap;
}
/* 交的时候写的反思 */
.reflection {
padding: 6px 10px;
border-left: 3px solid var(--wp-brand);
background: var(--wp-brand-soft);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.chain {
margin: 0;
padding-left: 20px;
@@ -442,6 +508,12 @@ h4 {
color: var(--wp-muted);
}
/* 不是 list-item,就不占 ol 的序号 */
.chain .guide {
display: block;
color: var(--wp-muted);
}
.level {
font: 600 11px var(--wp-font-mono);
color: var(--wp-brand);
+475 -324
View File
@@ -2,232 +2,99 @@
<n-modal
:show="show"
preset="card"
style="width: 90vw; max-width: 1400px"
@update:show="$emit('update:show', $event)"
style="width: 92vw; max-width: 1280px"
:bordered="false"
@update:show="emit('update:show', $event)"
>
<template #header>
<n-flex justify="start" align="center">
<span>提示词</span>
<n-tooltip placement="bottom-start">
<template #trigger>
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" />
</template>
<div style="line-height: 2">
<div>
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
L1
</span>
— 记忆:复述或识别知识点
</div>
<div>
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
L2
</span>
— 理解:用自己的话解释概念
</div>
<div>
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
L3
</span>
— 应用:将知识用于新情境
</div>
<div>
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
L4
</span>
— 分析:拆解结构、找出规律
</div>
<div>
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
L5
</span>
— 评价:基于标准作出判断
</div>
<div>
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
L6
</span>
— 创造:整合信息产出新成果
</div>
</div>
</n-tooltip>
</n-flex>
<div class="head">
<b>提示词</b>
<span v-if="steps" class="muted">{{ steps }} 轮</span>
<span v-if="title" class="muted title">{{ title }}</span>
</div>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && rounds.length === 0"
description="暂无对话记录"
/>
<div
v-else
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 75vh;
"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid var(--wp-line);
display: flex;
flex-direction: column;
gap: 8px;
"
>
<div
v-for="(round, index) in rounds"
:key="index"
style="
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
"
@click="selectedRound = index"
>
<div
:style="{
flexShrink: 0,
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? 'var(--wp-css)' : 'var(--wp-css-soft)',
color: 'var(--wp-surface)',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div>
<div
:style="{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? 'var(--wp-css-soft)' : 'var(--wp-paper)',
border:
selectedRound === index
? '1px solid var(--wp-css)'
: '1px solid var(--wp-line)',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
}"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
"
>
<div
class="prompt-markdown markdown-body"
:class="{
'is-collapsed':
isPromptLong(round.question) && !isExpanded(index),
}"
v-html="renderMarkdown(round.question)"
></div>
<div
style="
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex-shrink: 0;
"
>
<span
v-if="round.source"
:style="{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? 'var(--wp-css-soft)' : 'var(--wp-line)',
color:
round.source === 'conversation' ? 'var(--wp-css)' : 'var(--wp-muted)',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
>{{ sourceLabel(round.source) }}</span
>
<span
v-if="round.prompt_level"
:style="{
fontSize: '11px',
fontWeight: 'bold',
color: levelColors[round.prompt_level],
}"
>L{{ round.prompt_level }}</span
>
<n-button
v-if="isPromptLong(round.question)"
text
size="tiny"
type="primary"
@click.stop="toggleExpanded(index)"
>
<Icon
:icon="
isExpanded(index)
? 'lucide:chevron-up'
: 'lucide:chevron-down'
"
:width="12"
/>
{{ isExpanded(index) ? "收起" : "展开" }}
</n-button>
<n-popconfirm
v-if="round.assistantMsgId && canDelete"
:show-icon="false"
@positive-click="deleteRound(index)"
>
<template #trigger>
<n-button
text
size="tiny"
type="error"
style="margin-left: 2px"
@click.stop
>
<Icon icon="lucide:trash-2" :width="12" />
</n-button>
</template>
确定删除这一轮?
</n-popconfirm>
</div>
</div>
</div>
<template #header-extra>
<n-popover trigger="click" placement="bottom-end">
<template #trigger>
<n-button size="small" quaternary style="margin-right: 8px">
<template #icon><Icon icon="lucide:info" /></template>
L1–L6 是什么
</n-button>
</template>
<div class="levels">
<div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
<div v-for="(l, n) in PROMPT_LEVELS" :key="n">
<b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: var(--wp-ink)">
第 {{ selectedRound + 1 }} 轮网页
</n-popover>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && !rounds.length"
description="这份提交没有关联的提示词"
style="padding: 80px 0"
/>
<div v-else class="body">
<div class="left">
<div v-if="showTrend" class="trend">
<span class="muted">层级</span>
<div class="bars">
<button
v-for="b in trend"
:key="b.i"
:class="['bar', { on: b.i === selected, none: !b.level }]"
:style="{ height: `${b.level ? 6 + b.level * 5 : 4}px` }"
:title="`第 ${nums[b.i]} 轮${b.level ? ' · ' + levelLabel(b.level) : ''}`"
@click="select(b.i)"
></button>
</div>
</div>
<ol class="rounds">
<li
v-for="(r, i) in rounds"
:key="i"
:class="['round', { on: i === selected, guide: isGuide(r) }]"
@click="isGuide(r) || (selected = i)"
>
<div class="round-head">
<span class="num">{{ nums[i] }}</span>
<span :class="['src', `src-${r.source || 'conversation'}`]">
{{ sourceLabel(r.source) }}
</span>
<span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
<span v-if="repeats[i]" class="repeat" title="和前面某一轮的提问一字不差">重复提问</span>
<span v-if="changes[i]" class="muted">{{ changes[i] }}</span>
<span v-if="i === rounds.length - 1" class="final">交的这一版</span>
<span v-else-if="isGuide(r)" class="muted no-code">问写提示词的建议,不生成网页</span>
<span v-else-if="!r.html" class="muted no-code">没有网页</span>
</div>
<div
:class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
v-html="renderMarkdown(r.question)"
></div>
<button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
{{ expanded.has(i) ? "收起" : "展开全部" }}
</button>
</li>
</ol>
</div>
<div class="stage">
<div class="stage-head">
<b>第 {{ nums[selected] }} 轮生成的网页</b>
<span class="muted">↑ ↓ 切换</span>
</div>
<div class="frame">
<iframe
v-if="doc"
:key="selected"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
></iframe>
<n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
@@ -235,157 +102,441 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { computed, nextTick, onUnmounted, ref, watch } from "vue"
import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown"
import { sourceLabel } from "../../utils/helper"
import { storageShim } from "../../utils/previewStorage"
import { Prompt, Submission } from "../../api"
import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
const message = useMessage()
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
const props = defineProps<{
show: boolean
submissionId: string
username?: string
// 例如“张三 · 个人名片”
title?: string
// 题目素材的地址,网页里的图片要用
assetBaseUrl?: string
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
fromShowcase?: boolean
}>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const emit = defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false)
const selectedRound = ref(0)
const expandedRounds = ref<Set<number>>(new Set())
type ChainRound = Omit<PromptRound, "source"> & {
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
const rounds = ref<PromptRound[]>([])
const selected = ref(0)
const expanded = ref(new Set<number>())
async function deleteRound(index: number) {
const round = rounds.value[index]
if (!round.assistantMsgId) return
try {
await Prompt.deleteMessagePair(round.assistantMsgId)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
await loadMessages()
if (selectedRound.value >= rounds.value.length) {
selectedRound.value = Math.max(0, rounds.value.length - 1)
}
}
const levelColors: Record<number, string> = {
1: "var(--wp-faint)",
2: "var(--wp-brand)",
3: "var(--wp-css)",
4: "#c47ab8",
5: "var(--wp-warn)",
6: "var(--wp-bad)",
}
const selectedPageHtml = computed(() => {
const round = rounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
// 引导对话(问怎么写提示词)夹在 AI 对话中间,不算轮数,也不能选中
const isGuide = (r: PromptRound) => r.source === "guidance"
const steps = computed(() => rounds.value.filter((r) => !isGuide(r)).length)
const nums = computed(() => {
let n = 0
return rounds.value.map((r) => (isGuide(r) ? "" : String(++n)))
})
function isPromptLong(text: string): boolean {
return text.length > 220 || text.split(/\r?\n/).length > 4
// 层级走势:每轮 AI 对话 / 外部 AI 一根柱子,两轮以上、有标过层级才显示
const trend = computed(() =>
rounds.value.flatMap((r, i) => (isGuide(r) ? [] : [{ i, level: r.prompt_level }])),
)
const showTrend = computed(() => trend.value.length > 1 && trend.value.some((b) => b.level))
// 和前面某一轮一字不差的提问(手改那一轮的正文都一样,不算)
const repeats = computed(() => {
const seen = new Set<string>()
return rounds.value.map((r) => {
if (isGuide(r) || r.source === "edit") return false
const key = r.question.replace(/\s+/g, " ").trim()
const dup = seen.has(key)
seen.add(key)
return dup
})
})
// 这一轮的代码比上一版改了多少行:只按行内容数,不管顺序
function codeLines(r: PromptRound) {
return [r.html, r.css, r.js]
.join("\n")
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
}
function isExpanded(index: number): boolean {
return expandedRounds.value.has(index)
}
function toggleExpanded(index: number) {
const next = new Set(expandedRounds.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
function lineDiff(before: string[], after: string[]) {
const left = new Map<string, number>()
for (const l of before) left.set(l, (left.get(l) ?? 0) + 1)
let added = 0
for (const l of after) {
const n = left.get(l) ?? 0
if (n) left.set(l, n - 1)
else added++
}
expandedRounds.value = next
const removed = before.length - (after.length - added)
return added || removed ? `+${added} −${removed} 行` : "代码没变"
}
async function loadMessages() {
if (!props.submissionId) return
const changes = computed(() => {
let prev: string[] | null = null
return rounds.value.map((r) => {
if (isGuide(r) || !r.html) return ""
const cur = codeLines(r)
const text = prev ? lineDiff(prev, cur) : ""
prev = cur
return text
})
})
const doc = computed(() => {
const r = rounds.value[selected.value]
if (!r?.html) return ""
return buildPreviewDocument({
html: r.html,
css: r.css ?? "",
js: r.js ?? "",
assetBaseUrl: props.assetBaseUrl,
})
})
function isLong(text: string) {
return text.length > 200 || text.split(/\r?\n/).length > 4
}
function toggle(i: number) {
const next = new Set(expanded.value)
if (!next.delete(i)) next.add(i)
expanded.value = next
}
let seq = 0
async function load() {
const s = ++seq
loading.value = true
rounds.value = []
selectedRound.value = 0
expandedRounds.value = new Set()
expanded.value = new Set()
try {
const data = await Submission.getPromptChain(props.submissionId)
rounds.value = data.map((round) => ({
...round,
source: round.source ?? null,
assistantMsgId: round.assistant_msg_id ?? null,
}))
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
const data = props.fromShowcase
? await Showcase.getPromptChain(props.submissionId)
: await Submission.getPromptChain(props.submissionId)
if (s !== seq) return
rounds.value = data
// 默认看交的那一版
selected.value = Math.max(0, data.length - 1)
await nextTick()
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
} catch {
rounds.value = []
if (s === seq) rounds.value = []
} finally {
loading.value = false
if (s === seq) loading.value = false
}
}
function select(i: number) {
selected.value = i
nextTick(() =>
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }),
)
}
// 在捕获阶段接管按键:弹框开着时,批改页的 Esc / J / K、批改面板的 1–5 / P 都不能触发
function onKey(e: KeyboardEvent) {
if (!props.show) return
e.stopPropagation()
if (e.key === "Escape") {
emit("update:show", false)
return
}
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0
if (!step || !rounds.value.length) return
e.preventDefault()
let i = selected.value + step
while (rounds.value[i] && isGuide(rounds.value[i]!)) i += step
if (rounds.value[i]) select(i)
}
watch(
() => [props.show, props.submissionId] as const,
([visible]) => {
if (visible) loadMessages()
if (visible) {
load()
window.addEventListener("keydown", onKey, true)
} else {
window.removeEventListener("keydown", onKey, true)
}
},
{ immediate: true },
)
onUnmounted(() => window.removeEventListener("keydown", onKey, true))
</script>
<style scoped>
.prompt-markdown {
flex: 1;
.head {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
}
.prompt-markdown.is-collapsed {
position: relative;
max-height: 126px;
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
.muted {
font-size: 12px;
font-weight: 400;
color: var(--wp-muted);
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
.levels {
font-size: 13px;
line-height: 1.9;
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
.levels-note {
color: var(--wp-muted);
}
.body {
display: grid;
grid-template-columns: minmax(300px, 2fr) 3fr;
gap: 16px;
height: 72vh;
}
.left {
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.trend {
display: flex;
align-items: flex-end;
gap: 10px;
padding: 0 6px 6px 2px;
border-bottom: 1px solid var(--wp-line);
}
.bars {
flex: 1;
height: 38px;
display: flex;
align-items: flex-end;
gap: 3px;
}
.bar {
flex: 1;
max-width: 22px;
padding: 0;
border: 0;
border-radius: 2px 2px 0 0;
background: var(--wp-faint);
opacity: 0.55;
cursor: pointer;
}
.bar.none {
background: var(--wp-line);
opacity: 1;
}
.bar:hover,
.bar.on {
opacity: 1;
}
.bar.on {
background: var(--wp-brand);
}
.rounds {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 6px 0 0;
list-style: none;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.round {
padding: 8px 10px;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-surface);
cursor: pointer;
}
.round:hover {
border-color: var(--wp-faint);
}
.round.on {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.round-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
.num {
min-width: 18px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.round.on .num {
color: var(--wp-brand);
}
.src,
.level,
.repeat,
.final {
padding: 0 6px;
border-radius: 4px;
line-height: 18px;
white-space: nowrap;
}
.src {
color: var(--wp-css);
background: var(--wp-css-soft);
}
.src-edit {
color: var(--wp-js);
background: var(--wp-js-soft);
}
.src-manual {
color: var(--wp-html);
background: var(--wp-html-soft);
}
.src-guidance {
color: var(--wp-muted);
background: var(--wp-paper);
}
.round.guide {
border-style: dashed;
background: transparent;
cursor: default;
}
.round.guide:hover {
border-color: var(--wp-line);
}
.round.guide .q {
color: var(--wp-muted);
}
.repeat {
color: var(--wp-warn);
background: var(--wp-warn-soft);
}
.level {
color: var(--wp-ink);
background: var(--wp-paper);
border: 1px solid var(--wp-line);
}
.final {
margin-left: auto;
color: var(--wp-brand);
font-weight: 600;
}
.no-code {
margin-left: auto;
}
.q {
background: transparent;
color: var(--wp-ink);
font-size: 13px;
line-height: 1.6;
word-break: break-word;
}
.q.clamp {
max-height: 84px;
overflow: hidden;
-webkit-mask-image: linear-gradient(#000 55%, transparent);
mask-image: linear-gradient(#000 55%, transparent);
}
.q :deep(p),
.q :deep(ul),
.q :deep(ol),
.q :deep(pre),
.q :deep(blockquote) {
margin: 0 0 6px;
}
.q :deep(:last-child) {
margin-bottom: 0;
}
.q :deep(pre) {
padding: 6px 8px;
font-size: 12px;
}
.q :deep(code) {
white-space: pre-wrap;
}
.more {
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
color: var(--wp-brand);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.stage {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
}
.frame {
flex: 1;
min-height: 0;
display: flex;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.frame iframe {
flex: 1;
border: 0;
}
</style>
@@ -1,42 +0,0 @@
<template>
<n-modal
preset="card"
:show="show"
style="max-width: 60%"
@update:show="$emit('update:show', $event)"
>
<template #header>
<n-flex align="center">
<span>前端代码</span>
<n-button tertiary @click="$emit('copy-to-editor')"
>复制到编辑框</n-button
>
</n-flex>
</template>
<n-tabs animated type="segment">
<n-tab-pane name="html" tab="html">
<n-code :code="html" language="html" word-wrap />
</n-tab-pane>
<n-tab-pane name="css" tab="css">
<n-code :code="css" language="css" word-wrap />
</n-tab-pane>
<n-tab-pane v-if="!!js" name="js" tab="js">
<n-code :code="js" language="js" word-wrap />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
defineProps<{
show: boolean
html: string
css: string
js: string
}>()
defineEmits<{
"update:show": [value: boolean]
"copy-to-editor": []
}>()
</script>
@@ -1,126 +0,0 @@
<template>
<n-spin v-if="loading" size="small" style="padding: 12px" />
<n-data-table
v-else-if="items"
:columns="subColumns"
:data="items"
size="small"
striped
:row-key="(r: SubmissionOut) => r.id"
:row-props="rowProps"
:row-class-name="rowClassName"
/>
</template>
<script setup lang="ts">
import { computed, h } from "vue"
import {
NButton,
NDataTable,
NPopconfirm,
NSpin,
type DataTableColumn,
} from "naive-ui"
import type { SubmissionOut } from "../../utils/type"
import { TASK_TYPE } from "../../utils/const"
import { parseTime } from "../../utils/helper"
import { user, roleSuper } from "../../store/user"
import { submission } from "../../store/submission"
const props = defineProps<{
row: SubmissionOut
items: SubmissionOut[] | undefined
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [row: SubmissionOut, parentId: string]
"show-chain": [submissionId: string, username: string]
}>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
function rowProps(r: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => emit("select", r.id),
}
}
function rowClassName(r: SubmissionOut) {
return submission.value.id === r.id ? "row-active" : ""
}
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
{
title: "时间",
key: "created",
width: 160,
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "得分",
key: "score",
width: 80,
render: (r) => {
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
],
)
},
},
{
title: "操作",
key: "actions",
width: isChallenge.value ? 110 : 60,
render: (r: SubmissionOut) =>
h("div", { style: { display: "flex", gap: "8px" } }, [
...(isChallenge.value
? [
h(
NButton,
{
text: true,
type: "primary",
onClick: (e: Event) => {
e.stopPropagation()
emit("show-chain", r.id, r.username)
},
},
() => "查看",
),
]
: []),
...(r.username === user.username || roleSuper.value
? [
h(
NPopconfirm,
{ onPositiveClick: () => emit("delete", r, props.row.id) },
{
trigger: () =>
h(
NButton,
{
text: true,
type: "error",
size: "small",
onClick: (e: Event) => e.stopPropagation(),
},
() => "删除",
),
default: () => "确定删除这次提交?",
},
),
]
: []),
]),
} as DataTableColumn<SubmissionOut>,
])
</script>
@@ -1,37 +0,0 @@
<template>
<n-flex>
<n-tag
size="small"
:bordered="false"
type="primary"
v-if="props.submission.task_type === 'tutorial'"
>
教程
</n-tag>
<n-tag
:bordered="false"
size="small"
type="error"
v-if="props.submission.task_type === 'challenge'"
>
挑战
</n-tag>
<n-button text @click="open">{{ props.submission.task_title }}</n-button>
</n-flex>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router"
import type { SubmissionOut } from "../../utils/type"
import { goHome } from "../../utils/helper"
interface Props {
submission: SubmissionOut
}
const props = defineProps<Props>()
const router = useRouter()
function open() {
goHome(router, props.submission.task_type, props.submission.task_display)
}
</script>
+174 -98
View File
@@ -1,78 +1,57 @@
<template>
<n-flex vertical>
<n-button size="small" @click="showUpload = true">图片素材</n-button>
<n-flex v-if="assets.length" wrap>
<n-card
v-for="asset in assets"
:key="asset.name"
size="small"
style="width: 120px"
>
<template #cover>
<n-image
:src="asset.url"
style="width: 100%; height: 100px; object-fit: contain"
/>
</template>
<n-flex align="center" justify="space-between">
<n-text style="font-size: 12px; word-break: break-all">
{{ asset.name }}
</n-text>
<n-button
size="tiny"
quaternary
type="error"
@click="remove(asset.name)"
>
<template #icon>
<Icon icon="material-symbols:close-rounded" />
</template>
</n-button>
</n-flex>
</n-card>
</n-flex>
<n-button @click="show = true">
图片素材{{ assets.length ? `(${assets.length})` : "" }}
</n-button>
<n-modal
v-model:show="showUpload"
preset="card"
title="上传素材"
style="width: 400px"
<n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
<p class="hint">
题目和学生的网页里直接写文件名就能用,例如
<code>&lt;img src="{{ assets[0]?.name ?? "1.png" }}"&gt;</code>。
上传同名文件会替换原来的。
</p>
<label
:class="['drop', { over: dragging }]"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<n-form>
<n-form-item label="文件名(如 1.png)">
<n-input v-model:value="uploadName" placeholder="1.png" />
</n-form-item>
<n-form-item label="选择文件">
<n-upload :max="1" @change="onFileChange" :default-upload="false">
<n-button>选择文件</n-button>
</n-upload>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="showUpload = false">取消</n-button>
<n-button
type="primary"
:disabled="!uploadName || !uploadFile"
:loading="uploading"
@click="upload"
>
上传
</n-button>
</n-flex>
</template>
</n-modal>
</n-flex>
<input type="file" accept="image/*" multiple hidden @change="onPick" />
<Icon icon="lucide:image" :width="22" />
<span v-if="uploading">正在上传…</span>
<span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
</label>
<div v-if="assets.length" class="grid">
<div v-for="a in assets" :key="a.name" class="asset">
<div class="img">
<img :src="a.url" :alt="a.name" loading="lazy" />
</div>
<div class="name" :title="a.name">{{ a.name }}</div>
<div class="tools">
<n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
<n-popconfirm @positive-click="remove(a.name)">
<template #trigger>
<n-button size="tiny" quaternary type="error">删除</n-button>
</template>
删除 {{ a.name }}?用到它的网页里图片会显示不出来。
</n-popconfirm>
</div>
</div>
</div>
<n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
</n-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui"
import type { UploadFileInfo } from "naive-ui"
import copy from "copy-text-to-clipboard"
import { Icon } from "../../icons"
import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type"
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
const props = defineProps<{
taskType: "challenge" | "tutorial"
display: number
@@ -80,10 +59,9 @@ const props = defineProps<{
const message = useMessage()
const assets = ref<TaskAsset[]>([])
const showUpload = ref(false)
const uploadName = ref("")
const uploadFile = ref<File | null>(null)
const show = ref(false)
const uploading = ref(false)
const dragging = ref(false)
async function load() {
if (!props.display) return
@@ -97,40 +75,56 @@ async function load() {
}
}
function onFileChange({ file }: { file: UploadFileInfo }) {
if (file.status === "pending") {
uploadFile.value = file.file ?? null
}
// 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
function cleanName(name: string) {
return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
}
async function upload() {
if (!uploadName.value || !uploadFile.value) return
async function uploadAll(files: File[]) {
const images = files.filter((f) => f.type.startsWith("image/"))
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
if (!images.length) return
uploading.value = true
try {
const asset =
props.taskType === "challenge"
? await TaskAssets.uploadChallenge(
props.display,
uploadName.value,
uploadFile.value,
)
: await TaskAssets.uploadTutorial(
props.display,
uploadName.value,
uploadFile.value,
)
const idx = assets.value.findIndex((a) => a.name === asset.name)
if (idx >= 0) assets.value[idx] = asset
else assets.value.push(asset)
message.success(`${asset.name} 上传成功`)
showUpload.value = false
uploadName.value = ""
uploadFile.value = null
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "上传失败")
} finally {
uploading.value = false
let done = 0
for (const file of images) {
const name = cleanName(file.name)
if (!name || name.startsWith(".")) {
message.error(`文件名“${file.name}”不能用,请改个名字再传`)
continue
}
try {
const asset =
props.taskType === "challenge"
? await TaskAssets.uploadChallenge(props.display, name, file)
: await TaskAssets.uploadTutorial(props.display, name, file)
const i = assets.value.findIndex((a) => a.name === asset.name)
// 同名替换后浏览器可能还用着旧图的缓存
const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
if (i >= 0) assets.value[i] = fresh
else assets.value.push(fresh)
done++
} catch (err: any) {
message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
}
}
uploading.value = false
if (done) message.success(`上传了 ${done} 张`)
}
function onPick(e: Event) {
const input = e.target as HTMLInputElement
uploadAll([...(input.files ?? [])])
input.value = ""
}
function onDrop(e: DragEvent) {
dragging.value = false
uploadAll([...(e.dataTransfer?.files ?? [])])
}
function copyName(name: string) {
copy(name)
message.success(`已复制 ${name}`)
}
async function remove(name: string) {
@@ -139,7 +133,7 @@ async function remove(name: string) {
? await TaskAssets.deleteChallenge(props.display, name)
: await TaskAssets.deleteTutorial(props.display, name)
assets.value = assets.value.filter((a) => a.name !== name)
message.success("删除成功")
message.success("已删除")
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "删除失败")
}
@@ -147,3 +141,85 @@ async function remove(name: string) {
watch(() => props.display, load, { immediate: true })
</script>
<style scoped>
.hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--wp-muted);
}
.hint code {
padding: 1px 5px;
border-radius: 4px;
font: 12px var(--wp-font-mono);
color: var(--wp-ink);
background: var(--wp-paper);
}
.drop {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px;
border: 1px dashed var(--wp-line);
border-radius: 8px;
font-size: 13px;
color: var(--wp-muted);
background: var(--wp-paper);
cursor: pointer;
}
.drop:hover,
.drop.over {
border-color: var(--wp-brand);
color: var(--wp-brand);
}
.grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
max-height: 50vh;
overflow-y: auto;
}
.asset {
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.img {
height: 100px;
display: grid;
place-items: center;
overflow: hidden;
border-bottom: 1px solid var(--wp-line);
/* 透明图片放在浅色底上才看得出边界 */
background: var(--wp-paper);
}
.img img {
display: block;
max-width: 100%;
max-height: 100px;
}
.name {
padding: 6px 8px 0;
font: 12px var(--wp-font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tools {
display: flex;
justify-content: space-between;
padding: 2px 4px 4px;
}
</style>
+77 -7
View File
@@ -130,11 +130,39 @@
</div>
<div v-else class="empty">还没有打过分的作品</div>
</section>
<!-- 怎么问的 AI -->
<section class="span">
<h4>
怎么问的 AI
<span class="count">每人最新一版 · {{ stats.prompts.with_chain }} 份有提示词记录</span>
</h4>
<div v-if="stats.prompts.with_chain" class="prompt">
<div class="facts">
<div><b>{{ stats.prompts.avg_rounds }}</b>平均几轮</div>
<div><b>{{ pct(stats.prompts.one_round, stats.prompts.with_chain) }}</b>一轮就交</div>
<div><b>{{ pct(stats.prompts.external, stats.prompts.with_chain) }}</b>最后一版用外部 AI</div>
<div><b>{{ pct(stats.prompts.used_guidance, stats.prompts.with_chain) }}</b>问过怎么写提示词</div>
</div>
<div class="hist">
<template v-for="(n, i) in stats.prompts.levels" :key="i">
<span class="hist-label">{{ levelLabel(i + 1) }}</span>
<span class="hist-bar">
<span :style="{ width: pct(n, maxLevel) }"></span>
</span>
<span class="hist-value">{{ n }}</span>
</template>
</div>
</div>
<div v-else class="empty">
{{ stats.submitted_count ? "都是直接交的代码,没有提示词记录" : "还没有人交" }}
</div>
</section>
</div>
</div>
</n-spin>
<template #footer>
<template v-if="!inReview" #footer>
<n-flex justify="end">
<n-button type="primary" icon-placement="right" @click="openReview">
去批改{{ isAll ? "" : " " + classname + " 班" }}
@@ -150,11 +178,23 @@ import { computed, ref, watch } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "../../icons"
import { Submission } from "../../api"
import { levelLabel } from "../../utils/helper"
import type { TaskStatsOut, TopScoredItem, UserTag } from "../../utils/type"
// 老师下课前看的这道题的总结:默认看批改页正在看的班
const props = defineProps<{ taskId: number; show: boolean; title?: string }>()
defineEmits<{ (e: "update:show", v: boolean): void }>()
const props = defineProps<{
taskId: number
show: boolean
title?: string
// 在批改页里打开:不显示“去批改”,点高分作品直接在批改页里打开
inReview?: boolean
// 批改页正在看的班级(“__all__” 是全部班级);不传就用记住的
initialClass?: string | null
}>()
const emit = defineEmits<{
"update:show": [v: boolean]
open: [submissionId: string, classname: string]
}>()
// 和批改页共用“当前班级”
const ALL = "__all__"
@@ -189,6 +229,7 @@ const buckets = computed(() => {
]
})
const maxBucket = computed(() => Math.max(...buckets.value.map((b) => b.value)))
const maxLevel = computed(() => Math.max(0, ...(stats.value?.prompts.levels ?? [])))
const unsubmittedGroups = computed(() => byClass(stats.value?.unsubmitted_users ?? []))
const unratedGroups = computed(() => byClass(stats.value?.unrated_users ?? []))
@@ -237,11 +278,11 @@ watch(
() => props.show,
async (open) => {
if (!open || !props.taskId) return
let saved: string | null = props.initialClass ?? null
try {
classname.value = localStorage.getItem(CLASS_KEY) || ALL
} catch {
classname.value = ALL
}
saved ||= localStorage.getItem(CLASS_KEY)
} catch {}
classname.value = saved || ALL
await load()
// 记住的班级已经没有学生了
if (!isAll.value && !stats.value?.classes.includes(classname.value)) {
@@ -257,6 +298,10 @@ function openInNewTab(query: Record<string, string>) {
}
function openSubmission(item: TopScoredItem) {
if (props.inReview) {
emit("open", item.submission_id, item.classname)
return
}
openInNewTab({
class: isAll.value ? item.classname : classname.value,
task: String(props.taskId),
@@ -525,4 +570,29 @@ h4 {
.go {
color: var(--wp-faint);
}
.span {
grid-column: 1 / -1;
}
.prompt {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
align-content: start;
font-size: 12px;
color: var(--wp-muted);
}
.facts b {
display: block;
font: 600 18px/1.3 var(--wp-font-mono);
color: var(--wp-ink);
}
</style>
@@ -5,12 +5,16 @@
title="粘贴外部 AI 生成的代码"
style="width: 640px; max-width: 94vw"
>
<n-form-item label="你在外部 AI 里用的提示词" :show-feedback="false">
<n-form-item
label="你在外部 AI 里说了什么"
:validation-status="promptTooShort ? 'warning' : undefined"
:feedback="promptTooShort ? `再写具体一点:你让 AI 做了什么、改了什么(至少 ${MIN_PROMPT} 个字)` : ''"
>
<n-input
v-model:value="prompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里"
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
/>
</n-form-item>
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
@@ -30,7 +34,7 @@
<n-button @click="show = false">取消</n-button>
<n-button
type="primary"
:disabled="!prompt.trim() || !raw.trim()"
:disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
:loading="saving"
@click="save"
>
@@ -41,7 +45,7 @@
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { computed, ref } from "vue"
import { splitHtml, type Code } from "../../utils/code"
const show = defineModel<boolean>("show", { required: true })
@@ -50,6 +54,12 @@ const props = defineProps<{
}>()
const prompt = ref("")
// 以前四分之一的外部提示词只有“1”“倒计时”这样几个字,看不出学生想了什么
const MIN_PROMPT = 8
const promptTooShort = computed(() => {
const n = [...prompt.value.trim()].length
return n > 0 && n < MIN_PROMPT
})
const raw = ref("")
const saving = ref(false)
@@ -0,0 +1,67 @@
<template>
<n-modal
v-model:show="show"
preset="card"
:title="title"
style="width: 520px; max-width: 94vw"
:mask-closable="!saving"
>
<div class="question">
{{ hasEarlier ? "比起第一版,这一版你最满意的改动是什么?为什么这样改?" : "这一版你最满意的地方是什么?为什么?" }}
</div>
<n-input
v-model:value="reflection"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:maxlength="500"
placeholder="比如:我把按钮从五个减到三个,因为按钮太多了看不出先点哪个"
/>
<n-text depth="3" class="hint">可以不写,直接交。写了的话,老师批改时能看到。</n-text>
<template #footer>
<n-flex justify="end">
<n-button :disabled="saving" @click="show = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">交</n-button>
</n-flex>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from "vue"
// 交作品前问一句反思:最满意的改动和理由。不拦着交,只是让学生回头看一眼自己改了什么
const show = defineModel<boolean>("show", { required: true })
const props = defineProps<{
title: string
// 之前还有别的版本,就问“比起第一版”
hasEarlier: boolean
onSubmit: (reflection: string) => Promise<void>
}>()
const reflection = ref("")
const saving = ref(false)
async function save() {
saving.value = true
try {
await props.onSubmit(reflection.value.trim())
reflection.value = ""
show.value = false
} finally {
saving.value = false
}
}
</script>
<style scoped>
.question {
margin-bottom: 10px;
font-size: 14px;
line-height: 1.6;
color: var(--wp-ink);
}
.hint {
display: block;
margin-top: 8px;
font-size: 12px;
}
</style>
+39 -6
View File
@@ -103,6 +103,12 @@
</template>
</n-split>
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
<SubmitModal
v-model:show="showSubmit"
:title="submitTitle"
:has-earlier="submitHasEarlier"
:on-submit="submitChallenge"
/>
<n-drawer v-model:show="showHistory" :width="420" placement="right">
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
<PromptHistoryPanel
@@ -128,6 +134,7 @@ import Preview from "../editor/Preview.vue"
import CodeTabs from "./CodeTabs.vue"
import VersionBar, { type VersionItem } from "./VersionBar.vue"
import PasteCodeModal from "./PasteCodeModal.vue"
import SubmitModal from "./SubmitModal.vue"
import { Prompt, Submission } from "../../api"
import { useTaskCode } from "../../composables/useTaskCode"
import {
@@ -174,6 +181,7 @@ const selectedId = ref<number | null>(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
}
+2
View File
@@ -4,6 +4,7 @@ import {
ArrowRight,
Award,
BookOpen,
Bug,
ChartNoAxesColumn,
Check,
ChevronDown,
@@ -49,6 +50,7 @@ const registry: Record<string, Component> = {
"arrow-right": ArrowRight,
award: Award,
"book-open": BookOpen,
bug: Bug,
"bar-chart-2": ChartNoAxesColumn,
check: Check,
"chevron-down": ChevronDown,
+198 -83
View File
@@ -1,59 +1,48 @@
<template>
<main class="creation-home">
<n-flex
justify="space-between"
align="flex-end"
style="margin-bottom: 32px"
>
<header class="head">
<div>
<n-h2 style="margin: 0">自由创作</n-h2>
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
<h2>自由创作</h2>
<p class="sub">自己定一个主题,和 AI 一起把它做出来。不用交,也不打分</p>
</div>
<n-button type="primary" @click="openCreate">
<template #icon>
<Icon icon="lucide:plus" :width="16" />
</template>
<n-button type="primary" @click="openCreate()">
<template #icon><Icon icon="lucide:plus" /></template>
新建主题
</n-button>
</n-flex>
</header>
<n-spin :show="loading">
<n-empty
v-if="!loading && !creations.length"
description="还没有创作,点右上角新建一个主题吧"
style="margin-top: 72px"
/>
<div v-else class="card-grid">
<n-card
v-for="item in creations"
:key="item.display"
class="creation-card"
hoverable
@click="open(item)"
>
<template #header>
<n-ellipsis style="max-width: 100%">{{ item.title }}</n-ellipsis>
</template>
<template #header-extra>
<n-flex :size="4" align="center">
<n-tag v-if="item.has_code" type="success" size="small">
已有作品
</n-tag>
<n-button text @click.stop="remove(item)">
<Icon icon="lucide:trash-2" :width="15" />
</n-button>
</n-flex>
</template>
<n-ellipsis :line-clamp="2" class="creation-desc">
{{ item.content || "还没写主题描述" }}
</n-ellipsis>
<template #footer>
<n-text depth="3" style="font-size: 12px">
更新于 {{ formatTime(item.modified) }}
</n-text>
</template>
</n-card>
<div v-if="!loading && !creations.length" class="empty">
<p>还没有创作。不知道做什么?从这些开始:</p>
<div class="ideas">
<button v-for="idea in IDEAS" :key="idea.title" class="idea" @click="openCreate(idea)">
<b>{{ idea.title }}</b>
<span>{{ idea.content }}</span>
</button>
</div>
</div>
<ol v-else class="list">
<li v-for="item in creations" :key="item.display">
<router-link class="row" :to="{ name: 'creation', params: { display: item.display } }">
<span class="main">
<span class="title">{{ item.title }}</span>
<span class="desc">{{ item.content || "还没写主题描述" }}</span>
</span>
<span :class="['state', { done: item.has_code }]">
{{ item.has_code ? "已有作品" : "还没开始" }}
</span>
<span class="time">{{ formatTime(item.modified) }}</span>
<n-popconfirm @positive-click="remove(item)">
<template #trigger>
<n-button text class="del" title="删除" @click.prevent.stop>
<Icon icon="lucide:trash-2" :width="15" />
</n-button>
</template>
删除「{{ item.title }}」?和它相关的对话也会一起删掉。
</n-popconfirm>
</router-link>
</li>
</ol>
</n-spin>
<n-modal
@@ -65,6 +54,7 @@
<n-form>
<n-form-item label="主题名称">
<n-input
ref="titleRef"
v-model:value="form.title"
placeholder="例如:我的番茄钟"
maxlength="100"
@@ -98,22 +88,30 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue"
import { nextTick, onMounted, reactive, ref } from "vue"
import { useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui"
import { useMessage } from "naive-ui"
import { Icon } from "../icons"
import { Creation } from "../api"
import type { CreationSlim } from "../utils/type"
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const creating = ref(false)
const showCreate = ref(false)
const creations = ref<CreationSlim[]>([])
const form = reactive({ title: "", content: "" })
const titleRef = ref<{ focus: () => void } | null>(null)
// 一个创作都没有时给几个主题点子,点了带进新建框
const IDEAS = [
{ title: "番茄钟", content: "25 分钟倒计时,能开始、暂停、重置,时间到了提醒我休息" },
{ title: "我的个人主页", content: "头像、自我介绍、爱好,再放几张喜欢的图片" },
{ title: "点击小游戏", content: "方块随机出现,点中加分,30 秒后显示得分" },
{ title: "今日待办", content: "能添加、勾选完成、删除待办,刷新后还在" },
]
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
@@ -124,14 +122,11 @@ function formatTime(value: string) {
})
}
function open(item: CreationSlim) {
router.push({ name: "creation", params: { display: item.display } })
}
function openCreate() {
form.title = ""
form.content = ""
function openCreate(idea?: { title: string; content: string }) {
form.title = idea?.title ?? ""
form.content = idea?.content ?? ""
showCreate.value = true
nextTick(() => titleRef.value?.focus())
}
async function submit() {
@@ -149,24 +144,14 @@ async function submit() {
}
}
function remove(item: CreationSlim) {
dialog.warning({
title: "删除主题",
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
try {
await Creation.remove(item.display)
creations.value = creations.value.filter(
(c) => c.display !== item.display,
)
message.success("已删除")
} catch {
message.error("删除失败,请重试")
}
},
})
async function remove(item: CreationSlim) {
try {
await Creation.remove(item.display)
creations.value = creations.value.filter((c) => c.display !== item.display)
message.success("已删除")
} catch {
message.error("删除失败,请重试")
}
}
onMounted(async () => {
@@ -180,23 +165,153 @@ onMounted(async () => {
<style scoped>
.creation-home {
max-width: 1180px;
max-width: 880px;
margin: 0 auto;
padding: 32px 20px 48px;
padding: 28px 20px 48px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.creation-card {
h2 {
margin: 0;
font-size: 22px;
}
.sub {
margin: 4px 0 0;
color: var(--wp-muted);
font-size: 13px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 10px;
background: var(--wp-surface);
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--wp-line);
}
.row {
display: grid;
grid-template-columns: 1fr auto 96px 24px;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: var(--wp-ink);
text-decoration: none;
}
.row:hover {
background: var(--wp-paper);
}
.row:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: -2px;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desc {
margin-top: 2px;
font-size: 12px;
color: var(--wp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.state {
font-size: 12px;
color: var(--wp-faint);
}
.state.done {
color: var(--wp-brand);
}
.time {
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
text-align: right;
}
.del {
color: var(--wp-faint);
}
.del:hover {
color: var(--wp-bad);
}
.empty {
padding: 28px 24px;
border: 1px dashed var(--wp-line);
border-radius: 10px;
background: var(--wp-surface);
}
.empty p {
margin: 0 0 14px;
font-size: 14px;
color: var(--wp-muted);
}
.ideas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 10px;
}
.idea {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-paper);
color: var(--wp-ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.creation-desc {
font-size: 13px;
.idea:hover {
border-color: var(--wp-brand);
}
.idea b {
font-size: 14px;
}
.idea span {
font-size: 12px;
line-height: 1.6;
color: var(--wp-muted);
}
</style>
+379 -247
View File
@@ -1,95 +1,116 @@
<template>
<n-flex
vertical
:size="12"
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
>
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
<n-flex align="center" :size="8" wrap style="min-width: 0;">
<n-select
v-model:value="query.classname"
class="class-select"
:options="classOptions"
placeholder="班级"
:loading="classesLoading"
/>
<n-select
v-model:value="query.task_type"
class="type-select"
:options="taskTypeOptions"
/>
<n-input
v-model:value="query.username"
class="search-input"
clearable
placeholder="学生搜索"
/>
<n-switch v-model:value="query.include_all_tasks">
<template #checked>全部有提交任务</template>
<template #unchecked>只看计入任务</template>
</n-switch>
</n-flex>
<n-flex align="center" :size="8">
<n-button
secondary
title="刷新"
:disabled="!query.classname"
:loading="loading"
@click="loadGradebook"
>
<template #icon>
<Icon icon="lucide:refresh-cw" :width="15" />
</template>
</n-button>
<n-button
type="primary"
secondary
:disabled="!query.classname || !gradebook"
:loading="exporting"
@click="exportCsv"
>
<template #icon>
<Icon icon="lucide:download" :width="15" />
</template>
导出 CSV
</n-button>
</n-flex>
</n-flex>
<div class="gradebook">
<div class="toolbar">
<n-select
v-model:value="query.classname"
class="class-select"
:options="classOptions"
placeholder="班级"
size="small"
:loading="classesLoading"
/>
<n-radio-group v-model:value="query.task_type" size="small">
<n-radio-button value="">全部</n-radio-button>
<n-radio-button value="tutorial">教程</n-radio-button>
<n-radio-button value="challenge">挑战</n-radio-button>
</n-radio-group>
<n-input
v-model:value="query.username"
class="search-input"
size="small"
clearable
placeholder="搜索学生"
>
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
</n-input>
<n-checkbox v-model:checked="query.include_all_tasks" size="small">
显示不计入的任务
</n-checkbox>
<span class="spacer"></span>
<span v-if="gradebook" class="summary">
{{ gradebook.student_count }} 人 · 计入 {{ gradebook.included_task_count }} 个任务
</span>
<n-popover trigger="click" placement="bottom-end" style="max-width: 340px">
<template #trigger>
<n-button size="small" quaternary>
<template #icon><Icon icon="lucide:info" /></template>
怎么算的
</n-button>
</template>
<div class="rules">
<p>
<b>计入的任务</b>:班里至少一半人交过的任务才计入(现在是
{{ gradebook?.coverage_threshold_count ?? "—" }} 人),灰色的列不计入。
</p>
<p><b>每格分数</b>:这个学生在这个任务里分数最高的一次提交,点开可以批改。</p>
<p>
<b>总分 / 平时等级</b>:计入任务的分数相加后排名,A 30%、B 40%、C 20%、D 5%、E 5%。
</p>
<p>
<b>考核</b>:计入任务里最高三次的平均分另外排名,A 10%、B 20%、C 40%、D 20%、E 10%;
分数在等级的分数段内按名次铺开(A 92–99、B 84–91、C 76–83、D 68–75、E 60–67)。
</p>
</div>
</n-popover>
<n-tooltip>
<template #trigger>
<n-button
size="small"
quaternary
:disabled="!query.classname"
:loading="loading"
@click="loadGradebook"
>
<template #icon><Icon icon="lucide:refresh-cw" /></template>
</n-button>
</template>
刷新
</n-tooltip>
<n-button
size="small"
secondary
type="primary"
:disabled="!gradebook"
:loading="exporting"
@click="exportCsv"
>
<template #icon><Icon icon="lucide:download" /></template>
导出 CSV
</n-button>
</div>
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
{{ loadError }}
</n-alert>
<n-flex v-if="gradebook" align="center" :size="8" style="flex-shrink: 0;">
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
<n-tag size="small" type="success">
计入 {{ gradebook.included_task_count }}
</n-tag>
<n-tag size="small">
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
</n-tag>
</n-flex>
<n-data-table
class="table gradebook-table"
size="small"
striped
flex-height
:single-line="false"
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(row: GradebookRow) => row.user_id"
:scroll-x="scrollX"
style="flex: 1; min-height: 0;"
/>
</n-flex>
</div>
<n-drawer
:show="!!reviewId"
width="min(1180px, 92vw)"
placement="right"
@update:show="(v: boolean) => !v && (reviewId = null)"
>
<ReviewPanel v-if="reviewId" :submission-id="reviewId" wide @close="reviewId = null" />
<ReviewPanel
v-if="reviewId"
:submission-id="reviewId"
wide
@close="reviewId = null"
@changed="loadGradebook"
/>
</n-drawer>
</template>
@@ -98,13 +119,7 @@ import ReviewPanel from "../components/review/ReviewPanel.vue"
import { computed, h, onMounted, reactive, ref, watch } from "vue"
import { Icon } from "../icons"
import { watchDebounced } from "@vueuse/core"
import {
NButton,
NTag,
NText,
useMessage,
type DataTableColumn,
} from "naive-ui"
import { useMessage, type DataTableColumn } from "naive-ui"
import { Account, Gradebook } from "../api"
import { displayGradebookStudentName } from "../utils/gradebook"
import type {
@@ -113,9 +128,11 @@ import type {
GradebookQuery,
GradebookRow,
GradebookTask,
GradebookTaskType,
} from "../utils/type"
// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
const CLASS_KEY = "review-class"
const message = useMessage()
const classesLoading = ref(false)
const loading = ref(false)
@@ -131,28 +148,19 @@ const query = reactive<GradebookQuery>({
include_all_tasks: false,
})
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
{ label: "全部", value: "" },
{ label: "教程", value: "tutorial" },
{ label: "挑战", value: "challenge" },
]
const classOptions = computed(() =>
classes.value.map((classname) => ({ label: classname, value: classname })),
classes.value.map((classname) => ({ label: `${classname} 班`, value: classname })),
)
const rows = computed(() => gradebook.value?.rows ?? [])
const scrollX = computed(() => 1040 + (gradebook.value?.tasks.length ?? 0) * 96)
const TASK_WIDTH = 66
const scrollX = computed(() => 380 + (gradebook.value?.tasks.length ?? 0) * TASK_WIDTH)
function formatScore(value: number | null) {
if (value === null) return "-"
if (value === null) return "—"
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
function taskTitle(task: GradebookTask) {
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
return `${typeLabel}${task.display}`
}
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
const reviewId = ref<string | null>(null)
function openSubmission(cell: GradebookCell) {
@@ -160,152 +168,135 @@ function openSubmission(cell: GradebookCell) {
reviewId.value = cell.submission_id
}
function gradeTagType(grade: string) {
if (grade === "A") return "success"
if (grade === "B") return "info"
if (grade === "C") return "default"
return "warning"
function renderGrade(grade: string, extra?: string | number) {
return h("span", { class: ["grade", `g-${grade}`] }, [
grade,
extra === undefined ? null : h("small", String(extra)),
])
}
function renderTaskHeader(task: GradebookTask) {
return h("div", { class: ["task-header", { muted: !task.included }] }, [
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
h("div", { class: "task-meta" }, [
h("span", `${Math.round(task.coverage * 100)}%`),
task.included
? null
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
]),
])
const pct = Math.round(task.coverage * 100)
const studentCount = gradebook.value?.student_count ?? 0
return h(
"div",
{
class: ["task-head", { excluded: !task.included }],
title: `${task.display}. ${task.title}\n交了 ${task.submitted_count} / ${studentCount} 人(${pct}%)${task.included ? "" : ",不计入"}`,
},
[
h("div", { class: "task-name" }, task.title),
h("div", { class: "task-cov" }, [
h("span", { class: "cov-bar" }, [h("span", { style: { width: pct + "%" } })]),
]),
],
)
}
function renderScore(row: GradebookRow, task: GradebookTask) {
const cell = row.scores[task.id]
if (!cell || !cell.submitted) {
return h(
NText,
{ class: "missing-cell", type: "error" },
{ default: () => "缺交" },
)
}
if (!cell?.submitted)
return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
return h(
NButton,
"button",
{
text: true,
type: task.included ? "primary" : "default",
class: ["score-link", { muted: !task.included }],
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(cell)
},
class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
onClick: () => openSubmission(cell),
},
{ default: () => formatScore(cell.score) },
cell.score === 0 ? "未评" : cell.score.toFixed(1),
)
}
function renderMissingCount(value: number) {
if (value <= 0) return "0"
return h(NText, { type: "error" }, { default: () => String(value) })
function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
return {
title: () => renderTaskHeader(task),
key: `task-${task.id}`,
width: TASK_WIDTH,
align: "center",
className: task.included ? "" : "excluded-col",
render: (row) => renderScore(row, task),
}
}
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
const tasks = gradebook.value?.tasks ?? []
const groups = (["tutorial", "challenge"] as const)
.map((type) => ({ type, items: tasks.filter((t) => t.task_type === type) }))
.filter((g) => g.items.length)
return [
{
title: "排名",
title: "#",
key: "rank",
width: 60,
width: 40,
fixed: "left",
},
{
title: "平时等级",
key: "grade",
width: 80,
fixed: "left",
render: (row) =>
h(
NTag,
{ size: "small", type: gradeTagType(row.grade) },
{ default: () => row.grade },
),
},
{
title: "考核等级",
key: "assessment_grade",
width: 80,
fixed: "left",
render: (row) =>
h(
NTag,
{ size: "small", type: gradeTagType(row.assessment_grade) },
{ default: () => row.assessment_grade },
),
},
{
title: "考核分数",
key: "assessment_score",
width: 80,
fixed: "left",
render: (row) => row.assessment_score,
align: "right",
sorter: (a, b) => a.rank - b.rank,
render: (row) => h("span", { class: "rank" }, row.rank),
},
{
title: "学生",
key: "username",
width: 80,
width: 92,
fixed: "left",
render: (row) => {
const studentName = displayGradebookStudentName(row)
return h(NText, { title: studentName }, { default: () => studentName })
},
ellipsis: { tooltip: true },
render: (row) => displayGradebookStudentName(row),
},
...tasks.map((task) => ({
title: () => renderTaskHeader(task),
key: `task-${task.id}`,
width: 96,
{
title: "平时",
key: "grade",
width: 52,
fixed: "left",
align: "center",
render: (row) => renderGrade(row.grade),
},
{
title: "考核",
key: "assessment_score",
width: 72,
fixed: "left",
sorter: (a, b) => a.assessment_score - b.assessment_score,
render: (row) => renderGrade(row.assessment_grade, row.assessment_score),
},
...groups.map((g) => ({
title: g.type === "tutorial" ? "教程" : "挑战",
key: `group-${g.type}`,
align: "center" as const,
className: task.included ? "" : "excluded-task-column",
render: (row: GradebookRow) => renderScore(row, task),
children: g.items.map(taskColumn),
})),
{
title: "教程合计",
key: "tutorial_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.tutorial_total),
},
{
title: "挑战合计",
key: "challenge_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.challenge_total),
},
{
title: "总分",
key: "total_score",
width: 82,
width: 72,
fixed: "right",
render: (row) => formatScore(row.total_score),
},
{
title: "平均",
key: "average_score",
width: 82,
fixed: "right",
render: (row) => formatScore(row.average_score),
align: "right",
sorter: (a, b) => a.total_score - b.total_score,
render: (row) =>
h(
"span",
{
class: "total",
title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
},
formatScore(row.total_score),
),
},
{
title: "已交",
key: "submitted_task_count",
width: 70,
width: 64,
fixed: "right",
},
{
title: "缺交",
key: "missing_task_count",
width: 70,
fixed: "right",
render: (row) => renderMissingCount(row.missing_task_count),
align: "right",
sorter: (a, b) => a.missing_task_count - b.missing_task_count,
render: (row) =>
h(
"span",
{
class: ["done", { short: row.missing_task_count > 0 }],
title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了",
},
`${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`,
),
},
]
})
@@ -315,7 +306,11 @@ async function loadClasses() {
try {
classes.value = await Account.listClasses()
if (!query.classname && classes.value.length > 0) {
query.classname = classes.value[0]
let saved: string | null = null
try {
saved = localStorage.getItem(CLASS_KEY)
} catch {}
query.classname = saved && classes.value.includes(saved) ? saved : classes.value[0]!
}
} finally {
classesLoading.value = false
@@ -359,6 +354,16 @@ async function exportCsv() {
}
}
watch(
() => query.classname,
(c) => {
if (!c) return
try {
localStorage.setItem(CLASS_KEY, c)
} catch {}
},
)
watch(
() => [query.classname, query.task_type, query.include_all_tasks],
() => loadGradebook(),
@@ -379,67 +384,194 @@ onMounted(async () => {
</script>
<style scoped>
.class-select {
width: 150px;
.gradebook {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
box-sizing: border-box;
overflow: hidden;
}
.type-select {
width: 112px;
.toolbar {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.class-select {
width: 120px;
}
.search-input {
width: 160px;
width: 150px;
}
.task-header {
min-width: 0;
line-height: 1.2;
.spacer {
flex: 1;
}
.task-header.muted {
color: var(--wp-muted);
}
.task-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.task-meta {
display: flex;
min-height: 18px;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 2px;
color: var(--wp-faint);
font-size: 11px;
}
.missing-cell {
color: var(--wp-bad);
.summary {
font-size: 12px;
}
.score-link.muted {
color: var(--wp-muted);
}
:deep(.excluded-task-column) {
.rules {
font-size: 13px;
line-height: 1.6;
}
.rules p {
margin: 0 0 6px;
}
.rules p:last-child {
margin: 0;
}
.table {
flex: 1;
min-height: 0;
}
.table :deep(.n-data-table-td) {
padding: 4px 8px;
}
.table :deep(.n-data-table-th) {
padding: 6px 8px;
}
</style>
<style>
/* 单元格是 render 函数画的,拿不到 scoped 属性,用表格的类名限定 */
.gradebook-table .excluded-col {
background: var(--wp-paper);
}
@media (max-width: 720px) {
.toolbar {
align-items: flex-start;
}
.gradebook-table .rank {
font: 12px var(--wp-font-mono);
color: var(--wp-faint);
}
.class-select,
.type-select,
.search-input {
width: 100%;
}
.gradebook-table .grade {
font-weight: 700;
}
.gradebook-table .grade small {
margin-left: 6px;
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.gradebook-table .g-A {
color: var(--wp-brand);
}
.gradebook-table .g-B {
color: var(--wp-css);
}
.gradebook-table .g-C {
color: var(--wp-muted);
}
.gradebook-table .g-D {
color: var(--wp-warn);
}
.gradebook-table .g-E {
color: var(--wp-bad);
}
.gradebook-table .task-head {
line-height: 1.3;
font-weight: 400;
}
.gradebook-table .task-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: var(--wp-ink);
word-break: break-all;
}
.gradebook-table .task-head.excluded .task-name {
color: var(--wp-faint);
}
.gradebook-table .task-cov {
margin-top: 4px;
}
.gradebook-table .cov-bar {
display: block;
height: 3px;
border-radius: 2px;
background: var(--wp-line);
overflow: hidden;
}
.gradebook-table .cov-bar span {
display: block;
height: 100%;
background: var(--wp-brand);
}
.gradebook-table .task-head.excluded .cov-bar span {
background: var(--wp-faint);
}
.gradebook-table .cell {
font: 13px var(--wp-font-mono);
}
.gradebook-table .cell.missing {
font: 12px var(--wp-font-sans);
color: var(--wp-bad);
}
.gradebook-table .score {
padding: 2px 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--wp-ink);
cursor: pointer;
}
.gradebook-table .score:hover {
background: var(--wp-brand-soft);
}
.gradebook-table .score.unrated {
font: 12px var(--wp-font-sans);
color: var(--wp-faint);
}
.gradebook-table .missing.excluded,
.gradebook-table .score.excluded {
color: var(--wp-faint);
}
.gradebook-table .total {
font: 600 13px var(--wp-font-mono);
cursor: default;
}
.gradebook-table .done {
font: 13px var(--wp-font-mono);
color: var(--wp-muted);
}
.gradebook-table .done.short {
color: var(--wp-bad);
}
</style>
+442
View File
@@ -0,0 +1,442 @@
<template>
<div class="mine">
<aside class="tasks">
<div class="tasks-head">
<b>我的提交</b>
<span class="muted">{{ tasks.length }} 个任务</span>
</div>
<n-spin :show="loadingList" class="task-list">
<template v-for="g in groups" :key="g.label">
<div class="group-label">{{ g.label }}</div>
<button
v-for="t in g.items"
:key="t.task_id"
:class="['task', { on: t.task_id === taskId }]"
@click="taskId = t.task_id"
>
<span class="task-title">{{ t.task_display }}. {{ t.task_title }}</span>
<span class="task-meta">
{{ t.submit_count }} 次 · {{ parseTime(t.created, "MM-DD") }}
</span>
</button>
</template>
<n-empty
v-if="!loadingList && !tasks.length"
description="还没有交过作品"
style="margin-top: 60px"
/>
</n-spin>
</aside>
<main class="detail">
<template v-if="task">
<div class="detail-head">
<div class="title">
<span class="kind">{{ task.task_type === TASK_TYPE.Tutorial ? "教程" : "挑战" }}</span>
<b>{{ task.task_display }}. {{ task.task_title }}</b>
</div>
<n-button size="small" quaternary icon-placement="right" @click="openTask">
打开这道题
<template #icon><Icon icon="lucide:arrow-right" /></template>
</n-button>
</div>
<n-spin :show="loadingVersions" class="stage">
<div v-if="versions.length" class="versions">
<button
v-for="(v, i) in versions"
:key="v.id"
:class="['ver', { on: v.id === currentId }]"
@click="currentId = v.id"
>
第 {{ versions.length - i }} 次
<span class="ver-score">{{ v.score ? v.score.toFixed(1) : "未评" }}</span>
<span v-if="v.id === bestId && versions.length > 1" class="best">计入成绩</span>
</button>
</div>
<div class="frame">
<iframe
v-if="current"
:key="current.id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
></iframe>
</div>
<div v-if="current" class="meta">
<span class="muted">
{{ parseTime(current.created, "YYYY-MM-DD HH:mm") }} 提交 ·
{{ current.score ? `得分 ${current.score.toFixed(2)}` : "还没有人评分" }}
<template v-if="versions.length > 1"> · 成绩按分数最高的一次算</template>
</span>
<n-flex :size="4" :wrap="false">
<n-button
v-if="task.task_type === TASK_TYPE.Challenge"
size="small"
quaternary
@click="showChain = true"
>
提示词
</n-button>
<n-popconfirm @positive-click="copyToEditor">
<template #trigger>
<n-button size="small" quaternary>放回编辑器</n-button>
</template>
把这一版的代码放进这道题的编辑器,会替换编辑器里现在的代码。
</n-popconfirm>
<n-popconfirm @positive-click="remove">
<template #trigger>
<n-button size="small" quaternary type="error">删除这一版</n-button>
</template>
删除后找不回来;它对应的那轮 AI 对话也会一起删掉。<br />
已经有人评过分的不能删。
</n-popconfirm>
</n-flex>
</div>
</n-spin>
</template>
<n-empty v-else-if="tasks.length" description="选一个任务" style="margin-top: 120px" />
</main>
</div>
<ChainModal
v-if="current"
v-model:show="showChain"
:submission-id="current.id"
:title="`${current.task_display}. ${current.task_title}`"
:asset-base-url="`/media/tasks/${current.task_type}/${current.task_display}/`"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "../icons"
import { Submission } from "../api"
import ChainModal from "../components/submissions/ChainModal.vue"
import { saveTaskCode } from "../composables/useTaskCode"
import { TASK_TYPE } from "../utils/const"
import { goHome, parseTime } from "../utils/helper"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
import type { SubmissionOut } from "../utils/type"
// 学生看自己交过的作品:左边按任务列出,右边看每一次交的版本
type Version = SubmissionOut & { html: string | null; css: string | null; js: string | null }
const route = useRoute()
const router = useRouter()
const message = useMessage()
const tasks = ref<SubmissionOut[]>([])
const loadingList = ref(false)
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
const versions = ref<Version[]>([])
const loadingVersions = ref(false)
const currentId = ref<string | null>(null)
const showChain = ref(false)
const task = computed(() => tasks.value.find((t) => t.task_id === taskId.value) ?? null)
const current = computed(() => versions.value.find((v) => v.id === currentId.value) ?? null)
const groups = computed(() =>
[
{ label: "教程", type: TASK_TYPE.Tutorial },
{ label: "挑战", type: TASK_TYPE.Challenge },
]
.map((g) => ({
label: g.label,
items: tasks.value
.filter((t) => t.task_type === g.type)
.sort((a, b) => a.task_display - b.task_display),
}))
.filter((g) => g.items.length),
)
// 和成绩册一样:分数最高的一次计入成绩,同分取最新
const bestId = computed(() => {
let best: Version | null = null
for (const v of versions.value) if (v.score > 0 && (!best || v.score > best.score)) best = v
return best?.id ?? null
})
const doc = computed(() =>
current.value
? buildPreviewDocument({
html: current.value.html ?? "",
css: current.value.css ?? "",
js: current.value.js ?? "",
assetBaseUrl: `/media/tasks/${current.value.task_type}/${current.value.task_display}/`,
})
: "",
)
async function loadTasks() {
loadingList.value = true
try {
const all: SubmissionOut[] = []
for (let page = 1; ; page++) {
const res = await Submission.list({ page, page_size: 100, grouped: true })
all.push(...res.items)
if (all.length >= res.count || !res.items.length) break
}
tasks.value = all
if (!task.value) taskId.value = groups.value[0]?.items[0]?.task_id ?? null
} finally {
loadingList.value = false
}
}
let seq = 0
async function loadVersions() {
const s = ++seq
const t = task.value
if (!t) {
versions.value = []
return
}
loadingVersions.value = true
try {
const list = (await Submission.listByUserTask(t.userid, t.task_id)) as Version[]
if (s !== seq) return
versions.value = list
if (!list.some((v) => v.id === currentId.value)) currentId.value = list[0]?.id ?? null
} finally {
if (s === seq) loadingVersions.value = false
}
}
watch(taskId, (id) => {
router.replace({ query: id ? { task: String(id) } : {} })
loadVersions()
})
function openTask() {
if (task.value) goHome(router, task.value.task_type, task.value.task_display)
}
function copyToEditor() {
const v = current.value
if (!v) return
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
saveTaskCode(v.task_id, { html: v.html ?? "", css: v.css ?? "", js: v.js ?? "" })
goHome(router, v.task_type, v.task_display)
}
async function remove() {
const v = current.value
if (!v) return
try {
await Submission.delete(v.id)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "删除失败,请重试")
return
}
message.success("已删除")
if (versions.value.length > 1) {
await loadVersions()
const t = tasks.value.find((x) => x.task_id === v.task_id)
if (t) t.submit_count = versions.value.length
} else {
// 这道题一次都不剩了
tasks.value = tasks.value.filter((x) => x.task_id !== v.task_id)
taskId.value = groups.value[0]?.items[0]?.task_id ?? null
}
}
onMounted(async () => {
await loadTasks()
loadVersions()
})
</script>
<style scoped>
.mine {
height: 100%;
display: flex;
min-width: 0;
}
.tasks {
width: 260px;
flex: none;
display: flex;
flex-direction: column;
border-right: 1px solid var(--wp-line);
background: var(--wp-surface);
min-height: 0;
}
.tasks-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 12px 14px 6px;
}
.task-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 8px 10px;
}
.group-label {
padding: 10px 6px 4px;
font-size: 12px;
color: var(--wp-faint);
}
.task {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--wp-ink);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.task:hover {
background: var(--wp-paper);
}
.task.on {
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.task-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-meta {
font-size: 12px;
color: var(--wp-faint);
}
.muted {
font-size: 12px;
color: var(--wp-muted);
}
.detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 10px 14px 12px;
gap: 10px;
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.title {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kind {
font-size: 12px;
color: var(--wp-muted);
}
.stage {
flex: 1;
min-height: 0;
}
.stage :deep(.n-spin-content) {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.versions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ver {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.ver:hover {
border-color: var(--wp-brand);
}
.ver.on {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.ver-score {
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.best {
padding: 0 5px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.ver.on .best {
background: var(--wp-surface);
}
.frame {
flex: 1;
min-height: 0;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.frame iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
</style>
+30 -1
View File
@@ -48,6 +48,10 @@
>
开始讲评({{ presentList.length }})
</n-button>
<n-button size="small" @click="showStats = true">
<template #icon><Icon icon="lucide:bar-chart-2" /></template>
课堂总结
</n-button>
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary @click="loadWall">
@@ -119,6 +123,16 @@
</aside>
</div>
<TaskStatsModal
v-if="task"
v-model:show="showStats"
:task-id="task.id"
:title="`${task.display}. ${task.title}`"
:initial-class="classname"
in-review
@open="openFromStats"
/>
<div v-if="presenting" class="present-mode">
<div class="present-head">
<b>{{ presentList[presentIndex]?.username }}</b>
@@ -134,11 +148,12 @@
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useStorage } from "@vueuse/core"
import { Icon } from "../icons"
import ReviewPanel from "../components/review/ReviewPanel.vue"
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
import { Review } from "../api"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
import type { FlagType, ReviewOptions, ReviewProgress, ReviewStudent } from "../utils/type"
@@ -170,6 +185,20 @@ function openDetail(id: string) {
router.push({ query: { ...route.query, sub: id } })
}
// 课堂总结里点了高分作品:在这里打开;那个学生不在当前班就先切过去
const showStats = ref(false)
async function openFromStats(id: string, cls: string) {
showStats.value = false
if (classname.value !== ALL && classname.value !== cls) {
classname.value = cls
// 先让地址栏变成新班级(切班的 watch 也会这么改),关掉作品时“后退”才回到这个班
await nextTick()
await router.replace({ query: { class: cls, task: taskId.value ?? undefined } })
}
pushedDetail = true
router.push({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined, sub: id } })
}
function closeDetail() {
if (pushedDetail) {
pushedDetail = false
+379 -91
View File
@@ -1,167 +1,455 @@
<template>
<main class="showcase">
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
<header class="page-head">
<div>
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
<n-text depth="3">优秀作品展示</n-text>
<h2>作品展</h2>
<p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
</div>
</n-flex>
<nav v-if="awards.length > 1" class="jump">
<a v-for="a in awards" :key="a.id" :href="`#award-${a.id}`" @click.prevent="jump(a.id)">
{{ a.name }}
</a>
</nav>
</header>
<n-spin :show="loading">
<n-empty
v-if="!loading && awards.length === 0"
description="暂无展示作品"
style="margin-top: 72px;"
v-if="!loading && !awards.length"
description="还没有展出的作品"
style="margin-top: 72px"
/>
<section
v-for="section in awards"
:key="section.id"
style="margin-bottom: 48px;"
>
<n-flex vertical :size="4" style="margin-bottom: 16px;">
<n-h3 style="margin: 0;">{{ section.name }}</n-h3>
<n-text v-if="section.description" depth="3" style="font-size: 13px;">
{{ section.description }}
</n-text>
</n-flex>
<section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
<div class="section-head">
<h3>{{ section.name }}</h3>
<span class="muted">{{ section.items.length }} 件</span>
</div>
<p v-if="section.description" class="desc">{{ section.description }}</p>
<div class="card-grid">
<n-card
v-for="item in section.items"
<div class="grid">
<button
v-for="(item, ii) in section.items"
:key="item.submission_id"
class="work-card"
content-style="padding: 0;"
hoverable
@click="openDetail(item)"
class="card"
@click="open(si, ii)"
>
<div class="card-preview">
<div class="thumb">
<iframe
:srcdoc="buildSrcdoc(item)"
sandbox="allow-scripts"
:srcdoc="docOf(item)"
:sandbox="PREVIEW_SANDBOX"
loading="lazy"
scrolling="no"
class="preview-iframe"
/>
<div class="preview-overlay" />
tabindex="-1"
></iframe>
</div>
<div class="card-info">
<n-flex justify="space-between" align="center" :wrap="false">
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
{{ item.username }}
</n-ellipsis>
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
<n-flex align="center" :size="3">
<Icon icon="lucide:star" :width="13" />
<n-text style="font-size: 12px; color: var(--wp-muted);">
{{ item.score.toFixed(1) }}
</n-text>
</n-flex>
<n-flex align="center" :size="3">
<Icon icon="lucide:eye" :width="13" />
<n-text style="font-size: 12px; color: var(--wp-muted);">
{{ item.view_count }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
<n-ellipsis
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: var(--wp-muted);"
>
{{ item.task_title }}
</n-ellipsis>
<div class="cap">
<div class="who">
<b>{{ item.username }}</b>
<span v-if="item.score" class="score">
<Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
</span>
</div>
<div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
</div>
</n-card>
</button>
</div>
</section>
</n-spin>
</main>
<n-modal
:show="!!current"
preset="card"
style="width: 94vw; max-width: 1280px"
:bordered="false"
@update:show="(v: boolean) => !v && (viewing = null)"
>
<template #header>
<div class="viewer-head">
<b>{{ current?.username }}</b>
<span class="muted">{{ current?.task_display }}. {{ current?.task_title }}</span>
</div>
</template>
<template #header-extra>
<n-flex v-if="viewing" align="center" :size="4" style="margin-right: 8px">
<span class="muted">
{{ viewing.item + 1 }} / {{ awards[viewing.section]?.items.length }}
</span>
<n-button size="small" quaternary :disabled="viewing.item === 0" @click="step(-1)">
<template #icon><Icon icon="lucide:arrow-left" /></template>
</n-button>
<n-button
size="small"
quaternary
:disabled="viewing.item >= (awards[viewing.section]?.items.length ?? 0) - 1"
@click="step(1)"
>
<template #icon><Icon icon="lucide:arrow-right" /></template>
</n-button>
</n-flex>
</template>
<div v-if="current" class="viewer">
<div class="big">
<iframe :key="current.submission_id" :srcdoc="docOf(current)" :sandbox="PREVIEW_SANDBOX"></iframe>
</div>
<aside class="info">
<div class="field">
<div class="label">获奖</div>
<div class="awards">
<span v-for="name in awardsOf(current.submission_id)" :key="name" class="award">
<Icon icon="lucide:award" :width="13" />{{ name }}
</span>
</div>
</div>
<div class="field">
<div class="label">得分</div>
<div v-if="current.score" class="mono">{{ current.score.toFixed(2) }}</div>
<div v-else class="muted">还没有评分</div>
</div>
<div class="field">
<div class="label">看过的次数</div>
<div class="mono">{{ current.view_count }}</div>
</div>
<n-flex vertical :size="8" class="actions">
<n-button v-if="current.has_prompt_chain" secondary @click="showChain = true">
<template #icon><Icon icon="lucide:sparkles" /></template>
看看是怎么问出来的
</n-button>
<n-button quaternary @click="openFull">
<template #icon><Icon icon="lucide:maximize-2" /></template>
全屏打开
</n-button>
</n-flex>
<div class="muted hint">← → 切换,Esc 关闭</div>
</aside>
</div>
</n-modal>
<ChainModal
v-if="current"
v-model:show="showChain"
:submission-id="current.submission_id"
:title="`${current.username} · ${current.task_title}`"
:asset-base-url="assetBase(current)"
from-showcase
/>
</template>
<script setup lang="ts">
import { storageShim } from "../utils/previewStorage"
import { onMounted, ref } from "vue"
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { useRouter } from "vue-router"
import { Icon } from "../icons"
import { Showcase } from "../api"
import { Showcase, Submission } from "../api"
import ChainModal from "../components/submissions/ChainModal.vue"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
import type { AwardSection, ShowcaseItem } from "../utils/type"
const router = useRouter()
const loading = ref(true)
const awards = ref<AwardSection[]>([])
// 正在看第几个奖项的第几件
const viewing = ref<{ section: number; item: number } | null>(null)
const showChain = ref(false)
function buildSrcdoc(item: ShowcaseItem): string {
const css = item.css ? `<style>${item.css}</style>` : ""
const js = item.js ? `<script>${item.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}<meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
const current = computed(() =>
viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
)
const assetBase = (item: ShowcaseItem) => `/media/tasks/${item.task_type}/${item.task_display}/`
function docOf(item: ShowcaseItem) {
return buildPreviewDocument({
html: item.html ?? "",
css: item.css ?? "",
js: item.js ?? "",
assetBaseUrl: assetBase(item),
})
}
function openDetail(item: ShowcaseItem) {
const { href } = router.resolve({
name: "submission",
params: { id: item.submission_id },
})
// 一件作品可能同时在几个奖项里
function awardsOf(submissionId: string) {
return awards.value
.filter((a) => a.items.some((i) => i.submission_id === submissionId))
.map((a) => a.name)
}
function jump(id: number) {
document.getElementById(`award-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" })
}
function open(section: number, item: number) {
viewing.value = { section, item }
}
function step(d: number) {
const v = viewing.value
if (!v) return
const n = awards.value[v.section]?.items.length ?? 0
const item = v.item + d
if (item >= 0 && item < n) viewing.value = { section: v.section, item }
}
// 每打开一件记一次浏览
const counted = new Set<string>()
watch(current, (item) => {
if (!item || counted.has(item.submission_id)) return
counted.add(item.submission_id)
Submission.incrementView(item.submission_id)
.then(() => item.view_count++)
.catch(() => {})
})
function openFull() {
if (!current.value) return
const { href } = router.resolve({ name: "submission", params: { id: current.value.submission_id } })
window.open(href, "_blank")
}
async function init() {
function onKey(e: KeyboardEvent) {
if (!viewing.value || showChain.value) return
if (e.key === "ArrowLeft") step(-1)
else if (e.key === "ArrowRight") step(1)
}
onMounted(async () => {
window.addEventListener("keydown", onKey)
try {
awards.value = await Showcase.list()
} finally {
loading.value = false
}
}
onMounted(init)
})
onUnmounted(() => window.removeEventListener("keydown", onKey))
</script>
<style scoped>
.showcase {
max-width: 1180px;
margin: 0 auto;
padding: 32px 20px 48px;
padding: 28px 20px 48px;
}
.card-grid {
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 24px;
}
h2 {
margin: 0 0 4px;
font-size: 22px;
}
.muted {
font-size: 12px;
font-weight: 400;
color: var(--wp-muted);
}
.page-head .muted {
margin: 0;
font-size: 13px;
}
.jump {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.jump a {
padding: 3px 10px;
border: 1px solid var(--wp-line);
border-radius: 999px;
font-size: 13px;
color: var(--wp-ink);
text-decoration: none;
background: var(--wp-surface);
}
.jump a:hover {
border-color: var(--wp-brand);
color: var(--wp-brand);
}
section {
margin-bottom: 40px;
scroll-margin-top: 16px;
}
.section-head {
display: flex;
align-items: baseline;
gap: 10px;
}
h3 {
margin: 0;
font-size: 17px;
}
.desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--wp-muted);
}
.grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.work-card {
.card {
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 10px;
overflow: hidden;
background: var(--wp-surface);
color: var(--wp-ink);
font: inherit;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease;
transition:
border-color 0.15s,
transform 0.15s;
}
.work-card:hover {
.card:hover {
border-color: var(--wp-brand);
transform: translateY(-2px);
}
.card-preview {
.thumb {
position: relative;
height: 160px;
aspect-ratio: 16 / 10;
overflow: hidden;
background: var(--wp-paper);
border-bottom: 1px solid var(--wp-line);
}
.preview-iframe {
/* 按 2 倍大小渲染再缩小 */
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
border: none;
border: 0;
transform: scale(0.5);
transform-origin: top left;
transform-origin: 0 0;
pointer-events: none;
}
.preview-overlay {
position: absolute;
inset: 0;
.cap {
padding: 10px 12px 12px;
}
.card-info {
min-height: 72px;
padding: 10px 12px;
border-top: 1px solid var(--wp-line);
.who {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
.who b {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score {
flex: none;
display: inline-flex;
align-items: center;
gap: 3px;
font: 12px var(--wp-font-mono);
color: var(--wp-warn);
}
.task {
margin-top: 4px;
font-size: 12px;
color: var(--wp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewer-head {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.viewer {
display: grid;
grid-template-columns: 1fr 230px;
gap: 16px;
height: 72vh;
}
.big {
display: flex;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.big iframe {
flex: 1;
border: 0;
}
.info {
display: flex;
flex-direction: column;
gap: 14px;
}
.label {
margin-bottom: 4px;
font-size: 12px;
color: var(--wp-muted);
}
.awards {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.award {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
color: var(--wp-warn);
background: var(--wp-warn-soft);
}
.mono {
font: 15px var(--wp-font-mono);
}
.actions {
margin-top: 4px;
}
.actions :deep(.n-button) {
justify-content: flex-start;
}
.hint {
margin-top: auto;
}
</style>
File diff suppressed because it is too large. Load diff
+218 -311
View File
@@ -1,335 +1,217 @@
<template>
<n-split
class="container"
direction="horizontal"
:default-size="0.5"
:min="0.2"
:max="0.8"
>
<template #1>
<n-flex
vertical
style="height: 100%; padding-right: 10px; overflow: hidden"
>
<n-flex align="center" justify="end">
<n-select
v-model:value="query.zone"
style="width: 110px"
clearable
placeholder="得分分区"
:options="zoneOptions"
/>
<n-input
v-if="isAdmin"
style="width: 120px"
v-model:value="query.username"
placeholder="搜索学生"
clearable
/>
<n-pagination
v-model:page="query.page"
:page-size="10"
:item-count="count"
simple
/>
<n-button
secondary
style="padding: 0 10px"
title="刷新"
@click="init"
>
<Icon :width="16" icon="lucide:refresh-cw" />
<div class="subs">
<main class="list">
<div class="toolbar">
<n-input
v-model:value="query.username"
size="small"
clearable
placeholder="搜索学生"
style="width: 150px"
>
<template #prefix><Icon icon="lucide:search" :width="14" /></template>
</n-input>
<n-radio-group v-model:value="query.task_type" size="small">
<n-radio-button value="">全部</n-radio-button>
<n-radio-button value="tutorial">教程</n-radio-button>
<n-radio-button value="challenge">挑战</n-radio-button>
</n-radio-group>
<n-select
v-model:value="query.zone"
size="small"
clearable
placeholder="得分分区"
:options="zoneOptions"
style="width: 110px"
/>
<n-select
v-model:value="query.ordering"
size="small"
:options="orderOptions"
style="width: 130px"
/>
<n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
<span class="spacer"></span>
<span class="muted">共 {{ count }} 条</span>
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary :loading="loading" @click="load">
<template #icon><Icon icon="lucide:refresh-cw" /></template>
</n-button>
</n-flex>
<n-data-table
flex-height
striped
:columns="columns"
:data="data"
:row-key="(row: SubmissionOut) => row.id"
:expanded-row-keys="expandedKeys"
@update:expanded-row-keys="handleExpand"
:row-props="rowProps"
:row-class-name="rowClassName"
style="flex: 1; min-height: 0"
</template>
刷新
</n-tooltip>
</div>
<n-data-table
class="table"
size="small"
flex-height
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: SubmissionOut) => row.id"
:row-props="rowProps"
:row-class-name="(row: SubmissionOut) => (row.id === selectedId ? 'row-active' : '')"
/>
<n-flex justify="end">
<n-pagination
v-model:page="query.page"
:page-size="PAGE_SIZE"
:item-count="count"
size="small"
/>
</n-flex>
</template>
<template #2>
<div style="height: 100%; padding-left: 10px">
<Preview
v-if="submission.id"
:html="html"
:css="css"
:js="js"
:submission-id="submission.id"
@after-score="afterScore"
@show-code="codeModal = true"
/>
</div>
</template>
</n-split>
</main>
<CodeModal
v-model:show="codeModal"
:html="html"
:css="css"
:js="js"
@copy-to-editor="copyToEditor"
/>
<ChainModal
v-model:show="chainModal"
:submission-id="chainSubmissionId"
:username="chainUsername"
/>
<aside v-if="selectedId" class="detail">
<ReviewPanel
:submission-id="selectedId"
can-delete
@close="selectedId = null"
@changed="applyPatch"
@deleted="onDeleted"
/>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
import { h, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { watchDebounced } from "@vueuse/core"
import type { DataTableColumn } from "naive-ui"
import { Icon } from "../icons"
import { Submission } from "../api"
import type { SubmissionOut } from "../utils/type"
import { parseTime } from "../utils/helper"
import { goHome } from "../utils/helper"
import { saveTaskCode } from "../composables/useTaskCode"
import ReviewPanel from "../components/review/ReviewPanel.vue"
import { TASK_TYPE } from "../utils/const"
import { watchDebounced } from "@vueuse/core"
import { useRouter, useRoute } from "vue-router"
import { parseTime } from "../utils/helper"
import type { FlagType, SubmissionOut } from "../utils/type"
import Preview from "../components/editor/Preview.vue"
import TaskTitle from "../components/submissions/TaskTitle.vue"
import CodeModal from "../components/submissions/CodeModal.vue"
import ChainModal from "../components/submissions/ChainModal.vue"
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
import { submission } from "../store/submission"
import { roleAdmin, roleSuper } from "../store/user"
// 全站提交列表,留给超管查某个学生、处理有问题的提交;平时批改用“批改”页
const PAGE_SIZE = 20
const route = useRoute()
const router = useRouter()
const message = useMessage()
// 列表数据
const data = ref<SubmissionOut[]>([])
const count = ref(0)
const loading = ref(false)
const selectedId = ref<string | null>(null)
const query = reactive({
page: Number(route.params.page),
username: (Array.isArray(route.query.username)
? ""
: (route.query.username ?? "")) as string,
page: Number(route.params.page) || 1,
username: typeof route.query.username === "string" ? route.query.username : "",
task_type: "" as "" | "tutorial" | "challenge",
zone: null as string | null,
ordering: "-created",
grouped: true,
})
// 当前选中提交的代码
const html = computed(() => submission.value.html)
const css = computed(() => submission.value.css)
const js = computed(() => submission.value.js)
// Modal 状态
const codeModal = ref(false)
const chainModal = ref(false)
const chainSubmissionId = ref<string>("")
const chainUsername = ref<string>("")
// 展开行
const expandedKeys = ref<string[]>([])
const expandedData = reactive(new Map<string, SubmissionOut[]>())
const expandedLoading = reactive(new Set<string>())
// 管理员判断
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
const zoneOptions = [
{ label: "优秀", value: "featured" },
{ label: "待定", value: "pending" },
{ label: "待提高", value: "low" },
const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
const orderOptions = [
{ label: "最新的在前", value: "-created" },
{ label: "得分从高到低", value: "-score" },
{ label: "得分从低到高", value: "score" },
]
function showChain(submissionId: string, username: string) {
chainSubmissionId.value = submissionId
chainUsername.value = username
chainModal.value = true
}
// 表格列定义
const columns: DataTableColumn<SubmissionOut>[] = [
{
type: "expand",
expandable: () => true,
renderExpand: (row) =>
h(ExpandedSubTable, {
row,
items: expandedData.get(row.id),
loading: expandedLoading.has(row.id),
onSelect: (id) => getSubmissionByID(id),
onDelete: (r, parentId) => handleDelete(r, parentId),
"onShow-chain": (submissionId, username) =>
showChain(submissionId, username),
}),
},
{
title: "分区",
key: "zone",
width: 64,
render: (row) => {
const map: Record<
string,
{ label: string; type: "success" | "default" | "warning" }
> = {
featured: { label: "优秀", type: "success" },
pending: { label: "待定", type: "default" },
low: { label: "待提高", type: "warning" },
}
if (!row.zone || !map[row.zone]) return null
const { label, type } = map[row.zone]
return h(NTag, { size: "small", round: true, type }, () => label)
},
},
{
title: "时间",
key: "created",
width: 110,
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "提交者",
key: "user",
width: 80,
render: (row) => row.username,
width: 100,
render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
},
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
{
title: "任务",
key: "task_title",
render: (row) => h(TaskTitle, { submission: row }),
ellipsis: { tooltip: true },
render: (row) => [
h("span", { class: "muted" }, row.task_type === TASK_TYPE.Tutorial ? "教程 " : "挑战 "),
`${row.task_display}. ${row.task_title}`,
],
},
{
title: "得分",
key: "score",
width: 70,
render: (row) => {
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
],
)
},
align: "right",
render: (row) =>
row.score > 0
? h("span", { class: "mono" }, row.score.toFixed(2))
: h("span", { class: "muted" }, "未评"),
},
{
title: "次数",
title: "分区",
key: "zone",
width: 70,
render: (row) => (row.zone ? h("span", { class: `zone z-${row.zone}` }, ZONES[row.zone]) : null),
},
{
title: "共交",
key: "submit_count",
width: 60,
render: (row) => row.submit_count || "-",
},
{
title: "浏览",
key: "view_count",
width: 60,
render: (row) => row.view_count || "-",
align: "right",
render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
},
]
async function handleExpand(keys: (string | number)[]) {
const strKeys = keys.map(String)
const newKey = strKeys.find((k) => !expandedKeys.value.includes(k))
expandedKeys.value = strKeys
if (newKey) {
const row = data.value.find((d) => d.id === newKey)
if (row && !expandedData.has(newKey)) {
expandedLoading.add(newKey)
try {
const items = await Submission.listByUserTask(row.userid, row.task_id)
expandedData.set(newKey, items)
} finally {
expandedLoading.delete(newKey)
}
}
}
}
async function handleDelete(row: SubmissionOut, parentId: string) {
try {
await Submission.delete(row.id)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
const items = expandedData.get(parentId)
if (items)
expandedData.set(
parentId,
items.filter((d) => d.id !== row.id),
)
if (submission.value.id === row.id) submission.value.id = ""
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
}
function rowProps(row: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => {
getSubmissionByID(row.id)
handleExpand(
expandedKeys.value.includes(row.id)
? expandedKeys.value.filter((k) => k !== row.id)
: [...expandedKeys.value, row.id],
)
},
onClick: () => (selectedId.value = row.id),
}
}
function rowClassName(row: SubmissionOut) {
return submission.value.id === row.id ? "row-active" : ""
}
// 快速翻页时只用最后一次请求的结果
let initSeq = 0
let seq = 0
async function init() {
const seq = ++initSeq
expandedKeys.value = []
expandedData.clear()
const res = await Submission.list(query)
if (seq !== initSeq) return
data.value = res.items
count.value = res.count
async function load() {
const s = ++seq
loading.value = true
try {
const res = await Submission.list({
page: query.page,
page_size: PAGE_SIZE,
username: query.username || undefined,
task_type: query.task_type || undefined,
zone: query.zone,
ordering: query.ordering,
grouped: query.grouped,
})
if (s !== seq) return
data.value = res.items
count.value = res.count
} finally {
if (s === seq) loading.value = false
}
}
let detailSeq = 0
async function getSubmissionByID(id: string) {
const seq = ++detailSeq
const res = await Submission.get(id)
if (seq === detailSeq) submission.value = res
function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
const row = data.value.find((d) => d.id === patch.id)
if (row) Object.assign(row, patch)
}
function afterScore() {
data.value = data.value.map((d) => {
if (d.id === submission.value.id) d.my_score = submission.value.my_score
return d
})
function onDeleted() {
selectedId.value = null
load()
}
function copyToEditor() {
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
goHome(router, submission.value.task_type, submission.value.task_display)
// 换了筛选条件就回到第一页
function reset() {
if (query.page !== 1) query.page = 1
else load()
}
watch(
() => query.page,
(v) => {
init()
router.push({ params: { page: v } })
(page) => {
load()
router.push({ params: { page } })
},
)
// 浏览器前进 / 后退时,页码跟着地址栏走
@@ -340,53 +222,78 @@ watch(
if (page !== query.page) query.page = page
},
)
watchDebounced(
() => query.username,
() => {
if (query.page !== 1) query.page = 1
else init()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.zone,
() => {
if (query.page !== 1) query.page = 1
else init()
},
)
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
onMounted(init)
onUnmounted(() => {
submission.value = {
id: "",
userid: 0,
username: "",
task_id: 0,
task_display: 0,
task_title: "",
task_type: TASK_TYPE.Tutorial,
score: 0,
my_score: 0,
html: "",
css: "",
js: "",
submit_count: 0,
view_count: 0,
created: new Date(),
modified: new Date(),
}
})
onMounted(load)
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
width: 100%;
.subs {
height: 100%;
display: flex;
min-width: 0;
}
:deep(.row-active td) {
background-color: var(--wp-brand-soft) !important;
.list {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.spacer {
flex: 1;
}
.table {
flex: 1;
min-height: 0;
}
.table :deep(.row-active td) {
background: var(--wp-brand-soft) !important;
}
.table :deep(.mono) {
font-family: var(--wp-font-mono);
}
.muted,
.table :deep(.muted) {
font-size: 12px;
color: var(--wp-muted);
}
.table :deep(.zone) {
font-size: 12px;
}
.table :deep(.z-featured) {
color: var(--wp-brand);
}
.table :deep(.z-low) {
color: var(--wp-warn);
}
.table :deep(.z-pending) {
color: var(--wp-muted);
}
.detail {
width: 440px;
flex: none;
border-left: 1px solid var(--wp-line);
min-height: 0;
}
</style>
+4 -3
View File
@@ -44,9 +44,10 @@ const routes: RouteRecordRaw[] = [
meta: { auth: true },
},
{
path: "/submissions/:page",
// 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
path: "/submissions/:page?",
name: "submissions",
component: () => import("./pages/Submissions.vue"),
component: () => import("./pages/MySubmissions.vue"),
meta: { auth: true },
},
{
@@ -116,7 +117,7 @@ const routes: RouteRecordRaw[] = [
component: () => import("./pages/Review.vue"),
},
{
// 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失
// 全站提交列表(超管),和学生的“我的提交”是两个页面
path: "submissions/:page",
name: "teach-submissions",
component: () => import("./pages/Submissions.vue"),
+3
View File
@@ -19,6 +19,8 @@ export const conversationId = ref<string>("")
export const connected = ref(false)
export const streaming = ref(false)
export const streamingContent = ref("")
// 这次提问发出的时间:AI 先思考,第一个字可能一两分钟后才出来,界面据此显示等了多久
export const streamStartedAt = ref(0)
export const currentTaskId = ref(0)
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
let _codeSink:
@@ -106,6 +108,7 @@ export function disconnectPrompt() {
export function sendPrompt(content: string, model: string = "") {
if (!socket.send({ type: "message", content, model })) return
streaming.value = true
streamStartedAt.value = Date.now()
messages.value.push({ role: "user", content })
}
-22
View File
@@ -1,22 +0,0 @@
import { ref } from "vue"
import type { SubmissionAll } from "../utils/type"
import { TASK_TYPE } from "../utils/const"
export const submission = ref<SubmissionAll>({
id: "",
userid: 0,
username: "",
task_id: 0,
task_display: 0, // 这是序号
task_title: "",
task_type: TASK_TYPE.Tutorial,
score: 0.0,
my_score: 0,
submit_count: 0,
view_count: 0,
html: "",
css: "",
js: "",
created: new Date(),
modified: new Date(),
})
+15
View File
@@ -21,5 +21,20 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
export function sourceLabel(source: string | null | undefined) {
if (source === "edit") return "手改"
if (source === "manual") return "外部 AI"
if (source === "guidance") return "引导"
return "AI 对话"
}
// 提问的 Bloom 等级(services/classifier.ts 后台打的标签)
export const PROMPT_LEVELS: Record<number, { name: string; desc: string }> = {
1: { name: "记忆", desc: "只说要做什么或只报结果,没有自己的要求和解释" },
2: { name: "理解", desc: "想弄懂原理:问为什么、是什么意思、怎么做到的" },
3: { name: "应用", desc: "提出具体要求(颜色、布局、功能),照搬题目要求也算" },
4: { name: "分析", desc: "说清在什么操作、哪个位置出了什么问题,或推测原因" },
5: { name: "评价", desc: "带理由评判现有结果,或比较不同做法再选择" },
6: { name: "创造", desc: "题目之外自己构思的整体设计:主题、玩法、交互" },
}
export function levelLabel(level: number | null | undefined) {
return level ? `L${level} ${PROMPT_LEVELS[level]?.name ?? ""}`.trim() : ""
}
+2 -1
View File
@@ -30,8 +30,9 @@ export function buildPreviewDocument({
<title>预览</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
<style>${css}</style>
<!-- normalize 要在学生的样式前面,否则同样写法的规则(body、h1 的边距等)会被它盖掉 -->
<link rel="stylesheet" href="/normalize.min.css" />
<style>${css}</style>
</head>
<body>
${html}
+20 -10
View File
@@ -167,6 +167,8 @@ export interface SubmissionAll {
view_count: number
created: Date
modified: Date
// 交的时候写的反思,只有老师和作者本人拿得到
reflection?: string | null
}
export interface UserTag {
@@ -199,6 +201,18 @@ export interface TaskStatsOut {
present_count: number
classes: string[]
top_scored: TopScoredItem[]
prompts: PromptChainStats
}
// 课堂总结里每人最新一版的提示词链概况
export interface PromptChainStats {
with_chain: number
one_round: number
external: number
used_guidance: number
avg_rounds: number
// L1–L6 各有几次提问
levels: number[]
}
export type GradebookTaskType = "tutorial" | "challenge"
@@ -261,6 +275,7 @@ export interface ShowcaseItem {
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
html: string | null
@@ -301,28 +316,22 @@ export interface AwardItemUpdateIn {
sort_order: number
}
export interface ShowcaseSubmissionLookupOut {
submission_id: string
username: string
task_title: string
task_display: number
score: number
view_count: number
has_prompt_chain: boolean
}
export interface AwardItemManageOut {
id: number
submission_id: string
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
sort_order: number
awarded_at: string
has_prompt_chain: boolean
is_stale: boolean
html: string | null
css: string | null
js: string | null
}
export interface ShowcaseDetail {
@@ -330,6 +339,7 @@ export interface ShowcaseDetail {
username: string
task_title: string
task_display: number
task_type: TASK_TYPE
score: number
view_count: number
html: string | null
+109
View File
@@ -0,0 +1,109 @@
/**
* 对比某个日期前后,学生和 AI 对话的情况(比如改了 AI 的回复方式以后有没有变化)。
* 日期按北京时间,分界日期那天算“之后”。只读,不改数据。
*
* bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]
* bun scripts/prompt-stats.ts 2026-10-28 --from 2026-09-01
*
* 层级是打标签时的标准;分类标准改过以后,先跑 classify-prompts.ts --force 重打,前后才好比。
*/
import { all, toDbTime } from "../src/db"
const args = process.argv.slice(2)
const opt = (name: string) => {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : undefined
}
const split = args[0]
if (!split || !/^\d{4}-\d{2}-\d{2}$/.test(split)) {
console.log("用法:bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]")
process.exit(1)
}
const day = (d: string) => toDbTime(new Date(`${d}T00:00:00+08:00`))
const nextDay = (d: string) => toDbTime(new Date(new Date(`${d}T00:00:00+08:00`).getTime() + 86_400_000))
const periods = [
{ name: `${opt("--from") ?? "最早"} ~ ${split} 之前`, from: opt("--from") ? day(opt("--from")!) : "", to: day(split) },
{ name: `${split} ~ ${opt("--to") ?? "现在"}`, from: day(split), to: opt("--to") ? nextDay(opt("--to")!) : "9999" },
]
// 和 PromptPanel 里一样:只说“不行”“没反应”的短抱怨;“出问题了”拼出来的句子
const VAGUE =
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
const PROBLEM = /^我.+,(想看到.+,)?但实际.+。$/s
type Msg = {
conversation_id: string
source: string
content: string
prompt_level: number | null
created: string
// 在这个对话的 AI 对话里是第几轮(只数 conversation)
round: number | null
}
// 批处理脚本,整表扫一遍没关系
const msgs = all<Msg>(
`SELECT conversation_id, source, content, prompt_level, created,
CASE WHEN source = 'conversation'
THEN ROW_NUMBER() OVER (PARTITION BY conversation_id, source = 'conversation' ORDER BY created, id) END AS round
FROM prompt_message WHERE role = 'user'`,
)
const subs = all<{ created: string; reflected: number }>(
`SELECT s.created, r.submission_id IS NOT NULL AS reflected
FROM submission_submission s JOIN task_task t ON t.id = s.task_id
LEFT JOIN submission_reflection r ON r.submission_id = s.id
WHERE t.task_type = 'challenge'`,
)
const pct = (n: number, d: number) => (d ? `${((n / d) * 100).toFixed(1)}%` : "—")
const chars = (s: string) => [...s.trim()].length
function stats(from: string, to: string) {
const inRange = (t: string) => t >= from && t < to
const ms = msgs.filter((m) => inRange(m.created))
const byConv = new Map<string, Msg[]>()
for (const m of ms) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m])
const convs = [...byConv.values()]
const ai = convs.filter((c) => c.some((m) => m.source === "conversation"))
const manualOnly = convs.filter((c) => c.some((m) => m.source === "manual") && !c.some((m) => m.source === "conversation"))
const rounds = ai.map((c) => c.filter((m) => m.source === "conversation").length)
const bucket = (lo: number, hi: number) => rounds.filter((n) => n >= lo && n <= hi).length
const asked = ms.filter((m) => m.source === "conversation")
const levels = [1, 2, 3, 4, 5, 6].map((l) => asked.filter((m) => m.prompt_level === l).length)
const leveled = levels.reduce((a, b) => a + b, 0)
const later = asked.filter((m) => (m.round ?? 1) >= 2 && m.prompt_level)
const vague = asked.filter((m) => chars(m.content) <= 15 && VAGUE.test(m.content))
const problem = asked.filter((m) => PROBLEM.test(m.content.trim()))
const manual = ms.filter((m) => m.source === "manual")
const s = subs.filter((x) => inRange(x.created))
return [
["有对话或版本的作品", `${convs.length}`],
["只粘贴外部代码", `${manualOnly.length}(${pct(manualOnly.length, convs.length)})`],
["和平台 AI 对话的作品", `${ai.length}`],
[" 平均轮数", ai.length ? (rounds.reduce((a, b) => a + b, 0) / ai.length).toFixed(2) : "—"],
[" 1 轮", pct(bucket(1, 1), ai.length)],
[" 2–3 轮", pct(bucket(2, 3), ai.length)],
[" 4–6 轮", pct(bucket(4, 6), ai.length)],
[" 7 轮以上", pct(bucket(7, Infinity), ai.length)],
["提问(AI 对话)", `${asked.length},已分级 ${leveled}`],
...levels.map((n, i) => [` L${i + 1}`, pct(n, leveled)]),
[" L4–L6", pct(levels[3]! + levels[4]! + levels[5]!, leveled)],
[" 第 2 轮起的 L1", pct(later.filter((m) => m.prompt_level === 1).length, later.length)],
[" 只说“不行”的短抱怨", `${vague.length}(${pct(vague.length, asked.length)})`],
[" 用“出问题了”", `${problem.length}(${pct(problem.length, asked.length)})`],
["用了“帮我写清楚”的作品", `${convs.filter((c) => c.some((m) => m.source === "guidance")).length}(占和 AI 对话的 ${pct(ai.filter((c) => c.some((m) => m.source === "guidance")).length, ai.length)})`],
["外部 AI 提示词", `${manual.length},12 字以内 ${pct(manual.filter((m) => chars(m.content) <= 12).length, manual.length)}`],
["挑战提交", `${s.length},写了反思 ${pct(s.filter((x) => x.reflected).length, s.length)}`],
]
}
const [a, b] = periods.map((p) => stats(p.from, p.to))
// 终端里中文占两格
const cols = (k: string) => [...k].reduce((n, ch) => n + (ch.codePointAt(0)! >= 0x2e80 ? 2 : 1), 0)
const width = Math.max(...a!.map(([k]) => cols(k!))) + 2
const pad = (k: string) => k + " ".repeat(width - cols(k))
console.log(`${pad("")}${periods[0]!.name} → ${periods[1]!.name}`)
for (const [i, [k, v]] of a!.entries()) console.log(`${pad(k!)}${v} → ${b![i]![1]}`)
+6
View File
@@ -47,6 +47,12 @@ CREATE INDEX IF NOT EXISTS "prompt_conversation_user_id_ecc418b3" ON "prompt_con
CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
-- 课堂总结统计提示词链用的覆盖索引:只读小列,不碰 content / code_*
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";
+9 -27
View File
@@ -20,7 +20,7 @@ const ORDER_BY: Record<string, string> = {
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
u.username, t.title AS task_title, t.display AS task_display,
u.username, t.title AS task_title, t.display AS task_display, t.task_type,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submissionaward sa
JOIN submission_submission s ON s.id = sa.submission_id
@@ -34,12 +34,17 @@ function itemManageOut(r: any) {
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
task_type: r.task_type,
score: r.score,
view_count: r.view_count,
sort_order: r.sort_order,
awarded_at: iso(r.awarded_at),
has_prompt_chain: bool(r.has_prompt_chain),
is_stale: bool(r.is_stale),
// 管理页的缩略图要用
html: r.html,
css: r.css,
js: r.js,
}
}
@@ -104,6 +109,7 @@ router.get("/", (c) => {
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
task_type: r.task_type,
score: r.score,
view_count: r.view_count,
html: r.html,
@@ -162,31 +168,6 @@ router.delete("/manage/awards/:award_id", (c) => {
return c.json({ message: "删除成功" })
})
router.get("/manage/submissions/:submission_id", (c) => {
requireAdmin(c)
const id = uuidParam(c, "submission_id")
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id
WHERE s.id = $id`,
{ id },
),
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/manage/awards/:award_id/items", (c) => {
requireAdmin(c)
const award = orNotFound(
@@ -301,7 +282,7 @@ router.get("/:submission_id", (c) => {
assertAwarded(id)
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, t.task_type,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
@@ -320,6 +301,7 @@ router.get("/:submission_id", (c) => {
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
task_type: s.task_type,
score: s.score,
view_count: s.view_count,
html: s.html,
+22 -1
View File
@@ -26,6 +26,7 @@ import {
getActiveConversation,
getPrecedingUserMessage,
insertMessage,
promptChainStats,
type MessageRow,
} from "../services/prompt"
import { pyRound } from "../services/pyround"
@@ -119,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(
@@ -130,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
@@ -202,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 &&
@@ -473,6 +486,8 @@ router.get("/stats/:task_id", (c) => {
present_count: studentSubs.filter((s) => s.flag === "present").length,
classes,
top_scored: topScored,
// 每人最新一版是怎么问出来的
prompts: promptChainStats([...latest.values()].map((s) => s.id)),
})
})
@@ -537,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 })
+59 -12
View File
@@ -1,18 +1,32 @@
import { all, get, run } from "../db"
import { complete, llmConfigured } from "./llm"
import { briefOf } from "./prompt"
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页制作课上让 AI 生成或修改网页,下面给出题目要求、学生上一轮的提问和这一轮的提问。请按布鲁姆认知分类学,判断“这一轮提问”体现的学生自己的思考层级。
层级定义:
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
按下面的顺序判断,命中哪一条就是哪一级:
1. L1 记忆:没有任何具体要求、解释或问题。只给作品名称、只报结果或只让 AI 修(例:"帮我做一个象棋游戏"、"制作一个吃豆人"、"继续"、"预览代码"、"还是没有显示"、"点击开始游戏进不去"、"帮我修复代码bug")
2. L2 理解:想弄懂原理、代码或概念(问为什么、是什么意思、怎么做到的),或者用自己的话解释现有代码在做什么(例:"为什么点了按钮没反应?"、"grid 和 flex 有什么区别"、"这段 js 里的 setInterval 是干什么的")
3. L4 分析:说清在哪个操作、哪个位置出了什么具体问题,或者自己推测原因、指出问题出在哪部分(例:"拖动绘制时松开鼠标还在画,是不是没监听 mouseup"、"缩小窗口后网格就不显示了"、"围棋我刚下一颗黑棋就判我赢了")
4. L5 评价:明确给出判断理由来评价现有结果,或者比较两种做法再选择;必须有“因为……所以……”“A 比 B 好”这样的理由或比较(例:"现在按钮太多,小学生看不懂,改成只留三个主要按钮"、"用 canvas 画比用一堆 div 流畅,改成 canvas")
5. L6 创造:在题目要求之外提出自己构思的设计,而且几部分互相配合成一个整体,如原创的主题、玩法、规则或交互(例:"改成星空主题:转盘是星座,抽中稀有奖励时流星划过并解锁对应星座的图鉴")
6. L3 应用:其余给出具体、可执行要求的,如颜色、风格、布局、要加或要改的功能(例:"方块大一点,旁边加一个橡皮擦和清空按钮"、"页面风格可爱,浅粉加浅蓝的渐变"、"加上撤销和保存为图片")
注意:
- 只看学生自己想出来的部分。照搬或改写题目要求里的功能清单是 L3,要求写得长、功能多不等于层级高。
- 只说想要什么风格、加什么功能,没有理由,是 L3 不是 L5;只列很多功能,没有自己的整体构思,是 L3 不是 L6。
- 只说“没反应”“进不去”“不能玩”“有 bug”,哪怕提到了是哪个按钮,也是 L1;要说出更具体的现象(如“打字速度始终是 0”)或推测原因才是 L4。
- 说明要改成什么样(如“弧线应该对着中圈”“球门要比禁区小”)是 L3,不是 L4。
- 整段贴了题目要求或一长串功能清单,不是 L1,是 L3。
- 结合上一轮看:重复上一轮的提问,层级不高于上一轮。
只返回一个数字(1-6),不要解释。`
const MIN_PROMPT_CHARS = 4
const PROMPT_CHARS = 1500
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function callLlm(content: string): Promise<number | null> {
@@ -23,7 +37,8 @@ async function callLlm(content: string): Promise<number | null> {
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
{ role: "user", content },
],
{ maxTokens: 10, timeoutMs: 30_000 },
// 只回一个数字,不用思考;关了思考 temperature 才生效,同一句提示词得到同一个层级
{ thinking: false, maxTokens: 10, temperature: 0, timeoutMs: 30_000 },
)
const match = /[1-6]/.exec(text)
if (!match) {
@@ -37,13 +52,45 @@ async function callLlm(content: string): Promise<number | null> {
}
}
function clip(text: string) {
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
}
// 发给模型的:题目要求、同一对话里上一轮的提问(引导对话只和引导对话比)、这一轮的提问
function classifyInput(msg: { id: number; content: string; source: string; conversation_id: string; created: string }) {
const task = get<{ title: string; content: string }>(
`SELECT t.title, t.content FROM prompt_conversation c JOIN task_task t ON t.id = c.task_id WHERE c.id = $conv`,
{ conv: msg.conversation_id },
)
const prev = get<{ content: string }>(
`SELECT content FROM prompt_message
WHERE conversation_id = $conv AND role = 'user' AND source != 'edit'
AND (source = 'guidance') = $guidance
AND (created < $created OR (created = $created AND id < $id))
ORDER BY created DESC, id DESC LIMIT 1`,
{ conv: msg.conversation_id, guidance: msg.source === "guidance", created: msg.created, id: msg.id },
)
return [
`题目:${task?.title ?? "(未知)"}`,
`题目要求:${task ? briefOf(task.content) : "(无)"}`,
`上一轮提问:${prev ? clip(prev.content) : "(这是第一轮)"}`,
`这一轮提问:${clip(msg.content)}`,
].join("\n\n")
}
export async function classifyMessage(messageId: number): Promise<number | null> {
const msg = get<{ content: string }>(
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user' AND source != 'edit'`,
const msg = get<{ id: number; content: string; source: string; conversation_id: string; created: string }>(
`SELECT id, content, source, conversation_id, created FROM prompt_message
WHERE id = $id AND role = 'user' AND source != 'edit'`,
{ id: messageId },
)
if (!msg) return null
const level = await callLlm(msg.content)
// “1”“,”“可以”这类太短,判断不出层级;清掉以前打的标签(--force 重标时)
if ([...msg.content.trim()].length < MIN_PROMPT_CHARS) {
run(`UPDATE prompt_message SET prompt_level = NULL WHERE id = $id`, { id: messageId })
return null
}
const level = await callLlm(classifyInput(msg))
if (level !== null) {
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
level,
+70 -34
View File
@@ -1,18 +1,29 @@
import { config } from "../config"
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
// AI 回复里的说明是学生唯一会读的文字,用它引导学生想下一步:
// “我替你定的”指出学生没说、AI 自己拿的主意,学生看了才会说“不要这样,因为……”;“想一想”只问不答
export const SYSTEM_PROMPT = `你是一个网页生成助手,和学生一起做网页。根据学生的描述生成网页代码。
规则:
1. 使用一个 \`\`\`html 代码块返回所有代码
1. 生成或修改网页时,使用一个 \`\`\`html 代码块返回所有代码
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
4. 用中文回复,先简要说明你做了什么,然后给出代码
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
4. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
5. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
6. 用中文回复,对学生称“你”。先写说明,再给代码。说明分下面三项,标题照抄,每项一两句话,不要写长:
- **做了什么**:这次按学生的要求做了哪些改动
- **我替你定的**:学生没说、你自己拿主意的地方,比如配色、布局、规则、数值、文字内容;列 1-3 条,只写在网页上看得到、学生可以要求改的。学生这次已经说清楚、没有需要你拿主意的地方,就省略这一项
- **想一想**:问学生一个问题,帮他想清楚下一步想要什么。问他自己的想法或判断(如“……的时候你希望它怎样?”“这两种你觉得哪个更适合……,为什么?”),不要列一串功能让他挑,也不要替他回答
7. 学生只是在问问题(为什么、是什么意思、怎么做到的),没有要求改网页时,用浅显的话直接回答,不给代码,也不用分三项
8. 学生只说“不行”“有 bug”“没反应”,没说清哪里不对时,照样尽力修好,并在“想一想”里请他下次说清楚:做了什么操作、想看到什么、实际看到了什么
输出格式示例(必须严格遵守,只用一个代码块):
好的,我为你创建了一个点击按钮变色的示例。
**做了什么**:做了一个按钮,点一下会变成红色。
**我替你定的**:按钮放在页面左上角;变色后不会再变回来。
**想一想**:再点一次的时候,你希望它变回原来的颜色,还是换成别的颜色?
\`\`\`html
<style>
@@ -28,23 +39,36 @@ document.getElementById('btn').onclick = function() {
</script>
\`\`\``
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
// 挑战题目要求只当对照清单:学生没说的不替他做,挑一项放进“想一想”问他打算怎么做,
// 要做成什么样还是学生自己说;整段贴了题目的照做(不拦,拦了只会去用外部 AI)
export const BRIEF_RULES = `下面是这道题的题目要求。它是对照清单,不是让你照着做——这是课堂练习,题目要求要学生自己一步步提出来,这正是这节课要练的:
- 只做学生在对话里说了的内容,做出来要能打开、能用。学生说的话里包含了题目要求(比如整段贴了题目),就照着做
- 题目要求里学生还没提到的功能、规则和细节(如计时方式、对错的颜色、计分、重来按钮),哪怕很常见、顺手就能做,也先不做;也不要照搬题目里的类名、变量名和数据
- 从学生没提到的要求里挑一项最重要的放进“想一想”,问他打算怎么做(如“题目还要求标出打错的字,你希望打错的时候是什么样子?”)
- 学生要的和题目要求冲突时,按学生说的做,在“想一想”里提醒他题目是怎么要求的
- 题目要求都做到了,“想一想”就问他自己的想法,不再提题目`
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
判断标准——看学生这一次的话,满足下面一条就算"够好":
- 第一次描述作品:除了题目要求,有他自己的想法,至少有一处具体的视觉、交互或内容是他自己定的(如配色风格、玩法规则、页面上放什么)
- 修改已有网页:说清楚要改哪里、改成什么样;或者说清楚出了什么问题——做了什么操作、想看到什么、实际看到了什么
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
- 只有作品名称,如"做一个象棋游戏"
- 照搬或改写题目要求,没有自己的想法——哪怕写得很长、功能很多
- 只说"不行""有 bug""再好看一点",没说是哪里、要怎样
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
6. 不要生成任何代码`
1. 如果不够好,用 1-2 个启发性问题引导学生补充,问他自己的想法,不要直接给出答案,也不要列一串功能让他挑
2. 照搬题目要求时,先肯定他读懂了题目,再问题目之外他想让作品有什么不一样
3. AI 上一次的说明里如果有“我替你定的”,可以问他同不同意这些决定
4. 如果已经够好,以 [READY] 开头回复,简短说明好在哪里,告诉他可以生成了;他还给了理由(为什么这样改)的,特别夸一下
5. 用中文回复,语气鼓励,简洁明了
6. 使用 Markdown 语法高亮关键词,优先突出 **视觉**、**交互**、**内容**、**你的想法**、**可以生成** 等重点
7. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
8. 不要生成任何代码`
// 走火山方舟(ARK)接口的模型
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
@@ -59,10 +83,14 @@ export interface ChatMessage {
content: string
}
// DeepSeek(deepseek-flash)默认先思考再回答,思考的内容在 reasoning_content 里,也算输出 token;
// 思考模式下 temperature 不起作用。thinking 只发给 DeepSeek,豆包不传
interface CompletionOptions {
model?: string
stream: boolean
thinking?: boolean
maxTokens?: number
temperature?: number
signal?: AbortSignal
timeoutMs?: number
}
@@ -81,7 +109,9 @@ function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
messages,
stream: opts.stream,
}
if (!ark && opts.thinking !== undefined) body.thinking = { type: opts.thinking ? "enabled" : "disabled" }
if (opts.maxTokens) body.max_tokens = opts.maxTokens
if (opts.temperature !== undefined) body.temperature = opts.temperature
const apiKey = ark ? config.arkApiKey : config.llmApiKey
if (!apiKey) throw new Error("未配置模型接口密钥")
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
@@ -107,8 +137,7 @@ async function ensureOk(res: Response) {
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
async function* streamCompletion(
messages: ChatMessage[],
model?: string,
signal?: AbortSignal,
{ model, thinking, signal }: { model?: string; thinking?: boolean; signal?: AbortSignal },
): AsyncGenerator<string> {
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
const idle = new AbortController()
@@ -123,6 +152,7 @@ async function* streamCompletion(
const res = await buildRequest(messages, {
model,
stream: true,
thinking,
signal: AbortSignal.any(signals),
timeoutMs: STREAM_MAX_MS,
})
@@ -139,6 +169,7 @@ async function* streamCompletion(
if (data === "[DONE]") return true
const json = JSON.parse(data)
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
// 思考阶段只有 delta.reasoning_content,不发给学生(它也会重置空闲计时)
const content = json.choices?.[0]?.delta?.content
if (content) yield content
}
@@ -158,14 +189,15 @@ async function* streamCompletion(
}
}
// 发给模型的对话上下文:只留最近 20 条;除最后一条 AI 回复外,旧回复里的代码块换成占位文字。
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度
// 发给模型的对话上下文:只留最近 20 条;除最后一条带代码的 AI 回复外,旧回复里的代码块换成占位文字。
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度。
// 只回答问题的回复没有代码,不能拿它当“最后一条”,不然最新的代码也被省略了
const MAX_HISTORY = 20
const CODE_BLOCK = /```[\s\S]*?```/g
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
const recent = history.slice(-MAX_HISTORY)
const last = recent.findLastIndex((m) => m.role === "assistant")
const last = recent.findLastIndex((m) => m.role === "assistant" && m.content.includes("```"))
return recent.map((m, i) =>
m.role === "assistant" && i !== last
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
@@ -185,30 +217,34 @@ export function userFacingError(e: unknown) {
return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试"
}
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
return streamCompletion(
[{ role: "system", content: SYSTEM_PROMPT }, ...trimHistory(history)],
chatModel(model),
// 生成整页代码:开着思考,质量优先。brief 是挑战的题目要求(见 challengeBrief),自由创作没有
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal, brief?: string | null) {
const system = brief ? `${SYSTEM_PROMPT}\n\n${BRIEF_RULES}\n\n${brief}` : SYSTEM_PROMPT
return streamCompletion([{ role: "system", content: system }, ...trimHistory(history)], {
model: chatModel(model),
thinking: true,
signal,
)
})
}
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
return streamCompletion(
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
undefined,
// 引导只是简短追问,不用思考,回得快。context 是题目和学生之前的对话(见 guidanceContext)
export function streamGuidance(history: ChatMessage[], context: string, signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: `${GUIDANCE_SYSTEM_PROMPT}\n\n${context}` }, ...history], {
thinking: false,
signal,
)
})
}
export async function complete(
messages: ChatMessage[],
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
opts: { model?: string; thinking?: boolean; maxTokens?: number; temperature?: number; timeoutMs?: number } = {},
): Promise<string> {
const res = await buildRequest(messages, {
model: opts.model ?? config.llmModel,
stream: false,
thinking: opts.thinking,
maxTokens: opts.maxTokens,
temperature: opts.temperature,
timeoutMs: opts.timeoutMs,
})
await ensureOk(res)
+92
View File
@@ -1,5 +1,6 @@
import { all, get, run, now, newUuidHex } from "../db"
import { Role, type User } from "../auth"
import { pyRound } from "./pyround"
export interface MessageRow {
id: number
@@ -98,6 +99,62 @@ export function codeAsMarkdown(code: {
return "```html\n" + parts.join("\n\n") + "\n```"
}
// 题目要求去掉图片、压成一段,太长的截掉
const BRIEF_CHARS = 800
export function briefOf(content: string, max = BRIEF_CHARS) {
const text = content
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\s+/g, " ")
.trim()
return clipTo(text, max)
}
function clipTo(text: string, n: number) {
return text.length > n ? text.slice(0, n) + "……" : text
}
// 挑战的题目要求,给生成网页的 AI 当对照清单(见 llm.ts 的 BRIEF_RULES);要逐条对照,不能截得太短。
// 自由创作的题目是学生自己写的介绍,不用对照,返回 null
export function challengeBrief(taskId: number) {
const task = get<{ title: string; content: string }>(
`SELECT title, content FROM task_task WHERE id = $id AND task_type = 'challenge'`,
{ id: taskId },
)
const brief = task && briefOf(task.content, 4000)
return brief ? `题目:${task.title}\n\n题目要求:${brief}` : null
}
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
// 题目要求、学生最近几次对 AI 说的话、AI 上一次的说明(代码去掉)
export function guidanceContext(conversationId: string, taskId: number) {
const task = get<{ title: string; content: string }>(
`SELECT title, content FROM task_task WHERE id = $id`,
{ id: taskId },
)
const asked = all<{ content: string }>(
`SELECT content FROM prompt_message
WHERE conversation_id = $id AND role = 'user' AND source IN ('conversation', 'manual')
ORDER BY created DESC, id DESC LIMIT 5`,
{ id: conversationId },
).reverse()
const reply = get<{ content: string }>(
`SELECT content FROM prompt_message
WHERE conversation_id = $id AND role = 'assistant' AND source = 'conversation'
ORDER BY created DESC, id DESC LIMIT 1`,
{ id: conversationId },
)
const replyText = reply?.content.replace(/```[\s\S]*?(```|$)/g, "").trim()
return [
`题目:${task?.title ?? "(未知)"}`,
`题目要求:${task ? briefOf(task.content) || "(无)" : "(无)"}`,
`学生之前对 AI 说过的话:${
asked.length ? asked.map((m, i) => `\n${i + 1}. ${clipTo(m.content.trim(), 300)}`).join("") : "(还没有,这是第一次描述作品)"
}`,
`AI 上一次的说明:${replyText ? clipTo(replyText, 600) : "(无)"}`,
].join("\n\n")
}
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
return get<MessageRow>(
`SELECT * FROM prompt_message
@@ -141,3 +198,38 @@ export function buildPromptRounds(source: MessageRow) {
}
return rounds
}
// 课堂总结:这些提交各自是怎么问出来的(只走覆盖索引,不读提问和代码)
export function promptChainStats(submissionIds: string[]) {
type Slim = { conversation_id: string; created: string; id: number; role: string; source: string; prompt_level: number | null }
const finals = all<Omit<Slim, "role" | "prompt_level">>(
`SELECT conversation_id, created, id, source FROM prompt_message
WHERE submission_id IN (SELECT value FROM json_each($ids))`,
{ ids: JSON.stringify(submissionIds) },
)
const byConv = new Map<string, Slim[]>()
const msgs = all<Slim>(
`SELECT conversation_id, created, id, role, source, prompt_level FROM prompt_message
WHERE conversation_id IN (SELECT value FROM json_each($convs))`,
{ convs: JSON.stringify([...new Set(finals.map((f) => f.conversation_id))]) },
)
for (const m of msgs) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m])
const out = { with_chain: finals.length, one_round: 0, external: 0, used_guidance: 0, avg_rounds: 0 }
const levels = [0, 0, 0, 0, 0, 0]
let rounds = 0
for (const f of finals) {
// 和 buildPromptRounds 一样:只算交的那一版之前的提问
const asked = (byConv.get(f.conversation_id) ?? []).filter(
(m) => m.role === "user" && (m.created < f.created || (m.created === f.created && m.id < f.id)),
)
const code = asked.filter((m) => m.source !== "guidance")
rounds += code.length
if (code.length === 1) out.one_round++
if (f.source === "manual") out.external++
if (code.length < asked.length) out.used_guidance++
for (const m of code) if (m.prompt_level) levels[m.prompt_level - 1]!++
}
out.avg_rounds = finals.length ? pyRound(rounds / finals.length, 1) : 0
return { ...out, levels }
}
+20 -5
View File
@@ -3,8 +3,10 @@ import { all, get, run, iso, uuidOut } from "./db"
import { userFromAccessToken, type User } from "./auth"
import {
canAccessTask,
challengeBrief,
codeAsMarkdown,
getOrCreateActiveConversation,
guidanceContext,
insertMessage,
} from "./services/prompt"
import {
@@ -15,6 +17,7 @@ import {
userFacingError,
type ChatMessage,
} from "./services/llm"
import { classifyMessage, inBackground } from "./services/classifier"
type Kind = "prompt" | "guidance"
@@ -116,11 +119,12 @@ async function handlePrompt(ws: WS, data: any) {
const model = String(data.model ?? "")
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
const userMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
})
ws.data.pendingUserMessageId = userMessageId
try {
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
role: m.role as ChatMessage["role"],
@@ -129,7 +133,8 @@ async function handlePrompt(ws: WS, data: any) {
}))
let full: string
try {
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
const brief = challengeBrief(ws.data.taskId)
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal, brief))
} catch (e) {
// 客户端自己断开导致的中止不算出错
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
@@ -146,8 +151,12 @@ async function handlePrompt(ws: WS, data: any) {
code_css: code.css,
code_js: code.js,
})
// 有了回复,这条提问才留下来;在后台打 L1–L6 层级
inBackground(classifyMessage(userMessageId))
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", code, message_id: messageId })
// 只回答问题、没给代码的回复不是新版本;发 null,免得工作台把网页清空
const hasCode = !!(code.html || code.css || code.js)
send(ws, { type: "complete", code: hasCode ? code : null, message_id: messageId })
} finally {
dropPendingUserMessage(ws)
}
@@ -157,17 +166,22 @@ async function handleGuidance(ws: WS, data: any) {
const prompt = String(data.content ?? "").trim()
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
const userMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
source: "guidance",
})
ws.data.pendingUserMessageId = userMessageId
ws.data.guidanceHistory.push({ role: "user", content: prompt })
try {
let full: string
try {
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
const context = guidanceContext(ws.data.conversationId, ws.data.taskId)
full = await streamToClient(
ws,
streamGuidance(ws.data.guidanceHistory, context, ws.data.abort.signal),
)
} catch (e) {
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
ws.data.guidanceHistory.pop()
@@ -185,6 +199,7 @@ async function handleGuidance(ws: WS, data: any) {
content: clean,
source: "guidance",
})
inBackground(classifyMessage(userMessageId))
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
} finally {
+75 -1
View File
@@ -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: "<p>1", css: "", js: "" })
const sub = await stu("POST", "/api/submission/", {
task_id: task,
html: "<p>1",
message_id: edit.json.message_id,
reflection: " 按钮改成三个,因为五个太乱 ",
})
const id = sub.json.id
await stu("POST", "/api/submission/", { task_id: task, html: "<p>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: "<p>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")
@@ -415,7 +443,7 @@ describe("作品展", () => {
const author1 = await client("author")
expect((await author1("GET", "/api/submission/showcase/")).json).toHaveLength(1)
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json.awards).toEqual(["最佳"])
expect((await author1("GET", `/api/submission/showcase/${uuid(sub)}/`)).json).toMatchObject({ awards: ["最佳"], task_type: "challenge" })
await adm("PUT", `/api/submission/showcase/manage/awards/${award.id}`, { name: "最佳", is_active: false })
expect((await author1("GET", "/api/submission/showcase/")).json).toEqual([])
@@ -645,4 +673,50 @@ describe("课堂总结", () => {
const all = await adm("GET", `/api/submission/stats/${task}`)
expect(all.json.top_scored.map((t: any) => t.username)).toEqual(["weba2小李", "weba1小明"])
})
test("统计每人最新一版的提示词链", async () => {
const { createConversation, insertMessage } = await import("../src/services/prompt")
makeUser("adm", "admin")
const task = makeTask("挑战")
const say = (conv: string, source: string, level: number | null, submission_id?: string) => {
const id = insertMessage({ conversation_id: conv, role: "user", source, content: "提问" })
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, { level, id })
insertMessage({ conversation_id: conv, role: "assistant", source, content: "回答", submission_id })
}
// 小明:AI 对话 → 引导 → 外部 AI,交最后这版;交了之后的提问不算
const a = makeUser("weba1小明", "normal", "a1")
const convA = createConversation(a, task)
say(convA, "conversation", 3)
say(convA, "guidance", 2)
say(convA, "manual", 6, makeSubmission(a, task))
say(convA, "conversation", 1)
// 小红:一轮就交
const b = makeUser("weba1小红", "normal", "a1")
say(createConversation(b, task), "conversation", 1, makeSubmission(b, task))
// 小刚:手动交的,没有提示词
makeSubmission(makeUser("weba1小刚", "normal", "a1"), task)
const res = await (await client("adm"))("GET", `/api/submission/stats/${task}`)
expect(res.json.prompts).toEqual({
with_chain: 2,
one_round: 1,
external: 1,
used_guidance: 1,
avg_rounds: 1.5,
levels: [1, 0, 1, 0, 0, 1],
})
})
})
describe("提示词分类", () => {
test("太短的提问不打层级,以前打的也清掉", async () => {
const { classifyMessage } = await import("../src/services/classifier")
const { createConversation, insertMessage } = await import("../src/services/prompt")
const conv = createConversation(makeUser("stu"), makeTask("T"))
const id = insertMessage({ conversation_id: conv, role: "user", content: " 1 " })
run(`UPDATE prompt_message SET prompt_level = 6 WHERE id = $id`, { id })
expect(await classifyMessage(id)).toBeNull()
const row = get<{ prompt_level: number | null }>(`SELECT prompt_level FROM prompt_message WHERE id = $id`, { id })
expect(row!.prompt_level).toBeNull()
})
})
+20 -2
View File
@@ -5,6 +5,7 @@ import {
extractCode,
parseGuidanceResponse,
streamChat,
streamGuidance,
trimHistory,
userFacingError,
} from "../src/services/llm"
@@ -77,12 +78,13 @@ test("CSV 转义", () => {
})
describe("streamChat", () => {
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
test("不认识的模型换成默认模型,最后一行没有换行也能读到;生成代码开思考,引导不开", async () => {
let model = ""
let thinking: unknown
const server = Bun.serve({
port: 0,
async fetch(req) {
model = ((await req.json()) as any).model
;({ model, thinking } = (await req.json()) as any)
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
// 一行拆在两个分块之间,最后一行不带换行
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
@@ -108,6 +110,9 @@ describe("streamChat", () => {
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
expect(out).toBe("你好世界")
expect(model).toBe(config.llmModel)
expect(thinking).toEqual({ type: "enabled" })
for await (const _ of streamGuidance([{ role: "user", content: "hi" }], "题目:x"));
expect(thinking).toEqual({ type: "disabled" })
} finally {
Object.assign(config, saved)
server.stop(true)
@@ -135,6 +140,19 @@ test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => {
expect(out.filter((m) => m.content.includes("<p>"))).toHaveLength(1)
})
test("trimHistory 最后一条回复没有代码时,保留之前最新的那份代码", () => {
const out = trimHistory([
{ role: "user", content: "做个页面" },
{ role: "assistant", content: "第1版\n```html\n<p>1</p>\n```" },
{ role: "user", content: "加个按钮" },
{ role: "assistant", content: "第2版\n```html\n<p>2</p>\n```" },
{ role: "user", content: "为什么按钮是圆的?" },
{ role: "assistant", content: "因为加了 border-radius。" },
])
expect(out[3]!.content).toContain("<p>2</p>")
expect(out[1]!.content).not.toContain("<p>1</p>")
})
test("userFacingError 不透出接口原始返回", () => {
const quiet = console.error
console.error = () => {}
+89 -5
View File
@@ -2,18 +2,25 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
import type { Server } from "bun"
import { app } from "../src/app"
import { config } from "../src/config"
import { all } from "../src/db"
import { all, run } from "../src/db"
import { upgrade, websocket, type WsData } from "../src/ws"
import { client, freshDb, makeTask, makeUser } from "./helpers"
// 假的模型接口:每段之间停一下,方便测试中途断开
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
// 最近一次发给模型的请求
const DEFAULT_PIECES = ["好的", "```html\n<p>hi</p>\n```"]
let PIECES = DEFAULT_PIECES
// 最近一次发给模型的请求;分类(非流式)单独记,固定回“3”
let lastRequest: any = null
let lastClassify: any = null
const llm = Bun.serve({
port: 0,
async fetch(req) {
lastRequest = await req.json()
const body: any = await req.json()
if (!body.stream) {
lastClassify = body
return Response.json({ choices: [{ message: { content: "3" } }] })
}
lastRequest = body
const enc = new TextEncoder()
const stream = new ReadableStream({
async start(ctrl) {
@@ -50,7 +57,10 @@ afterAll(() => {
llm.stop(true)
})
beforeEach(freshDb)
beforeEach(() => {
freshDb()
PIECES = DEFAULT_PIECES
})
interface Conn {
ws: WebSocket
@@ -106,6 +116,19 @@ describe("WebSocket 代码生成", () => {
c.ws.close()
})
test("回复完后在后台给提问打层级", async () => {
const { task, token } = await setup()
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
await c.next("complete")
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE role = 'user'`)[0]?.prompt_level
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
expect(level()).toBe(3)
expect(lastClassify.thinking).toEqual({ type: "disabled" })
c.ws.close()
})
test("AI 回复中再发消息会被拒绝", async () => {
const { task, token } = await setup()
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
@@ -157,9 +180,70 @@ describe("WebSocket 代码生成", () => {
c.ws.close()
})
test("只回答问题、没给代码时 code 是 null", async () => {
const { task, token } = await setup()
PIECES = ["因为加了 ", "border-radius。"]
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "为什么按钮是圆的?" }))
const done = await c.next("complete")
expect(done.code).toBeNull()
expect(done.message_id).toBeGreaterThan(0)
c.ws.close()
})
test("挑战的题目要求作为对照清单发给 AI,自由创作不发", async () => {
const { task, token } = await setup()
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,有开始和暂停按钮", id: task })
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
await c.next("complete")
expect(lastRequest.messages[0].content).toContain("它是对照清单")
expect(lastRequest.messages[0].content).toContain("题目要求:做一个计时器,有开始和暂停按钮")
c.ws.close()
const creation = makeTask("我的作品", "creation", 2)
run(`UPDATE task_task SET content = '我想做一个相册' WHERE id = $id`, { id: creation })
const d = (await connect(`/ws/prompt/${creation}/?token=${token}`))!
await d.next("init")
d.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
await d.next("complete")
expect(lastRequest.messages[0].content).not.toContain("题目要求")
d.ws.close()
})
test("token 无效或任务不存在时握手失败", async () => {
const { task, token } = await setup()
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull()
})
})
describe("WebSocket 帮我写清楚", () => {
test("教练能看到题目要求、之前的提问和 AI 上一次的说明", async () => {
const { task, token } = await setup()
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,![图](a.png) 有开始和暂停按钮", id: task })
const p = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await p.next("init")
p.ws.send(JSON.stringify({ type: "message", content: "做个计时器" }))
await p.next("complete")
p.ws.close()
const g = (await connect(`/ws/guidance/${task}/?token=${token}`))!
await g.next("init")
g.ws.send(JSON.stringify({ type: "message", content: "改成粉色" }))
await g.next("complete")
const system = lastRequest.messages[0]
expect(system.role).toBe("system")
expect(system.content).toContain("题目要求:做一个计时器, 有开始和暂停按钮")
expect(system.content).toContain("1. 做个计时器")
expect(system.content).toContain("AI 上一次的说明:好的")
expect(system.content).not.toContain("<p>hi</p>")
expect(lastRequest.messages.at(-1)).toEqual({ role: "user", content: "改成粉色" })
// 等后台打完层级再结束,不然假接口关了它会报错
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE source = 'guidance' AND role = 'user'`)[0]?.prompt_level
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
g.ws.close()
})
})