Compare commits

..
41 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
xuyueandClaude Opus 5.5 06267fb0ff feat: 重新设计课堂总结弹框
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
默认看批改页当前的班级;顶部四个数字(已交、平均分、改过再交、待讲评),
未交和交了没人评的名单按班级分组并排显示;人气前五换成高分作品(每人最高的一版),
点开直接进批改页。接口去掉旧的四色标记统计和 top_viewed,新增 present_count、top_scored。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:31:29 +08:00
xuyueandClaude Opus 5.5 e4fb723544 fix: 老师看作品不用先交;补学生看/评同学作品的权限漏洞;随机评分改成弹框
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 老师打开"作品"直接看,显示作者和得分;"同学作品"改名"作品"
- 随机评分只抽学生自己交过的题,不再泄露没做过的题的答案
- GET /submission/:id 学生打分前不返回同学的作者和得分(作品展除外)
- 打分要先能看这份提交
- AI 生成时随机评分弹框遮住页面,写完时正在看的那份留着,换成"看结果"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:41:58 +08:00
xuyueandClaude Opus 5.5 0b3780e0cd fix: 批改页 J/K 按键位对调;预览换内容不再进浏览器历史,“后退”能回到作品墙
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- J 上一个、K 下一个,和面板上的左右箭头一致
- 打开的作品放进地址栏(?sub=):点开记一步历史,J/K 切换只替换,关闭 / Esc 退回那一步
- 所有会换内容的预览 iframe 加 key,换内容时换新的 iframe:在原 iframe 里改 srcdoc 是一次导航,
  会进浏览器历史,“后退”就成了在预览里倒退(批改页切换学生、挑战页改代码都会这样)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:31:29 +08:00
xuyueandClaude Opus 5.5 75e20a3ae1 feat: 同学作品和批改都能看全部班级,批改默认大图预览
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 学生“同学作品”加“本班 / 全部班级”切换(peers?scope=all),作品标班级,仍是交过才开放、打分前匿名;
  服务端放开:交过某题后能看任何班同学在这道题上的提交
- 批改页班级下拉加“全部班级”:一道题全校作品按班级分组,没人交的班收起成一行;
  /review/class、/review/progress 不传 classname 就是全部班级
- 点开作品默认大图:作品墙收起,预览占满中间,打分 / 课上展示 / 提示词放右栏;
  Esc 返回作品墙,可切回小面板(记住选择);成绩册弹出的详情也用大图布局

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:22:41 +08:00
xuyueandClaude Opus 5.5 8653d411a6 feat: 改版阶段 4:挑战列表页、教程按小节存代码、出题页合并、用户管理、整站暗色
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 挑战列表改成独立页面(ChallengeHome):每题一行,完成状态、分值、出题人,可按未做 / 已交筛选;
  教程面板只放教程
- 教程每一节的代码各存一份(和挑战同一个按任务的 key),切回来时还在,第一次才放示例代码;
  提交列表“复制到编辑器”对教程也写入该任务的代码
- 教程 / 挑战出题页合并为 TaskEditor:“提交”改为“保存”,保存后停在当前项,删除前说明会连带删除学生提交;
  admin 也能在列表里切换挑战是否公开(保存时本来就能设)
- 用户管理:初始密码默认隐藏,封号前确认,批量新建说明用户名规则
- 整站暗色:顶栏切换跟随系统 / 亮色 / 暗色;Naive 暗色主题、markdown 和代码高亮的暗色覆盖
  (styles/dark-content.css 由 GitHub 暗色样式生成)、CodeMirror One Dark 配色、md-editor 暗色;
  组件里剩下的硬编码颜色全部换成配色变量

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:17:43 +08:00
xuyueandClaude Opus 5.5 df3f842dac feat: 改版阶段 3:老师按班级 × 任务批改,学生看本班同学作品
老师
- 新的“批改”页(教学默认页):左边班级 + 任务列表和完成进度,中间作品墙
  (每人最新一版、未交 / 我未评 / 分数),右侧详情面板:各版本、提示词链、打分、
  加入课上展示、加入作品展;J/K 切换学生,1–5 打分,P 课上展示
- “开始讲评”全屏依次播放课上展示的作品;成绩册点分数打开同一个详情面板
- 四色标记和“一键清除全站标记”去掉,flag 只用 present(课上展示);全部提交只给超管

学生
- “同学作品”:只看本班、自己交过这道题才开放,打分前匿名(服务端不返回作者)
- 学生的提交列表只有自己的;别人的提交和提示词链只有本班同学交过同一题、或进了作品展才能看
- 随机互评不再返回作者,打完分由 PUT /:id/score 返回

服务端新增 routes/review.ts(/submission/review:options、progress、class、peers),
查询走 slim 覆盖索引

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:10:18 +08:00
xuyueandClaude Opus 5.5 9c6c01eba1 feat: 改版阶段 2:挑战和自由创作共用工作台,学生自己选版本提交
- 新的 Workbench:左边题目(可收起)+ AI 对话,右边预览 / 代码(可编辑),底部版本条;
  挑战页和自由创作页都用它,原来的“手动提交”“历史对话”标签换成“粘贴外部代码”和“全部历史”
- 不再每轮 AI 回复自动提交:学生点“交这一版”,可交多次,成绩仍取最高分;已交的版本不能重复点
- 手改代码、粘贴外部 AI 代码都存成版本(POST /prompt/versions,source 为 edit / manual),
  在版本条和提示词链里标“手改”“外部 AI”;它们进入 AI 上下文,AI 在手改后的代码上继续改;
  以前手动提交的回复正文为空时用代码补上;edit 的占位提问不参与提示词层级分类
- 互评从全屏弹窗改成对话里的卡片,打分前不显示作者,每个分数写明含义
- 编辑器代码按任务各存一份(useTaskCode),挑战页“清空”不再清掉教程的代码;
  提交列表“复制到编辑器”对挑战写入该任务自己的代码
- 抽出 utils/code.ts(整理代码、拆分整页 HTML);删除 RandomRatingModal、ExternalAIPanel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:59:24 +08:00
xuyueandClaude Opus 5.5 ff1eb2982e feat: 改版阶段 1:统一顶栏和教学侧栏、配色和图标规范、路由角色守卫
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 所有页面共用顶栏(教程 / 挑战 / 自由创作 / 作品展,右边“教学”和用户菜单);
  没登录时顶栏有“登录”按钮,关掉登录框不再白屏;/submission/:id 全屏页不带顶栏
- 后台改成“教学”侧栏,分出题 / 班级 / 管理三组;“全部提交”留在侧栏里打开;
  admin 也能进作品展管理(后端本来就允许)
- 路由按角色守卫:meta.role 为 staff / super,学生和 admin 进不了无权页面
- 配色只在 theme.ts 定义,同时生成 --wp-* CSS 变量和 Naive UI 主题,暗色值先定好
- 图标改用打包进来的 lucide(断网也能显示),去掉 @iconify/vue 和 PUBLIC_ICONIFY_URL;
  代码字体换成打包的 JetBrains Mono,编辑器默认字号 24 → 16
- 教程面板去掉和顶栏重复的切换和图标按钮,图标按钮都补上文字或提示

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:39:55 +08:00
xuyueandClaude Opus 5.5 e47fac1732 fix: 出题页新建会覆盖最后一道题;自由创作预览数据存到了错误的任务下
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 教程 / 挑战编辑器“新建”时序号没有加 1(?? 的优先级),而保存按序号更新,
  新建后保存会覆盖列表里最后一个任务
- 预览的 localStorage 作用域改由页面传入任务 id;自由创作页用的是局部 taskId,
  之前读全局 taskId 会把数据存到上一个打开过的任务名下

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:40:35 +08:00
xuyueandClaude Opus 5.5 ffdfbc2513 refactor: 精简发给模型的上下文、隐藏接口原始报错,合并重复的删除逻辑
- 发给模型的对话只留最近 20 条,除最后一条 AI 回复外旧回复的代码块换成占位文字,
  对话越长越省 token,也不会超出上下文长度
- 学生看到的 AI 报错只显示超时 / 繁忙 / 出错(状态码),完整错误写日志;
  客户端自己断开导致的中止不再记错误
- 管理员查询对话必须带 task_id 或 user_id,不再一次查出全站所有对话
- “已评分或获奖不能删”和“删除一轮对话”合并到 services/cascade.ts,补上删除一轮对话的测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:26:37 +08:00
xuyueandClaude Opus 5.5 325474bdb7 perf: 首屏 JS 减少 2/3、随机评分查询提速,WebSocket 防刷和登录限流
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- prettier 点“整理”时才加载,编辑器页面按路由懒加载:首屏 JS 2345 KB → 766 KB
- 随机评分先随机抽 64 个 rowid 挑选,抽不中再退回全表随机:6 万条提交时 50ms → 1ms
- WebSocket 消息最大 64 KB;AI 回复中再发来的消息直接拒绝,不再排队
- 同一用户名 10 分钟失败 10 次、同一 IP 失败 100 次后暂停登录(返回 429,登录框显示提示)
- 作品展的 iframe 加 loading="lazy"
- 新增 ws.test.ts(正常生成、回复中再发消息、中途断开、握手失败)和随机评分、登录限流的测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:19:50 +08:00
xuyueandClaude Opus 5.5 bd84e3f8d1 build: 恢复前端类型检查
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
vue-tsc 不支持 TypeScript 7,前端单独用 TS 5.9 + vue-tsc(服务端仍是 TS 7),
根目录 bun run typecheck 同时检查两边。修掉前端原有的 8 个类型错误(未使用的变量、类型标注)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:03:51 +08:00
xuyueandClaude Opus 5.5 03878f6cc2 fix: 提交列表和删除提交的慢查询,素材上传、登出和几处前端加载问题
性能(6 万条提交、18 万条消息的测试库上测的)
- 提交列表 join task_task 时用 +s.task_id,不再从任务表出发经 task_id 单列索引回表读
  html/css/js 大行,改走 slim 覆盖索引:翻页 260-700ms → 4-18ms
- 删除提交时先查出 id 再按等值删关联行:prompt_message.submission_id 几乎全是 NULL,
  写成 IN (子查询) 时统计信息会让 SQLite 扫整张表

bug
- 提交列表排序加 rowid 作次级键,同分的提交翻页不再重复或遗漏
- 素材文件名只允许字母数字和 - _ .(否则 422),覆盖同名文件改成写临时文件再 rename;
  前端删除时对名字做 URL 编码
- 刷新 token 只是网络失败时不再强制登出;退出请求失败时界面也显示为已退出
- 历史面板加载中收到的刷新不再丢掉;成绩册只用最后一次请求的结果,打开时不再加载两次

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:03:51 +08:00
xuyueandClaude Opus 5.5 40f3063b91 feat: 预览里的学生代码可以用 localStorage
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
预览 iframe 没有 allow-same-origin,访问 localStorage 会抛异常。现在在学生代码之前
注入一个替身(支持常用方法、属性读写、Object.keys 等):

- 自己的编辑器(教程、挑战、自由创作):写入通过 postMessage 交给主页面,按“用户 + 任务”
  存到本站 localStorage 的 preview-storage:* 下,下次渲染注入回去,刷新后数据还在
- 看别人的作品:只存在内存里
- 单个任务最多 10 万字符,总共 50 万字符,超出时先删最久没用的,不挤占登录 token 的配额
- 下载的 index.html 不带替身

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:55:31 +08:00
xuyueandClaude Opus 5.5 dc91b9b5db chore: 数据已迁到 SQLite,删掉 Postgres 迁移脚本、webpreview-db 服务和迁移文档
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
school 服务器以后要切换时,从 cdeb7c1 里找回(deploy.yaml 里写了)。
备份方法挪到 CLAUDE.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:36:33 +08:00
xuyueandClaude Opus 5.5 cdeb7c18ef fix: 修复中危问题:注册接口、模型名、LLM 超时、WebSocket 断线、未公开任务泄露等
服务端
- 删掉没人用的 /account/register,外网不能再批量注册小号
- 模型名只放行豆包和配置的 LLM_MODEL,浏览器里存的旧模型名换成默认模型;
  聊天默认模型改读 LLM_MODEL
- 流式生成改成 120 秒无数据才超时(另有 15 分钟总上限),思考模型不会被中途掐断;
  SSE 最后一行没有换行时不再丢内容
- WebSocket:token 无效 401、任务不存在 404,建对话失败时关闭连接;
  引导出错时把没有回复的提问移出上下文
- 未公开的教程只有超管能看,未公开的挑战和素材管理员也能看,其他人 404
- Caddy:/media 加 CSP sandbox 和 nosniff,上传的 html/svg 直接打开不能跑脚本;
  加 CORS,预览 iframe 里的代码可以读取字体和 fetch 素材

前端
- 新增 ReconnectingSocket:意外断开后退避重连,握手失败先刷新 token;
  断线时清掉“生成中”状态,旧连接的事件不再改写新连接
- marked 扩展抽到 utils/markdown.ts:教程用独立实例,不再改全局 marked;
  学生内容的渲染保留高亮,不带会渲染原始 HTML 的 preview 扩展
- 挑战页、创作页加载完成前离开时不再建立连接,离开时关闭引导弹窗
- 教程、提交列表、用户管理跟随浏览器前进后退;快速翻页只用最后一次结果,
  改筛选条件不再请求两次
- 随机评分弹窗在 AI 回复完之后不再弹出

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:33:44 +08:00
xuyueandClaude Opus 5.5 73788c4595 fix: 修复学生代码窃取 token、提示词链 XSS 和打分没有上限的问题
- 运行提交代码的 iframe(编辑器预览、随机评分弹窗、/submission/:id、全屏预览)
  加上 sandbox,不给 allow-same-origin,代码读不到 localStorage 里的 token
- 提示词和 AI 回复的 markdown 渲染时转义原始 HTML,链接只放行 http(s)/mailto;
  ChainModal 也改用这个渲染函数
- 打分限定 1-5 分(超出返回 422),不能给自己的提交打分

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:33:26 +08:00
xuyueandClaude Opus 5.5 4014aaabbf build: 改成 Bun workspaces 的 monorepo
- 根目录新增 package.json(workspaces: client、server),两边共用一个 bun.lock,
  依赖版本与原来两个 lockfile 完全一致
- 根目录脚本:bun run dev / build / build:staging / test / typecheck / fmt,
  取代 dev.sh;client 的 start 改名为 dev,包名改为 webpreview-client
- workspaces 用 isolated 安装,补上 client 一直直接 import 却没声明的 @codemirror/view
- server 镜像改用仓库根目录做构建上下文,只装 server 的依赖;
  .dockerignore 用白名单,不再把 node_modules、数据库等发给 docker
- CI 在根目录安装并构建前端

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:57:29 +08:00
110 changed files with 11565 additions and 5956 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# 只有 server/Dockerfile 用仓库根目录做构建上下文;其余一概不发给 docker(数据库、media、node_modules 等)
*
!package.json
!bun.lock
!client/package.json
!server/package.json
!server/tsconfig.json
!server/src
!server/scripts
+3 -3
View File
@@ -18,7 +18,8 @@ jobs:
- name: debian - name: debian
build_command: build build_command: build
remote_port: 22 remote_port: 22
# school 暂时停用,需要时恢复(并先按 docs/migrate-to-bun.md 切换那台服务器) # school 暂时停用,需要时恢复。那台还没从 Postgres 迁到 SQLite:迁移脚本、webpreview-db 服务和
# 步骤文档已删除,从提交 cdeb7c1 里找回(git show cdeb7c1:docs/migrate-to-bun.md)
# - name: school # - name: school
# build_command: build:staging # build_command: build:staging
# remote_port: 8822 # remote_port: 8822
@@ -30,9 +31,8 @@ jobs:
bun-version: 1.4.4 bun-version: 1.4.4
- name: Build client - name: Build client
working-directory: client
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile --filter webpreview-client
bun run ${{ matrix.build_command }} bun run ${{ matrix.build_command }}
env: env:
CI: false CI: false
+27 -10
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It has a Bun backend (`server/`) and a Vue 3 frontend (`client/`) in one repository, deployed via Docker Compose with Caddy as reverse proxy. WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It is a Bun workspaces monorepo: a Bun backend (`server/`, package `webpreview-server`) and a Vue 3 frontend (`client/`, package `webpreview-client`) share one root `package.json` and one `bun.lock`, deployed via Docker Compose with Caddy as reverse proxy.
The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1–2 GB). It keeps Django's exact database schema (table names, column types, UTC `YYYY-MM-DD HH:MM:SS.ffffff` timestamps, 32-char hex UUIDs, `pbkdf2_sha256$...` password hashes) so old data loads unchanged. The Django code is in git history before that change. The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1–2 GB). It keeps Django's exact database schema (table names, column types, UTC `YYYY-MM-DD HH:MM:SS.ffffff` timestamps, 32-char hex UUIDs, `pbkdf2_sha256$...` password hashes) so old data loads unchanged. The Django code is in git history before that change.
@@ -15,10 +15,11 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422) - `src/app.ts` — mounts routers under `/api/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
- `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions - `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions
- `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/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`), `prompt` - `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`
- `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), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit) - 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/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted - `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)
- `scripts/` — one-off CLIs: `migrate-from-postgres.ts`, `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts` - `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`, `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`) - 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) - **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
@@ -26,19 +27,32 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`) - UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`)
- `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs - `src/token.ts` — tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLs
- `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home - `src/api.ts` — Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes home
- `src/router.ts` — Vue Router with auth guards - `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; `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`
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets - `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore) - `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
- **`Caddyfile`** — serves SPA from `build/` and `/media`, proxies `/api*` and `/ws*` to the backend - **`Caddyfile`** — serves SPA from `build/` and `/media`, proxies `/api*` and `/ws*` to the backend
- **`docker-compose.yaml`** — `webpreview-web` (Caddy) and `webpreview-api` (Bun); `webpreview-db` (Postgres) exists only under the `migrate` profile for the one-time data migration - **`docker-compose.yaml`** — `webpreview-web` (Caddy) and `webpreview-api` (Bun); the API reads the repo-root `.env` via `env_file` (`server/.env` is only for local dev). The one-time Postgres → SQLite migration (script, `webpreview-db` service, `docs/migrate-to-bun.md`) was removed after commit `cdeb7c1`; restore it from there if another server still needs the cutover
- **`.github/workflows/deploy.yaml`** — on push to `main`: build client with Bun, rsync to server `build/`, then `git pull && docker compose up -d --build` on the server - **`.github/workflows/deploy.yaml`** — on push to `main`: build client with Bun, rsync to server `build/`, then `git pull && docker compose up -d --build` on the server
## Development Commands ## Development Commands
From the repo root (each script runs inside its workspace, via `bun run --filter`):
```bash ```bash
./dev.sh # backend (bun --watch) + frontend together bun install # installs both workspaces into the single root bun.lock
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
bun run build # client production build (build:staging for the school server)
bun run test # server tests
bun run typecheck # server (tsc 7) + client (vue-tsc) typecheck
bun run fmt # format the client
``` ```
Add a dependency from inside its workspace (`cd client && bun add x`); the lockfile is still the root one.
### Server (from `server/`) ### Server (from `server/`)
```bash ```bash
@@ -48,23 +62,26 @@ bun run typecheck
bun scripts/create-superuser.ts <username> <password> bun scripts/create-superuser.ts <username> <password>
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run] bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
bun scripts/recalculate-scores.ts [--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/`. Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
### Client (from `client/`) ### Client (from `client/`)
```bash ```bash
bun install # Install deps (bun.lock) bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
bun run start # Dev server (Vite, run on the Bun runtime) on :3000
bun run build # Production build (bun run build:staging for the school server) bun run build # Production build (bun run build:staging for the school server)
bun run typecheck # vue-tsc; the client pins TypeScript 5.9 because vue-tsc can't run on TS 7 (the server uses TS 7)
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml) bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
``` ```
Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`). Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLIC_WEB_URL` (dev proxy target for `/media` and `/ws`).
### Docker (from project root) ### Docker (from project root)
`server/Dockerfile` builds with the repo root as context (so it can use the root `bun.lock`) and installs only the server workspace; `.dockerignore` is an allowlist, so new top-level server files must be added there.
```bash ```bash
docker compose up -d --build docker compose up -d --build
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password> docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>
``` ```
Back up production by copying `sqlite/` (or `sqlite3 sqlite/db.sqlite3 ".backup backup.sqlite3"`).
## Key Technical Details ## Key Technical Details
+8
View File
@@ -8,6 +8,14 @@
} }
handle_path /media/* { handle_path /media/* {
# 上传的 html/svg 直接打开时不能当成本站页面跑脚本(会读到 localStorage 里的 token);
# 作为图片、样式、脚本引用时不受 sandbox 影响。预览 iframe 没有 allow-same-origin,
# 读字体或 fetch() 素材要跨域许可
header {
Content-Security-Policy "sandbox"
X-Content-Type-Options "nosniff"
Access-Control-Allow-Origin "*"
}
root * /media root * /media
file_server file_server
} }
+97 -37
View File
@@ -1,19 +1,28 @@
{ {
"lockfileVersion": 2, "lockfileVersion": 2,
"configVersion": 0, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "web", "name": "webpreview",
},
"client": {
"name": "webpreview-client",
"version": "0.3.0",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12", "@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "6.12.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",
"@fontsource/jetbrains-mono": "^5.3.0",
"@vueuse/core": "^15.0.0", "@vueuse/core": "^15.0.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"lucide-vue-next": "^1.0.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-code-preview": "^1.3.7", "marked-code-preview": "^1.3.7",
@@ -25,14 +34,27 @@
"vue-router": "^5.4.0", "vue-router": "^5.4.0",
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.4",
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.10", "@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^7.0.2", "typescript": "~5.9.3",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.3.5", "vite": "^8.3.5",
"vue-tsc": "^3.3.13",
},
},
"server": {
"name": "webpreview-server",
"dependencies": {
"hono": "^4.13.14",
"zod": "^4.6.7",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^7.0.2",
}, },
}, },
}, },
@@ -275,6 +297,8 @@
"@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], "@codemirror/state": ["@codemirror/state@6.6.0", "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], "@codemirror/view": ["@codemirror/view@6.41.1", "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.1.tgz", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="], "@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
@@ -283,9 +307,7 @@
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], "@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.3.0", "https://registry.npmjs.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", {}, "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A=="],
"@iconify/vue": ["@iconify/vue@5.0.4", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-9yP5PjOv5G9vStHfyPtQ69Ay1CSNAC3AZTRDJ4LX3UMX7nEH0GO8OKhc3qhcrXp1KS2GnlLufGbFCwD+U4Fi5A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -373,7 +395,9 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/bun": ["@types/bun@1.4.4", "https://registry.npmjs.com/@types/bun/-/bun-1.4.4.tgz", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], "@types/linkify-it": ["@types/linkify-it@5.0.0", "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
@@ -385,6 +409,8 @@
"@types/mdurl": ["@types/mdurl@2.0.0", "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], "@types/mdurl": ["@types/mdurl@2.0.0", "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -429,7 +455,7 @@
"@vavt/copy2clipboard": ["@vavt/copy2clipboard@1.0.3", "https://registry.npmmirror.com/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz", {}, "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA=="], "@vavt/copy2clipboard": ["@vavt/copy2clipboard@1.0.3", "https://registry.npmmirror.com/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz", {}, "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA=="],
"@vavt/markdown-theme": ["@vavt/markdown-theme@6.0.1", "", {}, "sha512-S6HClmYtnrBuAm+6aeAKRtMwy6cTv+7QlGQYlpNHwumoFr3x86X6uGtJbvrv3ReoKx40vCOJR7h+1OiflKJOWA=="], "@vavt/markdown-theme": ["@vavt/markdown-theme@6.0.1", "https://registry.npmjs.com/@vavt/markdown-theme/-/markdown-theme-6.0.1.tgz", {}, "sha512-S6HClmYtnrBuAm+6aeAKRtMwy6cTv+7QlGQYlpNHwumoFr3x86X6uGtJbvrv3ReoKx40vCOJR7h+1OiflKJOWA=="],
"@vavt/util": ["@vavt/util@2.1.2", "https://registry.npmmirror.com/@vavt/util/-/util-2.1.2.tgz", {}, "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw=="], "@vavt/util": ["@vavt/util@2.1.2", "https://registry.npmmirror.com/@vavt/util/-/util-2.1.2.tgz", {}, "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw=="],
@@ -437,15 +463,21 @@
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmjs.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmjs.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
"@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="], "@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="], "@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="], "@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="], "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="], "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
"@vue/devtools-api": ["@vue/devtools-api@8.2.1", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="], "@vue/devtools-api": ["@vue/devtools-api@8.2.1", "https://registry.npmjs.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz", { "dependencies": { "@vue/devtools-kit": "^8.2.1" } }, "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A=="],
@@ -453,33 +485,37 @@
"@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="], "@vue/devtools-shared": ["@vue/devtools-shared@8.2.1", "https://registry.npmjs.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", {}, "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g=="],
"@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="], "@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="], "@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="], "@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="], "@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "https://registry.npmjs.com/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
"@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], "@vue/server-renderer": ["@vue/server-renderer@3.5.44", "https://registry.npmjs.com/@vue/server-renderer/-/server-renderer-3.5.44.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
"@vue/shared": ["@vue/shared@3.5.44", "https://registry.npmjs.com/@vue/shared/-/shared-3.5.44.tgz", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
"@vue/tsconfig": ["@vue/tsconfig@0.9.1", "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="], "@vue/tsconfig": ["@vue/tsconfig@0.9.1", "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="],
"@vueuse/core": ["@vueuse/core@15.0.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="], "@vueuse/core": ["@vueuse/core@15.0.0", "https://registry.npmjs.com/@vueuse/core/-/core-15.0.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
"@vueuse/metadata": ["@vueuse/metadata@15.0.0", "", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="], "@vueuse/metadata": ["@vueuse/metadata@15.0.0", "https://registry.npmjs.com/@vueuse/metadata/-/metadata-15.0.0.tgz", {}, "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg=="],
"@vueuse/shared": ["@vueuse/shared@15.0.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="], "@vueuse/shared": ["@vueuse/shared@15.0.0", "https://registry.npmjs.com/@vueuse/shared/-/shared-15.0.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "agent-base": ["agent-base@6.0.2", "https://registry.npmjs.com/agent-base/-/agent-base-6.0.2.tgz", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmjs.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], "ast-kit": ["ast-kit@2.2.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-2.2.0.tgz", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
"ast-walker-scope": ["ast-walker-scope@0.10.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "ast-kit": "^3.0.0" } }, "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw=="], "ast-walker-scope": ["ast-walker-scope@0.10.0", "https://registry.npmjs.com/ast-walker-scope/-/ast-walker-scope-0.10.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "ast-kit": "^3.0.0" } }, "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw=="],
"async-validator": ["async-validator@4.2.5", "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="], "async-validator": ["async-validator@4.2.5", "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
@@ -487,7 +523,7 @@
"attributes-parser": ["attributes-parser@2.2.3", "https://registry.npmmirror.com/attributes-parser/-/attributes-parser-2.2.3.tgz", { "dependencies": { "json-loose": "^1.2.4" } }, "sha512-zjOUWt95la8AdUO+kP1GBOonWrV5jy9NjJP+z9tva/DSA6FIzGKcN/gk3tdqQf/pOeB8dkyd3FCPrjhELMmrkg=="], "attributes-parser": ["attributes-parser@2.2.3", "https://registry.npmmirror.com/attributes-parser/-/attributes-parser-2.2.3.tgz", { "dependencies": { "json-loose": "^1.2.4" } }, "sha512-zjOUWt95la8AdUO+kP1GBOonWrV5jy9NjJP+z9tva/DSA6FIzGKcN/gk3tdqQf/pOeB8dkyd3FCPrjhELMmrkg=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], "axios": ["axios@1.20.0", "https://registry.npmjs.com/axios/-/axios-1.20.0.tgz", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "https://registry.npmjs.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "https://registry.npmjs.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],
@@ -505,6 +541,8 @@
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.4.4", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.4.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmjs.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmjs.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001819", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="], "caniuse-lite": ["caniuse-lite@1.0.30001819", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", {}, "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg=="],
@@ -517,7 +555,7 @@
"commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], "confbox": ["confbox@0.3.1", "https://registry.npmjs.com/confbox/-/confbox-0.3.1.tgz", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmjs.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmjs.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -597,6 +635,8 @@
"highlight.js": ["highlight.js@11.12.0", "https://registry.npmjs.com/highlight.js/-/highlight.js-11.12.0.tgz", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], "highlight.js": ["highlight.js@11.12.0", "https://registry.npmjs.com/highlight.js/-/highlight.js-11.12.0.tgz", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
"hono": ["hono@4.13.14", "https://registry.npmjs.com/hono/-/hono-4.13.14.tgz", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="],
"hookable": ["hookable@5.5.3", "https://registry.npmjs.com/hookable/-/hookable-5.5.3.tgz", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "hookable": ["hookable@5.5.3", "https://registry.npmjs.com/hookable/-/hookable-5.5.3.tgz", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "https://registry.npmjs.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "https://registry.npmjs.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -647,6 +687,8 @@
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-vue-next": ["lucide-vue-next@1.0.0", "https://registry.npmjs.com/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
"magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="], "magic-string": ["magic-string@1.4.4", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.4.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ=="],
"magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="], "magic-string-ast": ["magic-string-ast@1.0.3", "https://registry.npmjs.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
@@ -659,7 +701,7 @@
"markdown-it-sup": ["markdown-it-sup@2.0.0", "https://registry.npmmirror.com/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz", {}, "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA=="], "markdown-it-sup": ["markdown-it-sup@2.0.0", "https://registry.npmmirror.com/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz", {}, "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA=="],
"marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="], "marked": ["marked@18.1.1", "https://registry.npmjs.com/marked/-/marked-18.1.1.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="],
"marked-alert": ["marked-alert@2.1.2", "https://registry.npmmirror.com/marked-alert/-/marked-alert-2.1.2.tgz", { "peerDependencies": { "marked": ">=7.0.0" } }, "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w=="], "marked-alert": ["marked-alert@2.1.2", "https://registry.npmmirror.com/marked-alert/-/marked-alert-2.1.2.tgz", { "peerDependencies": { "marked": ">=7.0.0" } }, "sha512-EFNRZ08d8L/iEIPLTlQMDjvwIsj03gxWCczYTht6DCiHJIZhMk4NK5gtPY9UqAYb09eV5VGT+jD4lp396E0I+w=="],
@@ -669,7 +711,7 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmjs.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmjs.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"md-editor-v3": ["md-editor-v3@7.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.8.1", "@codemirror/lang-markdown": "^6.3.4", "@codemirror/language": "^6.11.3", "@codemirror/language-data": "^6.5.1", "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.2", "@lezer/highlight": "^1.2.1", "@lucide/vue": "^1.16.0", "@types/markdown-it": "^14.0.1", "@vavt/copy2clipboard": "^1.0.1", "@vavt/markdown-theme": "^6.0.0", "@vavt/util": "^2.1.2", "codemirror": "^6.0.2", "json5": "^2.2.3", "markdown-it": "^14.0.0", "markdown-it-image-figures": "^2.1.1", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "medium-zoom": "^1.1.0", "xss": "^1.0.15" }, "peerDependencies": { "vue": "^3.5.3" } }, "sha512-+byIdwES9aGEeDDxcxx7LiVX8g31VgwJqETtfFvGjsGuwEAuMeuPZ6vV8/vltNa+SCBrigbwQxLb1R47mxaWfg=="], "md-editor-v3": ["md-editor-v3@7.1.0", "https://registry.npmjs.com/md-editor-v3/-/md-editor-v3-7.1.0.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.8.1", "@codemirror/lang-markdown": "^6.3.4", "@codemirror/language": "^6.11.3", "@codemirror/language-data": "^6.5.1", "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.2", "@lezer/highlight": "^1.2.1", "@lucide/vue": "^1.16.0", "@types/markdown-it": "^14.0.1", "@vavt/copy2clipboard": "^1.0.1", "@vavt/markdown-theme": "^6.0.0", "@vavt/util": "^2.1.2", "codemirror": "^6.0.2", "json5": "^2.2.3", "markdown-it": "^14.0.0", "markdown-it-image-figures": "^2.1.1", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "medium-zoom": "^1.1.0", "xss": "^1.0.15" }, "peerDependencies": { "vue": "^3.5.3" } }, "sha512-+byIdwES9aGEeDDxcxx7LiVX8g31VgwJqETtfFvGjsGuwEAuMeuPZ6vV8/vltNa+SCBrigbwQxLb1R47mxaWfg=="],
"mdurl": ["mdurl@2.0.0", "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "mdurl": ["mdurl@2.0.0", "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
@@ -689,16 +731,18 @@
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"naive-ui": ["naive-ui@2.45.3", "", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="], "naive-ui": ["naive-ui@2.45.3", "https://registry.npmjs.com/naive-ui/-/naive-ui-2.45.3.tgz", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
"nanoid": ["nanoid@3.3.20", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.20.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "nanoid": ["nanoid@3.3.20", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.20.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-releases": ["node-releases@2.0.60", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.60.tgz", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="], "node-releases": ["node-releases@2.0.60", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.60.tgz", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="],
"nostics": ["nostics@1.3.0", "", {}, "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg=="], "nostics": ["nostics@1.3.0", "https://registry.npmjs.com/nostics/-/nostics-1.3.0.tgz", {}, "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg=="],
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmjs.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -713,7 +757,7 @@
"postcss": ["postcss@8.5.31", "https://registry.npmjs.com/postcss/-/postcss-8.5.31.tgz", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], "postcss": ["postcss@8.5.31", "https://registry.npmjs.com/postcss/-/postcss-8.5.31.tgz", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
"prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], "prettier": ["prettier@3.9.10", "https://registry.npmjs.com/prettier/-/prettier-3.9.10.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
@@ -763,12 +807,14 @@
"treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="], "treemate": ["treemate@0.3.11", "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
"typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "typescript": ["typescript@5.9.3", "https://registry.npmjs.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "uc.micro": ["uc.micro@2.1.0", "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"ufo": ["ufo@1.6.4", "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "ufo": ["ufo@1.6.4", "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "https://registry.npmjs.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "https://registry.npmjs.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
"unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "https://registry.npmjs.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "https://registry.npmjs.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
@@ -787,26 +833,36 @@
"vdirs": ["vdirs@0.1.8", "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="], "vdirs": ["vdirs@0.1.8", "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
"vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], "vite": ["vite@8.3.5", "https://registry.npmjs.com/vite/-/vite-8.3.5.tgz", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
"vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="], "vooks": ["vooks@0.2.12", "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
"vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="], "vscode-uri": ["vscode-uri@3.2.0", "https://registry.npmjs.com/vscode-uri/-/vscode-uri-3.2.0.tgz", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
"vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="], "vue-codemirror": ["vue-codemirror@6.1.1", "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="],
"vue-router": ["vue-router@5.4.0", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="], "vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
"vueuc": ["vueuc@0.4.66", "", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="], "vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"webpreview-client": ["webpreview-client@workspace:client"],
"webpreview-server": ["webpreview-server@workspace:server"],
"xss": ["xss@1.0.15", "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": "bin/xss" }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="], "xss": ["xss@1.0.15", "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": "bin/xss" }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="],
"yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.6.7", "https://registry.npmjs.com/zod/-/zod-4.6.7.tgz", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.11", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.11.tgz", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="], "@babel/traverse/@babel/parser": ["@babel/parser@7.29.11", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.11.tgz", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="],
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
@@ -823,11 +879,13 @@
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@vue/language-core/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "ast-walker-scope/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
"ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "ast-walker-scope/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
"ast-walker-scope/ast-kit": ["ast-kit@3.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ=="], "ast-walker-scope/ast-kit": ["ast-kit@3.0.0", "https://registry.npmjs.com/ast-kit/-/ast-kit-3.0.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ=="],
"css-render/csstype": ["csstype@3.0.11", "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="], "css-render/csstype": ["csstype@3.0.11", "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
@@ -843,7 +901,9 @@
"vue-router/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "vue-router/picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"vue-router/unplugin": ["unplugin@3.5.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="], "vue-router/unplugin": ["unplugin@3.5.0", "https://registry.npmjs.com/unplugin/-/unplugin-3.5.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-s9qc6Y8ChhQE9RZ9Se+znEtZA0oK31LmEBO9y97ixR3ErA8UwlzA4XNybg/41od8rQ8EKScVQM857NWnI+EWkQ=="],
"webpreview-server/typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="], "@babel/traverse/@babel/parser/@babel/types": ["@babel/types@7.29.11", "https://registry.npmjs.com/@babel/types/-/types-7.29.11.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
@@ -871,7 +931,7 @@
"ast-walker-scope/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "ast-walker-scope/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="],
"ast-walker-scope/ast-kit/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "ast-walker-scope/ast-kit/estree-walker": ["estree-walker@3.0.3", "https://registry.npmjs.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"magic-string-ast/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "magic-string-ast/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
-1
View File
@@ -1,3 +1,2 @@
PUBLIC_WEB_URL=https://web.xuyue.cc PUBLIC_WEB_URL=https://web.xuyue.cc
PUBLIC_BASE_URL=https://web.xuyue.cc/api PUBLIC_BASE_URL=https://web.xuyue.cc/api
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
-1
View File
@@ -1,3 +1,2 @@
PUBLIC_WEB_URL=http://10.13.114.114:91 PUBLIC_WEB_URL=http://10.13.114.114:91
PUBLIC_BASE_URL=http://10.13.114.114:91/api PUBLIC_BASE_URL=http://10.13.114.114:91/api
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
-1
View File
@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>前端开发学习平台</title> <title>前端开发学习平台</title>
<link rel="stylesheet" href="/normalize.min.css" /> <link rel="stylesheet" href="/normalize.min.css" />
<link rel="stylesheet" href="/style.css" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+12 -6
View File
@@ -1,24 +1,30 @@
{ {
"name": "web", "name": "webpreview-client",
"private": true, "private": true,
"version": "0.3.0", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "bun --bun vite", "dev": "bun --bun vite",
"build": "bun --bun vite build", "build": "bun --bun vite build",
"build:staging": "bun --bun vite build --mode staging", "build:staging": "bun --bun vite build --mode staging",
"fmt": "bun fmt src/" "fmt": "bun fmt src/",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12", "@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "6.12.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",
"@fontsource/jetbrains-mono": "^5.3.0",
"@vueuse/core": "^15.0.0", "@vueuse/core": "^15.0.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"lucide-vue-next": "^1.0.0",
"marked": "^18.1.1", "marked": "^18.1.1",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-code-preview": "^1.3.7", "marked-code-preview": "^1.3.7",
@@ -30,13 +36,13 @@
"vue-router": "^5.4.0" "vue-router": "^5.4.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.4",
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.10", "@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"typescript": "^7.0.2", "typescript": "~5.9.3",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.3.5" "vite": "^8.3.5",
"vue-tsc": "^3.3.13"
} }
} }
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
@font-face {
font-family: "Monaco";
src: url(/Monaco.ttf);
}
+15 -25
View File
@@ -1,32 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { dateZhCN, zhCN } from "naive-ui" import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import type { GlobalThemeOverrides } from "naive-ui"
import Login from "./components/Login.vue" import Login from "./components/Login.vue"
import { onMounted } from "vue" import AppShell from "./components/layout/AppShell.vue"
import { Account } from "./api" import { computed, onMounted } from "vue"
import { user } from "./store/user" import { useRoute } from "vue-router"
import { loadUser } from "./store/session"
import { dark, light, naiveOverrides } from "./theme"
import { isDark } from "./store/theme"
import hljs from "highlight.js/lib/core" import hljs from "highlight.js/lib/core"
const themeOverrides: GlobalThemeOverrides = { const route = useRoute()
common: { const theme = computed(() => (isDark.value ? darkTheme : null))
borderRadius: "6px", const themeOverrides = computed(() => naiveOverrides(isDark.value ? dark : light))
borderRadiusSmall: "4px",
},
Card: {
borderRadius: "8px",
},
}
onMounted(async () => { onMounted(loadUser)
try {
const data = await Account.getMyProfile()
user.loaded = true
user.username = data.username
user.role = data.role
} catch {
user.loaded = true
}
})
</script> </script>
<template> <template>
@@ -35,12 +22,15 @@ onMounted(async () => {
:locale="zhCN" :locale="zhCN"
:date-locale="dateZhCN" :date-locale="dateZhCN"
:hljs="hljs" :hljs="hljs"
:theme="theme"
:theme-overrides="themeOverrides" :theme-overrides="themeOverrides"
> >
<n-modal-provider> <n-modal-provider>
<n-message-provider :max="1"> <n-message-provider :max="1">
<n-dialog-provider> <n-dialog-provider>
<router-view></router-view> <!-- 全屏预览这类页面不要顶栏 -->
<router-view v-if="route.meta.bare" />
<AppShell v-else />
<Login /> <Login />
</n-dialog-provider> </n-dialog-provider>
</n-message-provider> </n-message-provider>
+60 -19
View File
@@ -17,13 +17,16 @@ import type {
AwardItemManageOut, AwardItemManageOut,
GradebookOut, GradebookOut,
GradebookQuery, GradebookQuery,
ShowcaseSubmissionLookupOut,
ShowcaseDetail, ShowcaseDetail,
PromptRound, PromptRound,
RandomRatingItem, RandomRatingItem,
CreationSlim, CreationSlim,
CreationDetail, CreationDetail,
CreationIn, CreationIn,
ReviewOptions,
ReviewProgress,
ReviewStudent,
PeerWorks,
} from "./utils/type" } from "./utils/type"
import { BASE_URL } from "./utils/const" import { BASE_URL } from "./utils/const"
import { import {
@@ -60,6 +63,8 @@ http.interceptors.response.use(
) { ) {
err.config._retried = true err.config._retried = true
if (await refreshAccessToken()) return http(err.config) if (await refreshAccessToken()) return http(err.config)
// 刷新失败但 refresh token 还在,说明只是网络问题:保留登录,下次请求再试
if (hasLogin()) return Promise.reject(err)
} }
switch (err.response.status) { switch (err.response.status) {
case 401: // 未授权 case 401: // 未授权
@@ -259,6 +264,7 @@ export const Submission = {
prompt?: string prompt?: string
}, },
messageId?: number, messageId?: number,
reflection?: string,
) { ) {
const { prompt, ...rest } = code const { prompt, ...rest } = code
const data = { const data = {
@@ -266,6 +272,7 @@ export const Submission = {
...rest, ...rest,
prompt: prompt || null, prompt: prompt || null,
message_id: messageId ?? null, message_id: messageId ?? null,
reflection: reflection || null,
} }
const res = await http.post("/submission/", data) const res = await http.post("/submission/", data)
return res.data return res.data
@@ -277,7 +284,7 @@ export const Submission = {
username?: string username?: string
user_id?: number user_id?: number
flag?: string | null flag?: string | null
zone?: string zone?: string | null
task_id?: number task_id?: number
task_type?: string task_type?: string
score_min?: number score_min?: number
@@ -318,7 +325,11 @@ export const Submission = {
await http.post(`/submission/${id}/view`) await http.post(`/submission/${id}/view`)
}, },
async updateScore(id: string, score: number) { // 返回作者名:互评打分前是匿名的
async updateScore(
id: string,
score: number,
): Promise<{ message: string; username?: string }> {
const res = await http.put(`/submission/${id}/score`, { score }) const res = await http.put(`/submission/${id}/score`, { score })
return res.data return res.data
}, },
@@ -335,11 +346,6 @@ export const Submission = {
return res.data return res.data
}, },
async clearAllFlags() {
const res = await http.delete(`/submission/flags`)
return res.data as { cleared: number }
},
async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> { async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
const params: Record<string, string | number> = {} const params: Record<string, string | number> = {}
if (classname) params.classname = classname if (classname) params.classname = classname
@@ -393,6 +399,20 @@ export const Gradebook = {
} }
export const Prompt = { export const Prompt = {
// 保存一个不是 AI 生成的版本:手改的代码,或者带提示词的外部 AI 代码
async saveVersion(
taskId: number,
code: { html: string; css: string; js: string },
prompt?: string,
): Promise<{ message_id: number; user_message_id: number; source: string }> {
const res = await http.post("/prompt/versions", {
task_id: taskId,
...code,
prompt: prompt || undefined,
})
return res.data
},
async listConversations(taskId?: number, userId?: number) { async listConversations(taskId?: number, userId?: number) {
const params: Record<string, number> = {} const params: Record<string, number> = {}
if (taskId) params.task_id = taskId if (taskId) params.task_id = taskId
@@ -441,6 +461,28 @@ export const Helper = {
}, },
} }
// 老师按“班级 × 任务”批改;学生看本班同学的作品
export const Review = {
async options(): Promise<ReviewOptions> {
return (await http.get("/submission/review/options")).data
},
// classname 不传就是全部班级
async progress(classname?: string): Promise<ReviewProgress> {
return (await http.get("/submission/review/progress", { params: { classname } })).data
},
async classWall(classname: string | undefined, taskId: number): Promise<ReviewStudent[]> {
return (
await http.get("/submission/review/class", {
params: { classname, task_id: taskId },
})
).data
},
// scope:class 只看本班,all 看所有班
async peers(taskId: number, scope: "class" | "all" = "class"): Promise<PeerWorks> {
return (await http.get(`/submission/review/peers/${taskId}`, { params: { scope } })).data
},
}
export const Showcase = { export const Showcase = {
async list(): Promise<AwardSection[]> { async list(): Promise<AwardSection[]> {
const res = await http.get("/submission/showcase/") const res = await http.get("/submission/showcase/")
@@ -478,15 +520,6 @@ export const Showcase = {
return res.data return res.data
}, },
async findSubmissionForAward(
submissionId: string,
): Promise<ShowcaseSubmissionLookupOut> {
const res = await http.get(
`/submission/showcase/manage/submissions/${submissionId}`,
)
return res.data
},
async addAwardItem( async addAwardItem(
id: number, id: number,
payload: AwardItemIn, payload: AwardItemIn,
@@ -553,7 +586,11 @@ export const TaskAssets = {
).data ).data
}, },
async deleteChallenge(display: number, name: string) { async deleteChallenge(display: number, name: string) {
return (await http.delete(`/assets/challenge/${display}/${name}`)).data return (
await http.delete(
`/assets/challenge/${display}/${encodeURIComponent(name)}`,
)
).data
}, },
async listTutorial(display: number): Promise<TaskAsset[]> { async listTutorial(display: number): Promise<TaskAsset[]> {
return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data return (await http.get<TaskAsset[]>(`/assets/tutorial/${display}`)).data
@@ -573,6 +610,10 @@ export const TaskAssets = {
).data ).data
}, },
async deleteTutorial(display: number, name: string) { async deleteTutorial(display: number, name: string) {
return (await http.delete(`/assets/tutorial/${display}/${name}`)).data return (
await http.delete(
`/assets/tutorial/${display}/${encodeURIComponent(name)}`,
)
).data
}, },
} }
+78 -59
View File
@@ -2,12 +2,13 @@
<n-modal <n-modal
preset="card" preset="card"
title="登录" title="登录"
style="width: 420px" style="width: min(400px, 92vw)"
:bordered="false"
v-model:show="loginModal" v-model:show="loginModal"
> >
<n-tabs v-model:value="activeTab" @update:value="onTabChange"> <n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录"> <n-tab-pane name="student" tab="学生">
<n-form> <n-form :show-feedback="false" class="form">
<n-form-item label="班级"> <n-form-item label="班级">
<n-select <n-select
v-model:value="selectedClass" v-model:value="selectedClass"
@@ -21,37 +22,25 @@
<n-select <n-select
v-model:value="selectedUsername" v-model:value="selectedUsername"
:options="nameOptions" :options="nameOptions"
placeholder="选择姓名" filterable
:placeholder="selectedClass ? '选择或输入姓名' : '先选班级'"
:loading="namesLoading" :loading="namesLoading"
:disabled="!selectedClass || namesLoading" :disabled="!selectedClass || namesLoading"
@update:value="focusPassword"
/> />
</n-form-item> </n-form-item>
<n-form-item label="密码"> <n-form-item label="密码">
<n-input <n-input
ref="studentPasswordRef"
type="password" type="password"
show-password-on="click"
v-model:value="studentPassword" v-model:value="studentPassword"
name="password" name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitStudent" @keyup.enter="submitStudent"
/> />
</n-form-item> </n-form-item>
<n-alert <div v-if="studentError" class="error">{{ studentError }}</div>
type="error"
v-if="classesError"
class="message"
title="加载班级列表失败,请刷新重试"
/>
<n-alert
type="error"
v-if="namesError"
class="message"
title="加载姓名列表失败,请重新选择班级"
/>
<n-alert
type="error"
v-if="showStudentError"
class="message"
title="登录失败,请检查密码"
/>
<n-button <n-button
block block
type="primary" type="primary"
@@ -61,28 +50,26 @@
> >
登录 登录
</n-button> </n-button>
<div class="note">忘了密码找老师重置</div>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录"> <n-tab-pane name="admin" tab="老师">
<n-form> <n-form :show-feedback="false" class="form">
<n-form-item label="用户名"> <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>
<n-form-item label="密码"> <n-form-item label="密码">
<n-input <n-input
type="password" type="password"
show-password-on="click"
v-model:value="adminPassword" v-model:value="adminPassword"
name="password" name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitAdmin" @keyup.enter="submitAdmin"
/> />
</n-form-item> </n-form-item>
<n-alert <div v-if="adminError" class="error">{{ adminError }}</div>
type="error"
v-if="showAdminError"
class="message"
title="登录失败,请检查用户名和密码"
/>
<n-button <n-button
block block
type="primary" type="primary"
@@ -99,26 +86,27 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted } from "vue" import { ref, computed, nextTick, onMounted } from "vue"
import { Account } from "../api" import { Account } from "../api"
import { loginModal } from "../store/modal" import { loginModal } from "../store/modal"
import { user } from "../store/user" import { user } from "../store/user"
import { router } from "../router" import { router } from "../router"
// Tab state // 机房的电脑一节课一个班轮流用:记住班级,不记姓名
const CLASS_KEY = "login-class"
const activeTab = ref("student") const activeTab = ref("student")
const studentPasswordRef = ref<{ focus: () => void } | null>(null)
// Student tab state // Student tab state
const selectedClass = ref<string | null>(null) const selectedClass = ref<string | null>(null)
const selectedUsername = ref<string | null>(null) const selectedUsername = ref<string | null>(null)
const studentPassword = ref("") const studentPassword = ref("")
const studentLoading = ref(false) const studentLoading = ref(false)
const showStudentError = ref(false) const studentError = ref("")
// Classes data
const classes = ref<string[]>([]) const classes = ref<string[]>([])
const classesLoading = ref(false) const classesLoading = ref(false)
const classesError = ref(false)
const classOptions = computed(() => const classOptions = computed(() =>
classes.value.map((c) => ({ label: c, value: c })), classes.value.map((c) => ({ label: c, value: c })),
) )
@@ -126,7 +114,6 @@ const classOptions = computed(() =>
// Names data // Names data
const names = ref<{ name: string; username: string }[]>([]) const names = ref<{ name: string; username: string }[]>([])
const namesLoading = ref(false) const namesLoading = ref(false)
const namesError = ref(false)
const nameOptions = computed(() => const nameOptions = computed(() =>
names.value.map((n) => ({ label: n.name, value: n.username })), names.value.map((n) => ({ label: n.name, value: n.username })),
) )
@@ -135,53 +122,68 @@ const nameOptions = computed(() =>
const adminName = ref("") const adminName = ref("")
const adminPassword = ref("") const adminPassword = ref("")
const adminLoading = ref(false) const adminLoading = ref(false)
const showAdminError = ref(false) const adminError = ref("")
// Load classes on mount (cached — not reloaded on tab switch) // 班级列表只在打开页面时取一次
onMounted(async () => { onMounted(async () => {
classesLoading.value = true classesLoading.value = true
classesError.value = false
try { try {
classes.value = await Account.listClasses() classes.value = await Account.listClasses()
} catch { } catch {
classesError.value = true studentError.value = "班级列表没加载出来,请刷新页面重试"
return
} finally { } finally {
classesLoading.value = false 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() { function onTabChange() {
// Reset student tab state
selectedClass.value = null
selectedUsername.value = null
studentPassword.value = "" studentPassword.value = ""
showStudentError.value = false studentError.value = ""
namesError.value = false
names.value = []
// Reset admin tab state
adminName.value = "" adminName.value = ""
adminPassword.value = "" adminPassword.value = ""
showAdminError.value = false adminError.value = ""
} }
async function onClassChange(classname: string) { async function onClassChange(classname: string) {
selectedUsername.value = null selectedUsername.value = null
names.value = [] names.value = []
namesError.value = false studentError.value = ""
try {
localStorage.setItem(CLASS_KEY, classname)
} catch {}
namesLoading.value = true namesLoading.value = true
try { try {
names.value = await Account.listNamesByClass(classname) names.value = await Account.listNamesByClass(classname)
} catch { } catch {
namesError.value = true studentError.value = "姓名列表没加载出来,请重新选一次班级"
} finally { } finally {
namesLoading.value = false 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
}
async function submitStudent() { async function submitStudent() {
if (!selectedUsername.value || !studentPassword.value) return if (!selectedUsername.value || !studentPassword.value) return
studentLoading.value = true studentLoading.value = true
showStudentError.value = false studentError.value = ""
try { try {
const data = await Account.login( const data = await Account.login(
selectedUsername.value, selectedUsername.value,
@@ -194,8 +196,8 @@ async function submitStudent() {
router.replace( router.replace(
window.location.pathname + window.location.search + window.location.hash, window.location.pathname + window.location.search + window.location.hash,
) )
} catch { } catch (err: any) {
showStudentError.value = true studentError.value = loginError(err, "登录失败,请检查密码")
} finally { } finally {
studentLoading.value = false studentLoading.value = false
} }
@@ -204,7 +206,7 @@ async function submitStudent() {
async function submitAdmin() { async function submitAdmin() {
if (!adminName.value || !adminPassword.value) return if (!adminName.value || !adminPassword.value) return
adminLoading.value = true adminLoading.value = true
showAdminError.value = false adminError.value = ""
try { try {
const data = await Account.login(adminName.value, adminPassword.value) const data = await Account.login(adminName.value, adminPassword.value)
user.username = data.username user.username = data.username
@@ -214,8 +216,8 @@ async function submitAdmin() {
router.replace( router.replace(
window.location.pathname + window.location.search + window.location.hash, window.location.pathname + window.location.search + window.location.hash,
) )
} catch { } catch (err: any) {
showAdminError.value = true adminError.value = loginError(err, "登录失败,请检查用户名和密码")
} finally { } finally {
adminLoading.value = false adminLoading.value = false
} }
@@ -223,7 +225,24 @@ async function submitAdmin() {
</script> </script>
<style scoped> <style scoped>
.message { .form {
margin-bottom: 12px; 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> </style>
@@ -1,182 +0,0 @@
<template>
<div class="external-panel">
<div class="content-area">
<div class="field-label">提示词</div>
<n-input
v-model:value="promptText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="粘贴你发给外部 AI 的提示词..."
/>
<div class="code-field">
<div class="field-label" style="margin-top: 12px">完整的代码</div>
<n-input
v-model:value="rawCode"
type="textarea"
class="code-input"
placeholder="粘贴完整的前端代码..."
/>
</div>
<div v-if="splitResult" class="split-result">
<n-tag size="small" type="success"
>HTML · {{ splitResult.html.length }} 字符</n-tag
>
<n-tag size="small" type="info"
>CSS · {{ splitResult.css.length }} 字符</n-tag
>
<n-tag size="small" type="warning"
>JS · {{ splitResult.js.length }} 字符</n-tag
>
</div>
</div>
<div class="action-bar">
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
>应用预览</n-button
>
<n-button
type="primary"
:disabled="!splitResult || !promptText.trim()"
:loading="submitting"
@click="submit"
>
提交
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useMessage } from "naive-ui"
import { html, css, js } from "../../store/editors"
import { Submission } from "../../api"
const props = defineProps<{ taskId: number }>()
const emit = defineEmits<{
submitted: []
}>()
const message = useMessage()
const promptText = ref("")
const rawCode = ref("")
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
const submitting = ref(false)
watch(rawCode, () => {
splitResult.value = null
})
function splitHtml(raw: string): { html: string; css: string; js: string } {
let result = raw
const cssBlocks: string[] = []
const jsBlocks: string[] = []
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
cssBlocks.push(content.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, content) => {
jsBlocks.push(content.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
function applyPreview() {
const result = splitHtml(rawCode.value)
splitResult.value = result
html.value = result.html
css.value = result.css
js.value = result.js
}
async function submit() {
if (!splitResult.value) return
submitting.value = true
try {
await Submission.create(props.taskId, {
html: splitResult.value.html,
css: splitResult.value.css,
js: splitResult.value.js,
prompt: promptText.value.trim(),
})
emit("submitted")
message.success("提交成功")
promptText.value = ""
rawCode.value = ""
splitResult.value = null
} catch {
message.error("提交失败,请重试")
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.external-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content-area {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
}
.code-field {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.code-input {
flex: 1;
min-height: 0;
}
.code-input :deep(.n-input__textarea) {
height: 100%;
}
.code-input :deep(.n-input__textarea-el) {
height: 100%;
resize: none;
}
.field-label {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 6px;
}
.split-result {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.action-bar {
padding: 12px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
}
</style>
+134 -106
View File
@@ -2,89 +2,81 @@
<n-modal <n-modal
:show="isOpen" :show="isOpen"
preset="card" preset="card"
title="调教提示词" title="帮我把要求写清楚"
style="width: min(560px, 92vw)" style="width: min(640px, 92vw)"
:mask-closable="!streaming" :mask-closable="!streaming"
@update:show="onModalClose" @update:show="onModalClose"
> >
<div class="guidance-body"> <p class="intro">
<div class="guidance-messages" ref="messagesRef"> 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 <div
v-for="(msg, i) in messages" v-else
:key="msg.id ?? i" class="content markdown-body"
class="guidance-msg" v-html="renderMarkdown(displayStreamingContent)"
:class="msg.role" ></div>
>
<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>
</div> </div>
</div>
<div v-if="isReady" class="ready-hint"> <div v-if="isReady" class="ready">
<Icon icon="lucide:check-circle" :width="16" /> <Icon icon="lucide:check-circle" :width="16" />
提示词已优化,可以生成代码了! AI 觉得要求已经写清楚了,可以生成网页了
</div> </div>
<div class="guidance-input"> <div class="draft">
<n-input <div class="draft-label">你的要求</div>
v-model:value="draftPrompt" <n-input
type="textarea" v-model:value="draftPrompt"
:autosize="{ minRows: 2, maxRows: 5 }" type="textarea"
placeholder="修改你的提示词后发送..." :autosize="{ minRows: 3, maxRows: 6 }"
:disabled="streaming" placeholder="按 AI 的建议把要求改得更具体:要什么内容、什么颜色和布局、点了以后会怎样……"
@keydown.enter.exact.prevent="send" :disabled="streaming"
/> @keydown.enter.exact.prevent="send"
<n-flex justify="space-between" align="center" style="margin-top: 8px"> />
<div> <n-flex justify="space-between" align="center" class="draft-actions">
<n-button <n-flex align="center" :size="8">
v-if="streaming" <n-button v-if="streaming" size="small" @click="stopGuidance">停止</n-button>
type="error"
size="small"
@click="stopGuidance"
>
停止
</n-button>
<n-button
v-else
size="small"
:disabled="!draftPrompt.trim() || !connected"
@click="send"
>
发送
</n-button>
</div>
<n-button <n-button
:type="isReady ? 'primary' : 'default'" v-else
size="small" size="small"
:disabled="!draftPrompt.trim()" :disabled="!draftPrompt.trim() || !connected || draftPrompt.trim() === lastSent"
@click="generate" @click="send"
> >
<template #icon> 让 AI 再看看
<Icon icon="lucide:play" :width="14" />
</template>
生成代码
</n-button> </n-button>
<span class="muted">Enter 发送,Shift + Enter 换行</span>
</n-flex> </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> </div>
</n-modal> </n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue" import { ref, computed, watch, nextTick } from "vue"
import { Icon } from "@iconify/vue" import { Icon } from "../../icons"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
import { import {
messages, messages,
@@ -106,13 +98,23 @@ const emit = defineEmits<{
const draftPrompt = ref("") const draftPrompt = ref("")
const messagesRef = ref<HTMLElement>() 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(() => const displayStreamingContent = computed(() =>
streamingContent.value.replace(/^\[READY\]\n?/, "") streamingContent.value.replace(/^\[READY\]\n?/, "")
) )
function send() { function send() {
const text = draftPrompt.value.trim() const text = draftPrompt.value.trim()
if (!text || streaming.value) return if (!text || streaming.value || text === lastSent.value) return
sendGuidance(text) sendGuidance(text)
} }
@@ -141,87 +143,113 @@ watch([() => messages.value.length, streamingContent], () => {
</script> </script>
<style scoped> <style scoped>
.guidance-body { .intro {
display: flex; margin: 0 0 12px;
flex-direction: column; font-size: 13px;
gap: 12px; line-height: 1.7;
color: var(--wp-muted);
} }
.guidance-messages { .messages {
min-height: 160px; min-height: 120px;
max-height: 360px; max-height: 45vh;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding-right: 4px;
} }
.guidance-msg .msg-role { .who {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px; margin-bottom: 4px;
color: #888; font-size: 12px;
font-weight: 600;
color: var(--wp-muted);
} }
.guidance-msg.user .msg-role { .msg.assistant .who {
color: #2080f0; color: var(--wp-brand);
} }
.guidance-msg.assistant .msg-role { .content {
color: #18a058;
}
.guidance-msg.assistant .msg-content {
background: #f0f7ff;
border-left: 3px solid #2080f0;
padding: 8px 12px; padding: 8px 12px;
border-radius: 0 6px 6px 0; border-radius: 8px;
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.7;
color: var(--wp-ink);
} }
.guidance-msg.user .msg-content { .msg.user .content {
font-size: 14px; border: 1px solid var(--wp-line);
line-height: 1.6; background: var(--wp-paper);
color: #333;
} }
.msg-content :deep(p) { .msg.assistant .content {
margin: 0; border-left: 3px solid var(--wp-brand);
border-radius: 0 8px 8px 0;
background: var(--wp-brand-soft);
} }
.ready-hint { .content :deep(p) {
margin: 0 0 6px;
}
.content :deep(:last-child) {
margin-bottom: 0;
}
.ready {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
color: #18a058; margin-top: 12px;
font-size: 13px;
font-weight: 500;
padding: 6px 10px; padding: 6px 10px;
background: #f0fff4;
border-radius: 6px; border-radius: 6px;
border: 1px solid #b8e8cc; 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; display: flex;
gap: 5px; gap: 5px;
padding: 8px 0; padding: 10px 12px;
} }
.typing-indicator span { .typing span {
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: #2080f0; background: var(--wp-brand);
animation: bounce 1.4s ease-in-out infinite; animation: bounce 1.4s ease-in-out infinite;
} }
.typing-indicator span:nth-child(2) { .typing span:nth-child(2) {
animation-delay: 0.2s; animation-delay: 0.2s;
} }
.typing-indicator span:nth-child(3) { .typing span:nth-child(3) {
animation-delay: 0.4s; animation-delay: 0.4s;
} }
+204 -184
View File
@@ -1,142 +1,82 @@
<template> <template>
<div class="history-panel"> <div class="history-panel">
<n-flex <div class="toolbar">
class="history-toolbar" <span class="muted">共 {{ items.length }} 轮 · 点一条就把那一版放回编辑器</span>
align="center"
justify="space-between"
:wrap="false"
>
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
<n-tooltip> <n-tooltip>
<template #trigger> <template #trigger>
<n-button <n-button quaternary size="small" :loading="loading" @click="load(true)">
quaternary <template #icon><Icon icon="lucide:refresh-cw" /></template>
circle
size="small"
:loading="loading"
aria-label="刷新历史对话"
@click="load(true)"
>
<template #icon>
<Icon icon="lucide:refresh-cw" />
</template>
</n-button> </n-button>
</template> </template>
刷新历史对话 刷新
</n-tooltip> </n-tooltip>
</n-flex> </div>
<n-spin v-if="loading" class="state" /> <n-spin v-if="loading && !items.length" class="state" />
<n-empty <n-empty v-else-if="!items.length" class="state" description="还没有和 AI 对话过" />
v-else-if="!items.length" <n-scrollbar v-else class="scroll">
class="state" <ol class="list">
description="暂无历史对话" <li
/> v-for="item in newestFirst"
<n-scrollbar v-else class="history-scrollbar">
<n-flex vertical :size="12" class="history-list">
<n-card
v-for="(item, index) in items"
:key="item.assistant_message_id" :key="item.assistant_message_id"
class="history-card" :class="['item', { on: selectedAssistantMessageId === item.assistant_message_id }]"
:class="{
'is-selected':
selectedAssistantMessageId === item.assistant_message_id,
}"
size="small"
:bordered="true"
hoverable
:embedded="selectedAssistantMessageId === item.assistant_message_id"
:content-style="{ padding: 0 }"
@click="selectItem(item)" @click="selectItem(item)"
> >
<n-flex <div class="head">
class="history-main" <span class="num">{{ item.index }}</span>
align="center" <span :class="['src', `src-${item.source || 'conversation'}`]">
justify="space-between" {{ sourceLabel(item.source) }}
:wrap="false" </span>
> <span v-if="item.prompt_level" class="level">{{ levelLabel(item.prompt_level) }}</span>
<n-flex align="center" :wrap="false" :size="6"> <span v-if="item.submission_id" class="submitted">交过</span>
<n-tag <span class="time">{{ parseTime(item.created, "MM-DD HH:mm") }}</span>
round <n-popconfirm @positive-click="deleteItem(item)">
size="small" <template #trigger>
:bordered="false" <n-button
:type=" quaternary
selectedAssistantMessageId === item.assistant_message_id size="tiny"
? 'success' class="del"
: 'default' :loading="deletingId === item.assistant_message_id"
" :disabled="deletingId !== null"
> @click.stop
#{{ index + 1 }} >
</n-tag> <template #icon><Icon icon="lucide:trash-2" :width="14" /></template>
<n-tag </n-button>
size="small" </template>
:type="item.source === 'manual' ? 'info' : 'success'" <template v-if="item.submission_id">
> 这一版交过,删掉这轮对话后交的那一份也会一起删掉(有人评过分的删不了)。
{{ item.source === "manual" ? "手动提交" : "AI 对话" }} </template>
</n-tag> <template v-else>删掉这一轮对话和它生成的网页?</template>
</n-flex> </n-popconfirm>
<n-flex align="center" :wrap="false" :size="4"> </div>
<n-tag <div class="q markdown-body" v-html="renderMarkdown(item.prompt)"></div>
v-if="selectedAssistantMessageId === item.assistant_message_id" <div class="thumb">
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="页面缩略图">
<iframe <iframe
v-if="item.hasPage" v-if="item.hasPage"
title="页面缩略图" title="页面缩略图"
loading="lazy" loading="lazy"
sandbox="allow-scripts" :sandbox="PREVIEW_SANDBOX"
referrerpolicy="no-referrer"
tabindex="-1" tabindex="-1"
:srcdoc="item.previewDoc" :srcdoc="item.previewDoc"
/> ></iframe>
<n-empty v-else size="small" description="未生成页面" /> <span v-else class="muted">这一轮没有生成网页</span>
</div> </div>
</n-card> </li>
</n-flex> </ol>
</n-scrollbar> </n-scrollbar>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from "vue" import { computed, onMounted, ref, watch } from "vue"
import { Icon } from "@iconify/vue" import { Icon } from "../../icons"
import { useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
import { levelLabel, sourceLabel } from "../../utils/helper"
import { Prompt } from "../../api" import { Prompt } from "../../api"
import type { PromptHistoryItem } from "../../utils/type" import type { PromptHistoryItem } from "../../utils/type"
import { parseTime } from "../../utils/helper" import { parseTime } from "../../utils/helper"
import { buildPreviewDocument } from "../../utils/previewDocument" import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
@@ -151,6 +91,8 @@ const emit = defineEmits<{
}>() }>()
type HistoryViewItem = PromptHistoryItem & { type HistoryViewItem = PromptHistoryItem & {
// 按时间先后的序号(从 1 开始)
index: number
hasPage: boolean hasPage: boolean
previewDoc: string previewDoc: string
} }
@@ -164,12 +106,16 @@ let pendingRefresh = false
const naiveMessage = useMessage() 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 html = item.code_html ?? ""
const css = item.code_css ?? "" const css = item.code_css ?? ""
const js = item.code_js ?? "" const js = item.code_js ?? ""
return { return {
...item, ...item,
index: i + 1,
hasPage: !!(html.trim() || css.trim() || js.trim()), hasPage: !!(html.trim() || css.trim() || js.trim()),
previewDoc: buildPreviewDocument({ previewDoc: buildPreviewDocument({
html, html,
@@ -180,15 +126,14 @@ function toViewItem(item: PromptHistoryItem): HistoryViewItem {
} }
} }
async function deleteItem(item: HistoryViewItem, e: Event) { async function deleteItem(item: HistoryViewItem) {
e.stopPropagation()
if (deletingId.value !== null) return if (deletingId.value !== null) return
deletingId.value = item.assistant_message_id deletingId.value = item.assistant_message_id
try { try {
await Prompt.deleteMessagePair(item.assistant_message_id) await Prompt.deleteMessagePair(item.assistant_message_id)
items.value = items.value.filter( items.value = items.value
(i) => i.assistant_message_id !== item.assistant_message_id, .filter((i) => i.assistant_message_id !== item.assistant_message_id)
) .map((i, idx) => ({ ...i, index: idx + 1 }))
if (selectedAssistantMessageId.value === item.assistant_message_id) { if (selectedAssistantMessageId.value === item.assistant_message_id) {
selectedAssistantMessageId.value = null selectedAssistantMessageId.value = null
} }
@@ -210,14 +155,21 @@ function selectItem(item: HistoryViewItem) {
}) })
} }
// 加载中又要求刷新(比如自动提交了新代码、换了任务),等这次完成后再加载一次
let reloadAfter = false
async function load(force = true) { async function load(force = true) {
if (!props.taskId || loading.value) return if (!props.taskId) return
if (loading.value) {
reloadAfter = true
return
}
if (!force && loadedTaskId === props.taskId) return if (!force && loadedTaskId === props.taskId) return
loading.value = true loading.value = true
try { try {
const taskChanged = loadedTaskId !== props.taskId const taskChanged = loadedTaskId !== props.taskId
const data = await Prompt.listHistory(props.taskId) const data = await Prompt.listHistory(props.taskId)
items.value = data.map(toViewItem) items.value = data.map((item, i) => toViewItem(item, i))
if ( if (
taskChanged || taskChanged ||
!items.value.some( !items.value.some(
@@ -230,6 +182,10 @@ async function load(force = true) {
loadedTaskId = props.taskId loadedTaskId = props.taskId
} finally { } finally {
loading.value = false loading.value = false
if (reloadAfter) {
reloadAfter = false
load(true)
}
} }
} }
@@ -268,100 +224,164 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.history-toolbar { .toolbar {
height: 42px; height: 42px;
padding: 0 12px; flex: none;
border-bottom: 1px solid var(--n-border-color, #efeff5); display: flex;
flex-shrink: 0; 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 { .state {
margin: auto; margin: auto;
} }
.history-scrollbar { .scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.history-list { .list {
padding: 12px;
}
.history-card {
cursor: pointer;
overflow: hidden;
}
.history-card.is-selected {
--n-color: #f7fffa;
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
}
.history-card.is-selected .history-main {
background: #eefaf3;
}
.history-main {
padding: 10px 12px;
transition: background-color 0.18s ease;
}
.prompt-markdown {
margin: 0; margin: 0;
padding: 0 12px 12px; padding: 12px;
color: #333; list-style: none;
font-size: 14px; display: flex;
line-height: 1.6; flex-direction: column;
word-break: break-word; gap: 10px;
background: transparent;
} }
.prompt-markdown :deep(p), .item {
.prompt-markdown :deep(ul), border: 1px solid var(--wp-line);
.prompt-markdown :deep(ol), border-radius: 8px;
.prompt-markdown :deep(blockquote), overflow: hidden;
.prompt-markdown :deep(pre) { background: var(--wp-surface);
margin-top: 0; cursor: pointer;
margin-bottom: 6px;
} }
.prompt-markdown :deep(:last-child) { .item:hover {
margin-bottom: 0; border-color: var(--wp-faint);
} }
.prompt-markdown :deep(pre) { .item.on {
padding: 8px; border-color: var(--wp-brand);
overflow-x: auto; }
.head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 6px 4px 10px;
font-size: 12px; font-size: 12px;
} }
.prompt-markdown :deep(code) { .num {
white-space: pre-wrap; min-width: 18px;
word-break: break-word; font: 600 12px var(--wp-font-mono);
color: var(--wp-muted);
} }
.thumbnail { .src,
height: 160px; .level,
border-top: 1px solid var(--n-border-color, #efeff5); .submitted {
background: #f7f7f9; 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);
border: 1px solid var(--wp-line);
}
.submitted {
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.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; overflow: hidden;
display: flex; padding: 0 10px 8px;
align-items: center; background: transparent;
justify-content: center; 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);
} }
.history-card.is-selected .thumbnail { .q :deep(p),
border-top-color: #d8f1e2; .q :deep(ul),
background: #f4fbf6; .q :deep(ol),
.q :deep(pre),
.q :deep(blockquote) {
margin: 0 0 4px;
} }
.thumbnail iframe { .q :deep(pre) {
width: 100%; padding: 6px 8px;
height: 100%; 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; border: 0;
background: #fff; transform: scale(0.5);
transform-origin: 0 0;
pointer-events: none; pointer-events: none;
} }
</style> </style>
+327 -130
View File
@@ -2,7 +2,7 @@
<div class="prompt-panel"> <div class="prompt-panel">
<div class="messages" ref="messagesRef"> <div class="messages" ref="messagesRef">
<div <div
v-for="(pair, pi) in pairs" v-for="pair in pairs"
:key="pair.assistantMsg?.id ?? 'user-' + pair.index" :key="pair.assistantMsg?.id ?? 'user-' + pair.index"
class="message-pair" class="message-pair"
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }" :class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
@@ -18,8 +18,15 @@
</button> </button>
<!-- User message --> <!-- User message -->
<div class="message user"> <div v-if="pair.userMsg.source === 'edit'" class="message note">
<div class="message-role">我</div> 你手动修改了代码
</div>
<div v-else class="message user">
<div class="message-role">
我<span v-if="pair.userMsg.source === 'manual'" class="role-tag">
用外部 AI</span
>
</div>
<div <div
class="message-content" class="message-content"
v-html="renderContent(pair.userMsg)" v-html="renderContent(pair.userMsg)"
@@ -28,23 +35,16 @@
<!-- Assistant message --> <!-- Assistant message -->
<div v-if="pair.assistantMsg" class="message assistant"> <div v-if="pair.assistantMsg" class="message assistant">
<div class="message-role">AI</div> <template v-if="pair.assistantMsg.source === 'conversation' || !pair.assistantMsg.source">
<div <div class="message-role">AI</div>
class="message-content" <div
v-html="renderContent(pair.assistantMsg)" class="message-content"
></div> v-html="renderContent(pair.assistantMsg)"
<div ></div>
v-if="allowSubmit && hasCode(pair.assistantMsg)" </template>
class="message-actions" <div v-if="versionOf(pair.assistantMsg)" class="version-line">
> <span class="version-chip">v{{ versionOf(pair.assistantMsg) }}</span>
<n-button <span v-if="pair.assistantMsg.submitted">已提交</span>
size="tiny"
:disabled="pair.assistantMsg.submitted"
:loading="submittingId === pair.assistantMsg.id"
@click="submitVersion(pair.assistantMsg)"
>
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
</n-button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,94 +52,159 @@
<!-- Streaming indicator --> <!-- Streaming indicator -->
<div v-if="streaming" class="message assistant"> <div v-if="streaming" class="message assistant">
<div class="message-role">AI</div> <div class="message-role">AI</div>
<div v-if="!streamingContent" class="typing-indicator"> <template v-if="!streamingContent">
<span></span><span></span><span></span> <div class="typing-indicator">
</div> <span></span><span></span><span></span>
<div </div>
v-else <div class="streaming-hint">
class="message-content" AI 正在构思… 已 {{ elapsedText }}<template v-if="elapsed >= 20">,复杂的网页要想一两分钟</template>
v-html="renderMarkdown(streamingContent)" </div>
></div> </template>
<div class="streaming-hint">AI 正在生成中…</div> <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> </div>
<RatingCard v-if="rating" :active="streaming" />
</div> </div>
<GuidancePanel @generate="onGuidanceGenerate" /> <GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area"> <div class="input-area">
<n-flex <!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
class="prompt-input-row" <div v-if="vagueHint" class="vague-hint">
align="flex-start" <span>说清楚哪里不对,AI 更容易一次修好</span>
:wrap="false" <n-flex :size="4" :wrap="false">
:size="8" <n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
> <n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
<n-input </n-flex>
v-model:value="input" </div>
type="textarea" <div v-if="problemOpen" class="problem">
:autosize="{ minRows: 1, maxRows: 4 }" <div class="problem-title">出问题了?说清楚这三件事</div>
placeholder="描述你想要的网页效果..." <label>我做了什么</label>
:disabled="streaming" <n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
style="flex: 1; min-width: 0" <label>想看到什么</label>
@keydown.enter.exact.prevent="send" <n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
/> <label>实际看到了什么</label>
<n-button <n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
secondary <n-flex justify="end" :size="8" style="margin-top: 10px">
:disabled="!input.trim() || !connected || !currentTaskId || streaming" <n-button @click="problemOpen = false">取消</n-button>
style="flex-shrink: 0" <n-button
title="调教提示词" type="primary"
aria-label="调教提示词" :disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
@click="startGuidance" @click="sendProblem"
>
发给 AI
</n-button>
</n-flex>
</div>
<template v-else>
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
> >
<template #icon> <n-input
<Icon icon="lucide:lightbulb" :width="16" /> v-model:value="input"
</template> type="textarea"
</n-button> :autosize="{ minRows: 1, maxRows: 4 }"
</n-flex> placeholder="用你自己的话说说想做成什么样…"
<n-flex justify="flex-end" align="center" style="margin-top: 8px"> :disabled="streaming"
<n-select style="flex: 1; min-width: 0"
v-model:value="selectedModel" @keydown.enter.exact.prevent="send"
:options="modelOptions" />
style="width: 150px" <n-tooltip>
:disabled="streaming" <template #trigger>
/> <n-button
<n-button v-if="streaming" type="error" @click="stopPrompt"> secondary
停止 :disabled="!input.trim() || !connected || !currentTaskId || streaming"
</n-button> style="flex-shrink: 0"
<n-button @click="startGuidance"
v-else >
type="primary" <template #icon>
:disabled="!input.trim() || !connected" <Icon icon="lucide:lightbulb" :width="16" />
@click="send" </template>
> 帮我写清楚
发送 </n-button>
</n-button> </template>
</n-flex> 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>
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { useStorage } from "@vueuse/core"
import { useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "../../icons"
import GuidancePanel from "./GuidancePanel.vue" import GuidancePanel from "./GuidancePanel.vue"
import RatingCard from "../workbench/RatingCard.vue"
import { openGuidance } from "../../store/guidance" import { openGuidance } from "../../store/guidance"
import { import {
messages, messages,
streaming, streaming,
streamingContent, streamingContent,
streamStartedAt,
connected, connected,
sendPrompt, sendPrompt,
stopPrompt, stopPrompt,
currentTaskId, currentTaskId,
removeMessagePair, removeMessagePair,
markMessageSubmitted, type PromptMessage,
} from "../../store/prompt" } from "../../store/prompt"
import { Prompt, Submission } from "../../api" import { Prompt } from "../../api"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>() const props = defineProps<{
// 发送前调用:工作台用它把还没存的手改先存成版本,AI 才会在手改后的代码上继续改
beforeSend?: () => Promise<void>
// AI 生成时在对话里显示同学作品让学生打分(挑战页才有)
rating?: boolean
}>()
const emit = defineEmits<{ deleted: []; submitted: [] }>() const emit = defineEmits<{ deleted: [] }>()
const input = ref("") const input = ref("")
const messagesRef = ref<HTMLElement>() const messagesRef = ref<HTMLElement>()
@@ -150,20 +215,17 @@ const modelOptions = [
{ label: "DeepSeek", value: "deepseek-flash" }, { label: "DeepSeek", value: "deepseek-flash" },
] ]
const selectedModel = useStorage("prompt-model", "deepseek-flash") const selectedModel = useStorage("prompt-model", "deepseek-flash")
// 浏览器里可能还存着改版前的模型名
if (!modelOptions.some((o) => o.value === selectedModel.value)) {
selectedModel.value = "deepseek-flash"
}
// Group messages into user+assistant pairs // Group messages into user+assistant pairs
const submittingId = ref<number | null>(null)
const pairs = computed(() => { const pairs = computed(() => {
const result: Array<{ const result: Array<{
userMsg: { role: string; content: string; id?: number } userMsg: PromptMessage
assistantMsg: { assistantMsg: PromptMessage | null
role: string
content: string
id?: number
code?: any
submitted?: boolean
} | null
index: number index: number
}> = [] }> = []
const msgs = messages.value const msgs = messages.value
@@ -186,30 +248,17 @@ function hasCode(msg: { code?: any } | null): boolean {
return !!(msg.code.html || msg.code.css || msg.code.js) return !!(msg.code.html || msg.code.css || msg.code.js)
} }
async function submitVersion(msg: { // 带代码的 AI 回复就是一个版本,按出现顺序编号(和工作台底部的版本条一致)
id?: number const versionNumbers = computed(() => {
code?: { html: string | null; css: string | null; js: string | null } const map = new Map<number, number>()
}) { for (const m of messages.value) {
if (!msg.id || !msg.code || !currentTaskId.value) return if (m.role === "assistant" && m.id && hasCode(m)) map.set(m.id, map.size + 1)
submittingId.value = msg.id
try {
await Submission.create(
currentTaskId.value,
{
html: msg.code.html ?? "",
css: msg.code.css ?? "",
js: msg.code.js ?? "",
},
msg.id,
)
markMessageSubmitted(msg.id)
naiveMessage.success("提交成功")
emit("submitted")
} catch {
naiveMessage.error("提交失败,请重试")
} finally {
submittingId.value = null
} }
return map
})
function versionOf(msg: { id?: number }) {
return msg.id ? versionNumbers.value.get(msg.id) : undefined
} }
async function deletePair(assistantMsgId: number) { async function deletePair(assistantMsgId: number) {
@@ -223,11 +272,52 @@ async function deletePair(assistantMsgId: number) {
} }
} }
function send() { async function send() {
const text = input.value.trim() const text = input.value.trim()
if (!text || streaming.value) return if (!text || streaming.value) return
sendPrompt(text, selectedModel.value) // 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
vagueHint.value = true
return
}
input.value = "" 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)
} }
function startGuidance() { function startGuidance() {
@@ -236,15 +326,53 @@ function startGuidance() {
openGuidance(currentTaskId.value, text) openGuidance(currentTaskId.value, text)
} }
function onGuidanceGenerate(finalPrompt: string) { async function onGuidanceGenerate(finalPrompt: string) {
sendPrompt(finalPrompt, selectedModel.value)
input.value = "" input.value = ""
await props.beforeSend?.()
sendPrompt(finalPrompt, selectedModel.value)
} }
function renderContent(msg: { role: string; content: string }): string { // 学生只关心成品:AI 回复里的代码块不在对话里展开(右边“代码”里能看),只说写了多少行。
return renderMarkdown(msg.content) // 按 ``` 切开,奇数段是代码;段数是偶数说明最后一个代码块还没写完
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], () => { watch([() => messages.value.length, streamingContent], () => {
nextTick(() => { nextTick(() => {
if (messagesRef.value) { if (messagesRef.value) {
@@ -277,15 +405,15 @@ watch([() => messages.value.length, streamingContent], () => {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
margin-bottom: 4px; margin-bottom: 4px;
color: #666; color: var(--wp-muted);
} }
.message.user .message-role { .message.user .message-role {
color: #2080f0; color: var(--wp-css);
} }
.message.assistant .message-role { .message.assistant .message-role {
color: #18a058; color: var(--wp-brand);
} }
.message-content { .message-content {
@@ -293,8 +421,38 @@ watch([() => messages.value.length, streamingContent], () => {
line-height: 1.6; line-height: 1.6;
} }
/* 手改代码这类不是对话的一行说明 */
.message.note {
font-size: 12px;
color: var(--wp-muted);
text-align: center;
}
.role-tag {
margin-left: 6px;
font-weight: normal;
color: var(--wp-muted);
}
.version-line {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 12px;
color: var(--wp-muted);
}
.version-chip {
padding: 0 7px;
border: 1px solid var(--wp-brand);
border-radius: 4px;
color: var(--wp-brand);
font: 600 12px/18px var(--wp-font-mono);
}
.message-content :deep(pre) { .message-content :deep(pre) {
background: #f5f5f5; background: var(--wp-paper);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
overflow-x: auto; overflow-x: auto;
@@ -325,7 +483,7 @@ watch([() => messages.value.length, streamingContent], () => {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #18a058; background: var(--wp-brand);
animation: bounce 1.4s ease-in-out infinite; animation: bounce 1.4s ease-in-out infinite;
} }
@@ -350,17 +508,56 @@ 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 { .streaming-hint {
font-size: 11px; font-size: 11px;
color: #aaa; color: var(--wp-faint);
margin-top: 4px; margin-top: 4px;
animation: pulse 1.5s ease-in-out infinite; 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 { .input-area {
flex-shrink: 0; flex-shrink: 0;
padding: 12px; padding: 12px;
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--wp-line);
} }
.message-pair { .message-pair {
@@ -378,11 +575,11 @@ watch([() => messages.value.length, streamingContent], () => {
top: 0; top: 0;
right: 0; right: 0;
background: none; background: none;
border: 1px solid #e0e0e0; border: 1px solid var(--wp-line);
border-radius: 4px; border-radius: 4px;
padding: 3px 6px; padding: 3px 6px;
cursor: pointer; cursor: pointer;
color: #bbb; color: var(--wp-faint);
line-height: 1; line-height: 1;
transition: transition:
color 0.15s, color 0.15s,
@@ -390,8 +587,8 @@ watch([() => messages.value.length, streamingContent], () => {
} }
.pair-delete-btn:hover { .pair-delete-btn:hover {
color: #e03e3e; color: var(--wp-bad);
border-color: #e03e3e; border-color: var(--wp-bad);
} }
.message-pair.has-delete:hover .pair-delete-btn { .message-pair.has-delete:hover .pair-delete-btn {
@@ -1,135 +0,0 @@
<template>
<n-modal
preset="card"
:show="show"
title="等待 AI 回复时,给同学的作品打个分吧"
style="width: 90vw; max-width: 960px"
@update:show="onUpdateShow"
>
<n-text
v-if="current"
depth="3"
style="font-size: 12px; display: block; margin-bottom: 8px"
>
{{ current.task_title }} · 提交者 {{ current.username }}
</n-text>
<div class="preview-wrapper">
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
</div>
<div class="rate-row">
<n-rate
v-if="current"
:key="current.submission_id"
:size="32"
@update:value="onRate"
/>
</div>
<n-text
depth="3"
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
>
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
</n-text>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue"
import { useMessage } from "naive-ui"
import { Submission } from "../../api"
import { streaming } from "../../store/prompt"
import { buildPreviewDocument } from "../../utils/previewDocument"
import type { RandomRatingItem } from "../../utils/type"
const message = useMessage()
const show = ref(false)
const current = ref<RandomRatingItem | null>(null)
const previewContent = ref("")
let reshowTimer: ReturnType<typeof setTimeout> | null = null
function clearReshowTimer() {
if (reshowTimer !== null) {
clearTimeout(reshowTimer)
reshowTimer = null
}
}
async function fetchAndShow() {
try {
const item = await Submission.randomForRating(current.value?.submission_id)
if (item) {
current.value = item
previewContent.value = buildPreviewDocument({
html: item.html ?? "",
css: item.css ?? "",
js: item.js ?? "",
})
show.value = true
} else {
show.value = false
}
} catch (err: any) {
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
}
}
async function onRate(score: number) {
if (!current.value) return
try {
await Submission.updateScore(current.value.submission_id, score)
message.success("感谢评分!")
await fetchAndShow()
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败,请重试")
}
}
function onUpdateShow(value: boolean) {
show.value = value
if (!value) {
clearReshowTimer()
if (streaming.value) {
reshowTimer = setTimeout(fetchAndShow, 30_000)
}
}
}
watch(
streaming,
(val) => {
clearReshowTimer()
if (val) {
fetchAndShow()
} else {
show.value = false
}
},
{ immediate: true },
)
onUnmounted(() => {
clearReshowTimer()
})
</script>
<style scoped>
.preview-wrapper {
height: 70vh;
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.rate-row {
display: flex;
justify-content: center;
padding: 16px 0;
}
</style>
@@ -1,5 +1,10 @@
<template> <template>
<MdEditor v-bind="props" v-model="modelValue" @upload-img="uploadImg" /> <MdEditor
v-bind="props"
v-model="modelValue"
:theme="isDark ? 'dark' : 'light'"
@upload-img="uploadImg"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { MdEditor } from "md-editor-v3" import { MdEditor } from "md-editor-v3"
@@ -7,6 +12,7 @@ import { MdEditor } from "md-editor-v3"
import "md-editor-v3/lib/style.css" import "md-editor-v3/lib/style.css"
import { Helper } from "../../api" import { Helper } from "../../api"
import { useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { isDark } from "../../store/theme"
const message = useMessage() const message = useMessage()
@@ -28,7 +28,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue" import { ref } from "vue"
import { useMessage } from "naive-ui" import { useDialog, useMessage } from "naive-ui"
import { Account } from "../../api" import { Account } from "../../api"
import { Role, type User } from "../../utils/type" import { Role, type User } from "../../utils/type"
import UserFormModal from "./UserFormModal.vue" import UserFormModal from "./UserFormModal.vue"
@@ -41,15 +41,27 @@ const props = defineProps<Props>()
const emits = defineEmits(["reload"]) const emits = defineEmits(["reload"])
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const showEdit = ref(false) const showEdit = ref(false)
function edit() { function edit() {
showEdit.value = true showEdit.value = true
} }
async function toggleUserActive() { async function doToggle() {
const data = await Account.toggleActive(props.user.id) const data = await Account.toggleActive(props.user.id)
message.success(data.message) message.success(data.message)
emits("reload") emits("reload")
} }
function toggleUserActive() {
if (!props.user.is_active) return doToggle()
dialog.warning({
title: `封禁「${props.user.username}」?`,
content: "封禁后这个账号不能登录,已经登录的也会被退出。之后可以再解封。",
positiveText: "封禁",
negativeText: "取消",
onPositiveClick: doToggle,
})
}
</script> </script>
+26 -12
View File
@@ -5,23 +5,26 @@ import { css } from "@codemirror/lang-css"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { html } from "@codemirror/lang-html" import { html } from "@codemirror/lang-html"
import { computed } from "vue" import { computed } from "vue"
import { syntaxHighlighting } from "@codemirror/language"
import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import { isDark } from "../../store/theme"
const styleTheme = EditorView.baseTheme({ const styleTheme = EditorView.baseTheme({
"& .cm-scroller": { "& .cm-scroller": {
"font-family": "Monaco", "font-family": "var(--wp-font-mono)",
}, },
"&.cm-editor.cm-focused": { "&.cm-editor.cm-focused": {
outline: "none", outline: "none",
}, },
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": { "&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
"font-family": "Monaco", "font-family": "var(--wp-font-mono)",
}, },
}) })
const bgColors: Record<string, string> = { const bgColors: Record<string, string> = {
html: "#fff6f3", html: "var(--wp-html-soft)",
css: "#f3f6ff", css: "var(--wp-css-soft)",
js: "#fffdf0", js: "var(--wp-js-soft)",
} }
interface Props { interface Props {
@@ -31,7 +34,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
language: "html", language: "html",
fontSize: 20, fontSize: 16,
}) })
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -43,14 +46,25 @@ const lang = computed(() => {
}) })
const bgTheme = computed(() => { const bgTheme = computed(() => {
const bg = bgColors[props.language] ?? "#ffffff" const bg = bgColors[props.language] ?? "var(--wp-surface)"
return EditorView.theme({ return EditorView.theme(
"&": { backgroundColor: bg }, {
".cm-gutters": { backgroundColor: bg }, "&": { backgroundColor: bg, color: "var(--wp-ink)" },
}) ".cm-gutters": { backgroundColor: bg, color: "var(--wp-faint)", borderColor: "var(--wp-line)" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "rgba(127, 127, 127, 0.08)" },
".cm-cursor": { borderLeftColor: "var(--wp-ink)" },
},
{ dark: isDark.value },
)
}) })
const extensions = computed(() => [styleTheme, bgTheme.value, lang.value]) // 暗色时换成 One Dark 的语法配色(默认配色是给浅色背景设计的)
const extensions = computed(() => [
styleTheme,
bgTheme.value,
...(isDark.value ? [syntaxHighlighting(oneDarkHighlightStyle)] : []),
lang.value,
])
</script> </script>
<template> <template>
<Codemirror <Codemirror
+20 -54
View File
@@ -10,37 +10,25 @@
> >
<n-tab-pane name="html" tab="HTML"> <n-tab-pane name="html" tab="HTML">
<template #tab> <template #tab>
<n-flex align="center"> <span class="lang-tab lang-html">HTML</span>
<Icon :width="20" icon="skill-icons:html"></Icon>
<span>HTML</span>
</n-flex>
</template> </template>
<Editor v-model:value="html" :font-size="size" language="html" /> <Editor v-model:value="html" :font-size="size" language="html" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="css" tab="CSS"> <n-tab-pane name="css" tab="CSS">
<template #tab> <template #tab>
<n-flex align="center"> <span class="lang-tab lang-css">CSS</span>
<Icon :width="20" icon="skill-icons:css"></Icon>
<span>CSS</span>
</n-flex>
</template> </template>
<Editor v-model:value="css" :font-size="size" language="css" /> <Editor v-model:value="css" :font-size="size" language="css" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="js" tab="JS"> <n-tab-pane name="js" tab="JS">
<template #tab> <template #tab>
<n-flex align="center"> <span class="lang-tab lang-js">JS</span>
<Icon :width="20" icon="skill-icons:javascript"></Icon>
<span>JS</span>
</n-flex>
</template> </template>
<Editor v-model:value="js" :font-size="size" language="js" /> <Editor v-model:value="js" :font-size="size" language="js" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="actions" tab="选项"> <n-tab-pane name="actions" tab="选项">
<template #tab> <template #tab>
<n-flex align="center"> <span class="lang-tab">选项</span>
<Icon :width="20" icon="skill-icons:actix-dark"></Icon>
<span>选项</span>
</n-flex>
</template> </template>
<n-flex class="wrapper" vertical> <n-flex class="wrapper" vertical>
<n-flex align="center"> <n-flex align="center">
@@ -53,7 +41,7 @@
<span class="label">字号</span> <span class="label">字号</span>
<n-flex align="center"> <n-flex align="center">
<span :style="{ 'font-size': size + 'px' }">{{ size }}</span> <span :style="{ 'font-size': size + 'px' }">{{ size }}</span>
<n-button :disabled="size === 20" @click="changeSize(size - 2)"> <n-button :disabled="size <= 12" @click="changeSize(size - 2)">
调小 调小
</n-button> </n-button>
<n-button :disabled="size === 40" @click="changeSize(size + 2)"> <n-button :disabled="size === 40" @click="changeSize(size + 2)">
@@ -78,16 +66,11 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue"
import prettier from "prettier/standalone"
import * as htmlParser from "prettier/parser-html"
import * as cssParser from "prettier/parser-postcss"
import * as babelParser from "prettier/parser-babel"
import * as estreeParser from "prettier/plugins/estree"
import Editor from "./Editor.vue" import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue" import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../../store/editors" import { html, css, js, tab, size, reset } from "../../store/editors"
import { taskId } from "../../store/task" import { taskId } from "../../store/task"
import { formatCode } from "../../utils/code"
import { Submission } from "../../api" import { Submission } from "../../api"
import { NCode, useDialog, useMessage } from "naive-ui" import { NCode, useDialog, useMessage } from "naive-ui"
import { h, ref } from "vue" import { h, ref } from "vue"
@@ -104,34 +87,16 @@ function changeSize(num: number) {
size.value = num size.value = num
} }
async function formatCode() { async function formatEditors() {
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([ const out = await formatCode({ html: html.value, css: css.value, js: js.value })
prettier.format(html.value, { html.value = out.html
parser: "html", css.value = out.css
//@ts-ignore js.value = out.js
plugins: [htmlParser, babelParser, estreeParser, cssParser],
tabWidth: 4,
}),
prettier.format(css.value, {
parser: "css",
plugins: [cssParser],
tabWidth: 4,
}),
prettier.format(js.value, {
parser: "babel",
//@ts-ignore
plugins: [babelParser, estreeParser],
tabWidth: 2,
}),
])
html.value = htmlFormatted
css.value = cssFormatted
js.value = jsFormatted
} }
async function format() { async function format() {
try { try {
await formatCode() await formatEditors()
} catch (err: any) { } catch (err: any) {
dialog.error({ dialog.error({
title: "格式化失败", title: "格式化失败",
@@ -157,7 +122,7 @@ async function doSubmit() {
async function formatAndSubmit() { async function formatAndSubmit() {
submitLoading.value = true submitLoading.value = true
try { try {
await formatCode() await formatEditors()
await doSubmit() await doSubmit()
} catch (err: any) { } catch (err: any) {
dialog.warning({ dialog.warning({
@@ -189,14 +154,15 @@ async function formatAndSubmit() {
font-size: 16px; font-size: 16px;
} }
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) { :deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
background-color: #fff6f3 !important; background-color: var(--wp-html-soft) !important;
} }
:deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) { :deep(.n-tabs-nav .n-tabs-tab[data-name="css"]) {
background-color: #f3f6ff !important; background-color: var(--wp-css-soft) !important;
} }
:deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) { :deep(.n-tabs-nav .n-tabs-tab[data-name="js"]) {
background-color: #fffdf0 !important; background-color: var(--wp-js-soft) !important;
} }
.editors-root { .editors-root {
@@ -204,12 +170,12 @@ async function formatAndSubmit() {
} }
:deep(.tab-active-html .n-tab-pane) { :deep(.tab-active-html .n-tab-pane) {
background-color: #fff6f3; background-color: var(--wp-html-soft);
} }
:deep(.tab-active-css .n-tab-pane) { :deep(.tab-active-css .n-tab-pane) {
background-color: #f3f6ff; background-color: var(--wp-css-soft);
} }
:deep(.tab-active-js .n-tab-pane) { :deep(.tab-active-js .n-tab-pane) {
background-color: #fffdf0; background-color: var(--wp-js-soft);
} }
</style> </style>
+53 -77
View File
@@ -1,10 +1,7 @@
<template> <template>
<n-flex align="center" justify="space-between" class="title"> <n-flex align="center" justify="space-between" class="title">
<div> <div>
<n-text class="titleText">预览</n-text> <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> </div>
<n-flex> <n-flex>
<n-tooltip> <n-tooltip>
@@ -19,55 +16,42 @@
</n-tooltip> </n-tooltip>
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button> <n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</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>
</n-flex> </n-flex>
<div class="iframe-wrapper" :style="iframeWrapperStyle"> <div class="iframe-wrapper" :style="iframeWrapperStyle">
<iframe class="iframe" :srcdoc="previewContent"></iframe> <iframe
ref="frame"
class="iframe"
:sandbox="PREVIEW_SANDBOX"
:key="frameKey"
:srcdoc="previewContent"
></iframe>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watchDebounced } from "@vueuse/core" import { watchDebounced } from "@vueuse/core"
import { computed, onMounted, ref } from "vue" import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRouter } from "vue-router" import { Icon } from "../../icons"
import { Submission } from "../../api" import {
import { submission } from "../../store/submission" buildPreviewDocument,
import { useMessage } from "naive-ui" PREVIEW_SANDBOX,
import { Icon } from "@iconify/vue" } from "../../utils/previewDocument"
import copy from "copy-text-to-clipboard" import {
import { buildPreviewDocument } from "../../utils/previewDocument" listenPreviewStorage,
loadPreviewStorage,
} from "../../utils/previewStorage"
import { user } from "../../store/user"
interface Props { interface Props {
html: string html: string
css: string css: string
js: string js: string
assetBaseUrl?: string assetBaseUrl?: string
submissionId?: string // 自己的编辑器传当前任务 id:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
showCodeButton?: boolean storageTaskId?: number
clearable?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode", "clear"])
type Layout = "desktop" | "mobile" | "tablet" type Layout = "desktop" | "mobile" | "tablet"
const layouts: Layout[] = ["desktop", "mobile", "tablet"] const layouts: Layout[] = ["desktop", "mobile", "tablet"]
@@ -105,27 +89,34 @@ function cycleLayout() {
layoutIndex.value = (layoutIndex.value + 1) % layouts.length layoutIndex.value = (layoutIndex.value + 1) % layouts.length
} }
const message = useMessage()
const router = useRouter()
const showDL = computed(() => props.html || props.css || props.js) const showDL = computed(() => props.html || props.css || props.js)
const previewContent = ref("") const previewContent = ref("")
function getContent() { const frame = useTemplateRef<HTMLIFrameElement>("frame")
const storageScope = () =>
props.storageTaskId ? `${user.username}:${props.storageTaskId}` : ""
function getContent(forDownload = false) {
const scope = storageScope()
return buildPreviewDocument({ return buildPreviewDocument({
html: props.html, html: props.html,
css: props.css, css: props.css,
js: props.js, js: props.js,
assetBaseUrl: props.assetBaseUrl, assetBaseUrl: props.assetBaseUrl,
storage: forDownload ? false : scope ? loadPreviewStorage(scope) : undefined,
}) })
} }
// 每次内容变了换一个新的 iframe:在同一个 iframe 里改 srcdoc 会进浏览器历史,
// 按“后退”就成了在预览里倒退,而不是离开页面
const frameKey = ref(0)
function preview() { function preview() {
previewContent.value = getContent() previewContent.value = getContent()
frameKey.value++
} }
function download() { function download() {
const content = getContent() const content = getContent(true)
const blob = new Blob([content], { type: "text/html" }) const blob = new Blob([content], { type: "text/html" })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
@@ -136,40 +127,21 @@ function download() {
} }
function open() { function open() {
if (props.submissionId) { const newTab = window.open("about:blank", "_blank")
const data = router.resolve({ if (!newTab) return
name: "submission", // 新标签页和本站同源,代码要放进带 sandbox 的 iframe 里跑
params: { id: props.submissionId }, const doc = newTab.document
}) doc.open()
window.open(data.href, "_blank") doc.write(
} else { `<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
const newTab = window.open("about:blank", "_blank") )
if (!newTab) return doc.close()
newTab.document.open() const tabFrame = doc.querySelector("iframe")!
newTab.document.write(getContent()) // 全屏页里的写入也存到同一个地方
newTab.document.close() 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( watchDebounced(
() => [props.html, props.css, props.js, props.assetBaseUrl], () => [props.html, props.css, props.js, props.assetBaseUrl],
@@ -180,13 +152,16 @@ watchDebounced(
}, },
) )
onMounted(preview) onMounted(preview)
onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
</script> </script>
<style scoped> <style scoped>
.title { .title {
height: 43px; height: 43px;
padding: 0 20px; padding: 0 12px 0 16px;
border-bottom: 1px solid rgb(239, 239, 245); border-bottom: 1px solid var(--wp-line);
background: var(--wp-surface);
box-sizing: border-box; box-sizing: border-box;
flex-wrap: nowrap !important;
} }
.titleText { .titleText {
@@ -195,7 +170,8 @@ onMounted(preview)
.iframe-wrapper { .iframe-wrapper {
width: 100%; width: 100%;
height: 100%; /* 减去上面标题栏的高度,否则底部 43px 会被裁掉 */
height: calc(100% - 43px);
margin: 0 auto; margin: 0 auto;
transition: max-width 0.3s ease; transition: max-width 0.3s ease;
overflow: hidden; overflow: hidden;
+11 -84
View File
@@ -1,43 +1,31 @@
<template> <template>
<n-flex align="center" class="corner"> <n-flex align="center" class="corner">
<n-button quaternary v-if="!show" @click="showTutorial"> 教程 </n-button> <n-button quaternary v-if="!show" @click="showTutorial">显示教程</n-button>
<template v-if="user.loaded && authed"> <template v-if="user.loaded && authed">
<n-button quaternary @click="emit('format')">整理</n-button> <n-tooltip>
<template #trigger>
<n-button quaternary @click="emit('format')">整理代码</n-button>
</template>
自动整理缩进和换行
</n-tooltip>
<n-button <n-button
type="primary" type="primary"
secondary
:disabled="submitDisabled" :disabled="submitDisabled"
:loading="submitLoading" :loading="submitLoading"
@click="emit('submit')" @click="emit('submit')"
> >
提交 提交
</n-button> </n-button>
<n-dropdown :options="menu" @select="clickMenu">
<n-button>{{ user.username }}</n-button>
</n-dropdown>
</template> </template>
<n-button
v-if="user.loaded && !authed"
@click="handleLogin"
secondary
type="primary"
>
登录
</n-button>
</n-flex> </n-flex>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, h } from "vue" import { computed } from "vue"
import { Icon } from "@iconify/vue" import { authed, user } from "../../store/user"
import { authed, roleAdmin, roleNormal, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { show, panelSize } from "../../store/panel" import { show, panelSize } from "../../store/panel"
import { taskId } from "../../store/task" import { taskId } from "../../store/task"
import { Account } from "../../api"
import { Role } from "../../utils/type"
import { router } from "../../router"
const props = defineProps<{ defineProps<{
submitLoading: boolean submitLoading: boolean
}>() }>()
const emit = defineEmits(["format", "submit"]) const emit = defineEmits(["format", "submit"])
@@ -46,74 +34,13 @@ const submitDisabled = computed(() => {
return taskId.value === 0 return taskId.value === 0
}) })
const menu = computed(() => [
{
label: "后台管理",
key: "dashboard",
show: !roleNormal.value,
icon: () =>
h(Icon, {
icon: "streamline-emojis:robot-face-1",
width: 20,
}),
},
{
label: "我的提交",
key: "submissions",
icon: () =>
h(Icon, {
icon: "streamline-emojis:bar-chart",
width: 20,
}),
},
{
label: "退出账号",
key: "logout",
icon: () =>
h(Icon, {
icon: "streamline-emojis:hot-beverage-2",
width: 20,
}),
},
])
function showTutorial() { function showTutorial() {
show.value = true show.value = true
panelSize.value = 2 / 5 panelSize.value = 2 / 5
} }
function clickMenu(name: string) {
switch (name) {
case "dashboard": {
const route = roleAdmin.value ? "challenge-editor" : "tutorial-editor"
router.push({ name: route, params: { display: 0 } })
break
}
case "submissions":
router.push({
name: "submissions",
params: { page: 1 },
query: { username: user.username },
})
break
case "logout":
handleLogout()
break
}
}
function handleLogin() {
loginModal.value = true
}
async function handleLogout() {
await Account.logout()
user.username = ""
user.role = Role.Normal
}
</script> </script>
<style scoped> <style scoped>
.corner { .corner {
margin-right: 20px; margin-right: 12px;
} }
</style> </style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div class="shell">
<header class="topbar">
<router-link class="brand" :to="tutorialRoute">
<img src="/noto--honeybee.ico" alt="" width="20" height="20" />
<span>前端开发学习平台</span>
</router-link>
<nav class="nav">
<router-link
v-for="item in nav"
:key="item.label"
:to="item.to"
:class="['nav-item', { active: item.active }]"
>
{{ item.label }}
</router-link>
</nav>
<div class="spacer"></div>
<n-tooltip>
<template #trigger>
<n-button quaternary circle size="small" @click="cycleTheme">
<Icon :icon="themeIcon" :width="16" />
</n-button>
</template>
外观:{{ themeLabel }}(点击切换)
</n-tooltip>
<template v-if="user.loaded">
<router-link
v-if="roleStaff"
:to="{ name: 'dashboard' }"
:class="['nav-item', { active: inTeach }]"
>
教学
</router-link>
<n-dropdown
v-if="authed"
:options="userMenu"
trigger="click"
@select="clickUserMenu"
>
<n-button quaternary class="user-btn">
{{ user.username }}
</n-button>
</n-dropdown>
<n-button v-else type="primary" size="small" @click="loginModal = true">
登录
</n-button>
</template>
</header>
<main class="main">
<router-view v-if="$route.matched.length" />
<div v-else-if="user.loaded && !authed" class="signed-out">
<p>登录后开始学习</p>
<n-button type="primary" @click="loginModal = true">登录</n-button>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { authed, roleStaff, user } from "../../store/user"
import { loginModal } from "../../store/modal"
import { step } from "../../store/tutorial"
import { logout } from "../../store/session"
import { themeMode, type ThemeMode } from "../../store/theme"
import { Icon } from "../../icons"
const themeOrder: ThemeMode[] = ["auto", "light", "dark"]
const themeLabel = computed(
() => ({ auto: "跟随系统", light: "亮色", dark: "暗色" })[themeMode.value],
)
const themeIcon = computed(
() => ({ auto: "lucide:sun-moon", light: "lucide:sun", dark: "lucide:moon" })[themeMode.value],
)
function cycleTheme() {
themeMode.value = themeOrder[(themeOrder.indexOf(themeMode.value) + 1) % themeOrder.length]!
}
const route = useRoute()
const router = useRouter()
const routeName = computed(() => String(route.name ?? ""))
const inTeach = computed(() => route.path.startsWith("/dashboard"))
const tutorialRoute = computed(() =>
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
const nav = computed(() => [
{
label: "教程",
to: tutorialRoute.value,
active:
routeName.value === "home" || routeName.value.startsWith("home-tutorial"),
},
{
label: "挑战",
to: { name: "home-challenge-list" },
active: routeName.value.startsWith("home-challenge"),
},
{
label: "自由创作",
to: { name: "creation-list" },
active: routeName.value.startsWith("creation"),
},
{
label: "作品展",
to: { name: "showcase" },
active: routeName.value === "showcase",
},
])
const userMenu = [
{ label: "我的提交", key: "submissions" },
{ type: "divider", key: "d1" },
{ label: "退出登录", key: "logout" },
]
async function clickUserMenu(key: string) {
if (key === "submissions") {
router.push({ name: "submissions" })
} else if (key === "logout") {
await logout()
// 留在需要登录或特定角色的页面上没有意义
if (route.meta.auth || route.meta.role) router.push(tutorialRoute.value)
}
}
</script>
<style scoped>
.shell {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--wp-paper);
}
.topbar {
height: var(--wp-topbar-h);
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 0 16px;
background: var(--wp-surface);
border-bottom: 1px solid var(--wp-line);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
margin-right: 20px;
color: var(--wp-ink);
font-weight: 600;
font-size: 15px;
text-decoration: none;
white-space: nowrap;
}
.nav {
display: flex;
gap: 2px;
}
.nav-item {
padding: 6px 12px;
border-radius: 6px;
color: var(--wp-muted);
font-size: 14px;
text-decoration: none;
white-space: nowrap;
}
.nav-item:hover {
color: var(--wp-ink);
background: var(--wp-paper);
}
.nav-item.active {
color: var(--wp-brand);
background: var(--wp-brand-soft);
font-weight: 600;
}
.nav-item:focus-visible,
.brand:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 2px;
}
.spacer {
flex: 1;
}
.user-btn {
margin-left: 4px;
}
.main {
height: var(--wp-main-h);
min-height: 0;
overflow: auto;
}
.signed-out {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--wp-muted);
}
</style>
@@ -0,0 +1,236 @@
<template>
<n-drawer v-model:show="show" :width="600" placement="right">
<n-drawer-content title="作品" :native-scrollbar="false">
<n-spin :show="loading">
<n-result
v-if="data?.locked"
status="info"
title="交过这道题以后就能看到"
description="先完成自己的作品并提交,再来看看同学们是怎么做的。"
style="margin-top: 40px"
/>
<template v-else-if="data">
<n-flex justify="space-between" align="center" class="bar">
<span class="hint">
{{
roleStaff
? "学生们在这道题上最新的作品。"
: "同学们在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。"
}}
</span>
<n-radio-group v-model:value="scope" size="small">
<n-radio-button value="class">本班</n-radio-button>
<n-radio-button value="all">全部班级</n-radio-button>
</n-radio-group>
</n-flex>
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
<div class="grid">
<div v-for="w in data.items" :key="w.id" class="work">
<button class="thumb" title="点击放大看" @click="large = w">
<iframe
:sandbox="PREVIEW_SANDBOX"
:srcdoc="docOf(w)"
loading="lazy"
tabindex="-1"
></iframe>
</button>
<div class="info">
<span :class="{ anon: !w.username }">
<span v-if="scope === 'all' && w.classname" class="cls">{{ w.classname }} 班</span>
{{ w.username ?? "匿名同学" }}
</span>
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
</div>
<div v-if="!w.my_score" class="rates">
<n-tooltip v-for="s in scores" :key="s.value">
<template #trigger>
<button class="rate" :disabled="rating === w.id" @click="rate(w, s.value)">
{{ s.value }}
</button>
</template>
{{ s.value }} 分:{{ s.label }}
</n-tooltip>
</div>
</div>
</div>
</template>
</n-spin>
</n-drawer-content>
</n-drawer>
<n-modal
:show="!!large"
preset="card"
:title="large?.username ?? '匿名同学的作品'"
style="width: 90vw; max-width: 1000px"
@update:show="(v: boolean) => !v && (large = null)"
>
<div v-if="large" class="large">
<iframe :key="large.id" :sandbox="PREVIEW_SANDBOX" :srcdoc="docOf(large)"></iframe>
</div>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue"
import { useMessage } from "naive-ui"
import { Review, Submission } from "../../api"
import { roleStaff } from "../../store/user"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import type { PeerWork, PeerWorks } from "../../utils/type"
const show = defineModel<boolean>("show", { required: true })
const props = defineProps<{ taskId: number; assetBaseUrl?: string }>()
const message = useMessage()
const scores = [
{ value: 1, label: "没完成题目要求" },
{ value: 2, label: "完成了一小部分" },
{ value: 3, label: "基本完成" },
{ value: 4, label: "完成得不错" },
{ value: 5, label: "完成且有自己的想法" },
]
const data = ref<PeerWorks | null>(null)
const loading = ref(false)
const rating = ref<string | null>(null)
const large = ref<PeerWork | null>(null)
const scope = ref<"class" | "all">("class")
const docOf = (w: PeerWork) =>
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
let seq = 0
async function load() {
if (!props.taskId) return
const s = ++seq
loading.value = true
try {
const res = await Review.peers(props.taskId, scope.value)
if (s === seq) data.value = res
} finally {
if (s === seq) loading.value = false
}
}
watch(show, (open) => open && load())
watch(scope, load)
async function rate(w: PeerWork, score: number) {
rating.value = w.id
try {
const res = await Submission.updateScore(w.id, score)
w.my_score = score
w.username = res.username ?? null
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败,请重试")
} finally {
rating.value = null
}
}
</script>
<style scoped>
.bar {
margin-bottom: 12px;
flex-wrap: nowrap !important;
}
.hint {
font-size: 13px;
color: var(--wp-muted);
}
.cls {
margin-right: 4px;
color: var(--wp-muted);
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.work {
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
background: var(--wp-surface);
}
.thumb {
position: relative;
display: block;
width: 100%;
height: 150px;
padding: 0;
border: 0;
border-bottom: 1px solid var(--wp-line);
overflow: hidden;
background: var(--wp-surface);
cursor: zoom-in;
}
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 300%;
height: 300%;
border: 0;
transform: scale(0.3333);
transform-origin: 0 0;
pointer-events: none;
}
.info {
display: flex;
justify-content: space-between;
padding: 8px 10px 4px;
font-size: 13px;
}
.anon {
color: var(--wp-muted);
}
.mine {
color: var(--wp-brand);
font-size: 12px;
}
.rates {
display: flex;
gap: 6px;
padding: 4px 10px 10px;
}
.rate {
flex: 1;
padding: 4px 0;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: 600 13px var(--wp-font-mono);
cursor: pointer;
}
.rate:hover:not(:disabled) {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.large {
height: 70vh;
border: 1px solid var(--wp-line);
border-radius: 6px;
overflow: hidden;
}
.large iframe {
width: 100%;
height: 100%;
border: 0;
}
</style>
@@ -0,0 +1,531 @@
<template>
<div :class="['panel', { wide }]">
<div class="head">
<div class="who">
<b>{{ sub?.username ?? "…" }}</b>
<span class="muted">{{ sub?.task_title }}</span>
</div>
<n-flex :size="4" :wrap="false" align="center">
<template v-if="nav">
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary :disabled="!nav.prev" @click="emit('prev')">
<Icon icon="lucide:arrow-left" />
</n-button>
</template>
上一个学生(J)
</n-tooltip>
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary :disabled="!nav.next" @click="emit('next')">
<Icon icon="lucide:arrow-right" />
</n-button>
</template>
下一个学生(K)
</n-tooltip>
</template>
<n-button
v-if="canWiden"
size="small"
quaternary
@click="emit('toggle-wide')"
>
<template #icon>
<Icon :icon="wide ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
</template>
{{ wide ? "小面板" : "大图" }}
</n-button>
<n-tooltip>
<template #trigger>
<n-button size="small" quaternary @click="emit('close')">
<Icon icon="lucide:x" />
</n-button>
</template>
{{ wide ? "返回作品墙(Esc)" : "关闭" }}
</n-tooltip>
</n-flex>
</div>
<n-spin :show="loading" class="body">
<div v-if="sub" class="layout">
<div class="stage">
<div class="versions" v-if="versions.length > 1">
<span class="muted">共交了 {{ versions.length }} 次:</span>
<button
v-for="(v, i) in versions"
:key="v.id"
:class="['ver', { on: v.id === currentId }]"
@click="currentId = v.id"
>
第 {{ versions.length - i }} 次
</button>
</div>
<div class="frame">
<!-- key:换作品时换新的 iframe,不进浏览器历史 -->
<iframe :key="currentId" :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
</div>
<n-flex justify="space-between" align="center" class="meta">
<span class="muted">
{{ parseTime(sub.created, "MM-DD HH:mm") }} 提交 ·
{{ sub.score ? `得分 ${sub.score.toFixed(2)}` : "还没有人评分" }}
</span>
<n-button text size="small" @click="openFull">全屏打开</n-button>
</n-flex>
</div>
<div class="side">
<section>
<h4>打分</h4>
<div v-if="sub.my_score" class="muted">你已经给了 {{ sub.my_score }} 分</div>
<div v-else class="rates">
<button
v-for="n in 5"
:key="n"
class="rate"
:disabled="rating"
@click="rate(n)"
>
{{ n }}
</button>
<span class="muted hint">数字键 1–5</span>
</div>
</section>
<section>
<h4>操作</h4>
<n-flex :size="8">
<n-button
size="small"
:type="sub.flag === 'present' ? 'primary' : 'default'"
@click="togglePresent"
>
{{ sub.flag === "present" ? "已加入课上展示" : "加入课上展示" }}
</n-button>
<n-dropdown
:options="awardOptions"
trigger="click"
:disabled="!awardOptions.length"
@select="addToAward"
>
<n-button size="small">加入作品展</n-button>
</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>
<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" :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">{{ levelLabel(r.prompt_level) }}</span>
</n-flex>
<div class="q">{{ r.question }}</div>
</li>
</ol>
</section>
</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>
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { useMessage } from "naive-ui"
import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import { levelLabel, parseTime, sourceLabel } from "../../utils/helper"
import ChainModal from "../submissions/ChainModal.vue"
import type {
AwardManageOut,
FlagType,
PromptRound,
SubmissionAll,
SubmissionOut,
} from "../../utils/type"
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
const props = defineProps<{
submissionId: string
// 大图:预览占满左边,打分和提示词放右边一栏
wide?: boolean
// 是否显示“大图 / 小面板”切换
canWiden?: boolean
// 作品墙里用:能不能切到上一个 / 下一个学生
nav?: { prev: boolean; next: boolean }
// 全部提交页(超管):能删除这一版
canDelete?: boolean
}>()
const emit = defineEmits<{
prev: []
next: []
close: []
"toggle-wide": []
// 让作品墙同步更新这张卡片
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
deleted: [id: string]
}>()
const message = useMessage()
const loading = ref(false)
const currentId = ref(props.submissionId)
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[]>([])
const awardOptions = computed(() =>
awards.value.filter((a) => a.is_active).map((a) => ({ label: a.name, key: a.id })),
)
const doc = computed(() =>
sub.value
? buildPreviewDocument({
html: sub.value.html,
css: sub.value.css,
js: sub.value.js,
assetBaseUrl: `/media/tasks/${sub.value.task_type}/${sub.value.task_display}/`,
})
: "",
)
let seq = 0
async function load(id: string) {
const s = ++seq
loading.value = true
try {
const data: SubmissionAll = await Submission.get(id)
if (s !== seq) return
sub.value = data
const [list, rounds] = await Promise.all([
Submission.listByUserTask(data.userid, data.task_id),
Submission.getPromptChain(id).catch(() => [] as PromptRound[]),
])
if (s !== seq) return
versions.value = list
chain.value = rounds
} finally {
if (s === seq) loading.value = false
}
}
watch(
() => props.submissionId,
(id) => {
currentId.value = id
},
)
watch(currentId, load, { immediate: true })
async function rate(score: number) {
if (!sub.value || sub.value.my_score || rating.value) return
rating.value = true
try {
const res = await Submission.updateScore(sub.value.id, score)
sub.value.my_score = score
message.success(res.message)
// 重新取一次得分(加权平均由服务端算)
const fresh: SubmissionAll = await Submission.get(sub.value.id)
sub.value.score = fresh.score
emit("changed", { id: sub.value.id, my_score: score, score: fresh.score })
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败")
} finally {
rating.value = false
}
}
async function togglePresent() {
if (!sub.value) return
const flag: FlagType = sub.value.flag === "present" ? null : "present"
await Submission.updateFlag(sub.value.id, flag)
sub.value.flag = flag
emit("changed", { id: sub.value.id, flag })
}
async function addToAward(awardId: number) {
if (!sub.value) return
try {
await Showcase.addAwardItem(awardId, { submission_id: sub.value.id, sort_order: 0 })
const name = awards.value.find((a) => a.id === awardId)?.name
message.success(`已加入作品展「${name}」`)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "加入失败")
}
}
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")
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
if (/^[1-5]$/.test(e.key)) rate(Number(e.key))
else if (e.key === "p" || e.key === "P") togglePresent()
}
onMounted(async () => {
window.addEventListener("keydown", onKey)
awardsCache ??= Showcase.listManageAwards()
awards.value = await awardsCache.catch(() => [])
})
onUnmounted(() => window.removeEventListener("keydown", onKey))
</script>
<style scoped>
.panel {
height: 100%;
display: flex;
flex-direction: column;
background: var(--wp-surface);
}
.head {
height: 44px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 0 14px;
border-bottom: 1px solid var(--wp-line);
}
.who {
min-width: 0;
display: flex;
gap: 8px;
align-items: baseline;
white-space: nowrap;
overflow: hidden;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px 20px;
}
/* 大图:左边预览占满高度,右边一栏单独滚动 */
.wide .body {
overflow: hidden;
padding: 0;
}
.wide .body :deep(.n-spin-content) {
height: 100%;
}
.wide .layout {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
}
.wide .stage {
display: flex;
flex-direction: column;
min-height: 0;
padding: 12px 14px;
}
.wide .stage .frame {
flex: 1;
height: auto;
min-height: 0;
}
.wide .side {
border-left: 1px solid var(--wp-line);
overflow-y: auto;
padding: 0 14px 20px;
}
.muted {
color: var(--wp-muted);
font-size: 12px;
}
.versions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.ver {
padding: 1px 8px;
border: 1px solid var(--wp-line);
border-radius: 4px;
background: var(--wp-surface);
color: var(--wp-ink);
font-size: 12px;
cursor: pointer;
}
.ver.on {
border-color: var(--wp-brand);
color: var(--wp-brand);
}
.frame {
height: 260px;
border: 1px solid var(--wp-line);
border-radius: 6px;
overflow: hidden;
}
.frame iframe {
width: 100%;
height: 100%;
border: 0;
}
.meta {
margin-top: 6px;
}
section {
margin-top: 16px;
}
h4 {
margin: 0 0 8px;
font-size: 13px;
}
.chain-head {
margin-bottom: 8px;
}
.chain-head h4 {
margin: 0;
}
.delete {
margin-top: 8px;
}
.rates {
display: flex;
gap: 6px;
align-items: center;
}
.rate {
width: 40px;
padding: 6px 0;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: 600 14px var(--wp-font-mono);
cursor: pointer;
}
.rate:hover:not(:disabled) {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.hint {
margin-left: 6px;
white-space: nowrap;
}
.wide .rates {
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;
display: flex;
flex-direction: column;
gap: 10px;
}
.src {
font-size: 11px;
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);
}
.q {
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+477 -330
View File
@@ -2,234 +2,99 @@
<n-modal <n-modal
:show="show" :show="show"
preset="card" preset="card"
style="width: 90vw; max-width: 1400px" style="width: 92vw; max-width: 1280px"
@update:show="$emit('update:show', $event)" :bordered="false"
@update:show="emit('update:show', $event)"
> >
<template #header> <template #header>
<n-flex justify="start" align="center"> <div class="head">
<span>提示词</span> <b>提示词</b>
<n-tooltip placement="bottom-start"> <span v-if="steps" class="muted">{{ steps }} 轮</span>
<template #trigger> <span v-if="title" class="muted title">{{ title }}</span>
<Icon icon="lucide:info" :width="20" style="color: #aaa" /> </div>
</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>
</template> </template>
<n-spin :show="loading"> <template #header-extra>
<n-empty <n-popover trigger="click" placement="bottom-end">
v-if="!loading && rounds.length === 0" <template #trigger>
description="暂无对话记录" <n-button size="small" quaternary style="margin-right: 8px">
/> <template #icon><Icon icon="lucide:info" /></template>
<div L1–L6 是什么
v-else </n-button>
style=" </template>
display: grid; <div class="levels">
grid-template-columns: 1fr 1fr; <div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
gap: 16px; <div v-for="(l, n) in PROMPT_LEVELS" :key="n">
height: 75vh; <b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid #e0e0e0;
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 ? '#2080f0' : '#c2d5fb',
color: '#fff',
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 ? '#e8f0fe' : '#f5f5f5',
border:
selectedRound === index
? '1px solid #2080f0'
: '1px solid #e0e0e0',
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' ? '#e8f0fe' : '#f0f0f0',
color:
round.source === 'conversation' ? '#2060c0' : '#888',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
>{{
round.source === "conversation" ? "对话" : "手动"
}}</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>
</div> </div>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 8px"> </n-popover>
<div style="font-weight: bold; font-size: 13px; color: #555"> </template>
第 {{ selectedRound + 1 }} 轮网页
<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> </div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div> </div>
</div> </div>
</n-spin> </n-spin>
@@ -237,159 +102,441 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue" import { computed, nextTick, onUnmounted, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui" import { Icon } from "../../icons"
import { Icon } from "@iconify/vue" import { Showcase, Submission } from "../../api"
import { marked } from "marked" import { renderMarkdown } from "../../utils/markdown"
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 type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
const message = useMessage()
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
submissionId: string submissionId: string
username?: string // 例如“张三 · 个人名片”
title?: string
// 题目素材的地址,网页里的图片要用
assetBaseUrl?: string
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
fromShowcase?: boolean
}>() }>()
const emit = defineEmits<{ "update:show": [value: boolean] }>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false) const loading = ref(false)
const selectedRound = ref(0) const rounds = ref<PromptRound[]>([])
const expandedRounds = ref<Set<number>>(new Set()) const selected = ref(0)
type ChainRound = Omit<PromptRound, "source"> & { const expanded = ref(new Set<number>())
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
async function deleteRound(index: number) { // 引导对话(问怎么写提示词)夹在 AI 对话中间,不算轮数,也不能选中
const round = rounds.value[index] const isGuide = (r: PromptRound) => r.source === "guidance"
if (!round.assistantMsgId) return const steps = computed(() => rounds.value.filter((r) => !isGuide(r)).length)
try { const nums = computed(() => {
await Prompt.deleteMessagePair(round.assistantMsgId) let n = 0
} catch (error: any) { return rounds.value.map((r) => (isGuide(r) ? "" : String(++n)))
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: "#aaa",
2: "#6aab9c",
3: "#4a9ade",
4: "#c47ab8",
5: "#e0a040",
6: "#e05c5c",
}
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">${style}</head><body>${round.html}${script}</body></html>`
}) })
function renderMarkdown(text: string): string { // 层级走势:每轮 AI 对话 / 外部 AI 一根柱子,两轮以上、有标过层级才显示
return marked.parse(text) as string 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 isPromptLong(text: string): boolean { function lineDiff(before: string[], after: string[]) {
return text.length > 220 || text.split(/\r?\n/).length > 4 const left = new Map<string, number>()
} for (const l of before) left.set(l, (left.get(l) ?? 0) + 1)
let added = 0
function isExpanded(index: number): boolean { for (const l of after) {
return expandedRounds.value.has(index) const n = left.get(l) ?? 0
} if (n) left.set(l, n - 1)
else added++
function toggleExpanded(index: number) {
const next = new Set(expandedRounds.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
} }
expandedRounds.value = next const removed = before.length - (after.length - added)
return added || removed ? `+${added} −${removed} 行` : "代码没变"
} }
async function loadMessages() { const changes = computed(() => {
if (!props.submissionId) return 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 loading.value = true
rounds.value = [] rounds.value = []
selectedRound.value = 0 expanded.value = new Set()
expandedRounds.value = new Set()
try { try {
const data = await Submission.getPromptChain(props.submissionId) const data = props.fromShowcase
rounds.value = data.map((round) => ({ ? await Showcase.getPromptChain(props.submissionId)
...round, : await Submission.getPromptChain(props.submissionId)
source: round.source ?? null, if (s !== seq) return
assistantMsgId: round.assistant_msg_id ?? null, rounds.value = data
})) // 默认看交的那一版
const last = rounds.value.length - 1 selected.value = Math.max(0, data.length - 1)
if (last >= 0) selectedRound.value = last await nextTick()
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
} catch { } catch {
rounds.value = [] if (s === seq) rounds.value = []
} finally { } 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( watch(
() => [props.show, props.submissionId] as const, () => [props.show, props.submissionId] as const,
([visible]) => { ([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> </script>
<style scoped> <style scoped>
.prompt-markdown { .head {
flex: 1; display: flex;
align-items: baseline;
gap: 10px;
min-width: 0; min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
} }
.prompt-markdown.is-collapsed { .title {
position: relative; white-space: nowrap;
max-height: 126px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.prompt-markdown :deep(p), .muted {
.prompt-markdown :deep(ul), font-size: 12px;
.prompt-markdown :deep(ol), font-weight: 400;
.prompt-markdown :deep(blockquote), color: var(--wp-muted);
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
} }
.prompt-markdown :deep(:last-child) { .levels {
margin-bottom: 0; font-size: 13px;
line-height: 1.9;
} }
.prompt-markdown :deep(pre) { .levels-note {
padding: 8px; color: var(--wp-muted);
overflow-x: auto; }
.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; font-size: 12px;
} }
.prompt-markdown :deep(code) { .num {
white-space: pre-wrap; 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; 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> </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: "#999" } }, 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,74 +0,0 @@
<template>
<n-popover v-if="isAdmin" trigger="click">
<template #trigger>
<span :style="dotStyle" />
</template>
<n-space vertical size="small">
<n-button
v-for="opt in FLAG_OPTIONS"
:key="opt.value"
text
@click="$emit('update:flag', opt.value)"
>
<span style="display: flex; align-items: center; gap: 6px">
<span
:style="{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: opt.color,
}"
/>
{{ opt.label }}
</span>
</n-button>
<n-button
v-if="flag"
text
block
type="error"
@click="$emit('update:flag', null)"
>
清除
</n-button>
</n-space>
</n-popover>
<span v-else :style="dotStyle" />
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { FlagType } from "../../utils/type"
const FLAG_OPTIONS: {
value: NonNullable<FlagType>
color: string
label: string
}[] = [
{ value: "red", color: "#e03030", label: "值得展示" },
{ value: "blue", color: "#2080f0", label: "需要讲解" },
{ value: "green", color: "#18a058", label: "优秀作品" },
{ value: "yellow", color: "#f0a020", label: "需要改进" },
]
const props = defineProps<{
flag: FlagType
isAdmin: boolean
}>()
defineEmits<{ "update:flag": [value: FlagType] }>()
const dotStyle = computed(() => {
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
return {
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: match ? match.color : "transparent",
border: match ? "none" : "1px dashed #ccc",
cursor: props.isAdmin ? "pointer" : "default",
}
})
</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>
@@ -1,83 +0,0 @@
<template>
<div class="container" v-if="taskTab === TASK_TYPE.Challenge">
<n-empty v-if="!challenges.length">暂无挑战,敬请期待</n-empty>
<n-flex v-else vertical :size="12">
<n-card
v-for="item in challenges"
:key="item.display"
hoverable
:class="['challenge-card', { submitted: item.submitted }]"
@click="select(item)"
>
<template #header>
<n-flex align="center" :size="6">
<span v-if="item.submitted" class="check-icon">✓</span>
<span :class="{ 'submitted-title': item.submitted }">{{
item.title
}}</span>
</n-flex>
</template>
<template #header-extra>
<n-flex :size="6">
<n-tag type="warning" size="small">{{ item.score }} 分</n-tag>
<n-tag v-if="item.pass_score != null" size="small"
>及格 {{ item.pass_score }} 分</n-tag
>
</n-flex>
</template>
<n-text depth="3" class="challenge-author">
出题人:{{ item.author_name || "未设置" }}
</n-text>
</n-card>
</n-flex>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useRouter } from "vue-router"
import { Challenge } from "../../api"
import { taskTab } from "../../store/task"
import { TASK_TYPE } from "../../utils/const"
import type { ChallengeSlim } from "../../utils/type"
const router = useRouter()
const challenges = ref<ChallengeSlim[]>([])
function select(item: ChallengeSlim) {
router.push({ name: "home-challenge", params: { display: item.display } })
}
onMounted(async () => {
challenges.value = await Challenge.listDisplay()
})
</script>
<style scoped>
.container {
padding: 16px;
overflow: auto;
}
.challenge-card {
cursor: pointer;
}
.challenge-card.submitted {
background-color: #f6ffed;
border-color: #b7eb8f;
}
.check-icon {
color: #52c41a;
font-size: 14px;
font-weight: bold;
}
.submitted-title {
color: #888;
}
.challenge-author {
display: block;
font-size: 13px;
}
</style>
+174 -98
View File
@@ -1,78 +1,57 @@
<template> <template>
<n-flex vertical> <n-button @click="show = true">
<n-button size="small" @click="showUpload = true">图片素材</n-button> 图片素材{{ assets.length ? `(${assets.length})` : "" }}
<n-flex v-if="assets.length" wrap> </n-button>
<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-modal <n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
v-model:show="showUpload" <p class="hint">
preset="card" 题目和学生的网页里直接写文件名就能用,例如
title="上传素材" <code>&lt;img src="{{ assets[0]?.name ?? "1.png" }}"&gt;</code>。
style="width: 400px" 上传同名文件会替换原来的。
</p>
<label
:class="['drop', { over: dragging }]"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
> >
<n-form> <input type="file" accept="image/*" multiple hidden @change="onPick" />
<n-form-item label="文件名(如 1.png)"> <Icon icon="lucide:image" :width="22" />
<n-input v-model:value="uploadName" placeholder="1.png" /> <span v-if="uploading">正在上传…</span>
</n-form-item> <span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
<n-form-item label="选择文件"> </label>
<n-upload :max="1" @change="onFileChange" :default-upload="false">
<n-button>选择文件</n-button> <div v-if="assets.length" class="grid">
</n-upload> <div v-for="a in assets" :key="a.name" class="asset">
</n-form-item> <div class="img">
</n-form> <img :src="a.url" :alt="a.name" loading="lazy" />
<template #footer> </div>
<n-flex justify="end"> <div class="name" :title="a.name">{{ a.name }}</div>
<n-button @click="showUpload = false">取消</n-button> <div class="tools">
<n-button <n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
type="primary" <n-popconfirm @positive-click="remove(a.name)">
:disabled="!uploadName || !uploadFile" <template #trigger>
:loading="uploading" <n-button size="tiny" quaternary type="error">删除</n-button>
@click="upload" </template>
> 删除 {{ a.name }}?用到它的网页里图片会显示不出来。
上传 </n-popconfirm>
</n-button> </div>
</n-flex> </div>
</template> </div>
</n-modal> <n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
</n-flex> </n-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from "vue" import { ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { useMessage } from "naive-ui" 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 { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type" import type { TaskAsset } from "../../utils/type"
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
const props = defineProps<{ const props = defineProps<{
taskType: "challenge" | "tutorial" taskType: "challenge" | "tutorial"
display: number display: number
@@ -80,10 +59,9 @@ const props = defineProps<{
const message = useMessage() const message = useMessage()
const assets = ref<TaskAsset[]>([]) const assets = ref<TaskAsset[]>([])
const showUpload = ref(false) const show = ref(false)
const uploadName = ref("")
const uploadFile = ref<File | null>(null)
const uploading = ref(false) const uploading = ref(false)
const dragging = ref(false)
async function load() { async function load() {
if (!props.display) return if (!props.display) return
@@ -97,40 +75,56 @@ async function load() {
} }
} }
function onFileChange({ file }: { file: UploadFileInfo }) { // 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
if (file.status === "pending") { function cleanName(name: string) {
uploadFile.value = file.file ?? null return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
}
} }
async function upload() { async function uploadAll(files: File[]) {
if (!uploadName.value || !uploadFile.value) return const images = files.filter((f) => f.type.startsWith("image/"))
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
if (!images.length) return
uploading.value = true uploading.value = true
try { let done = 0
const asset = for (const file of images) {
props.taskType === "challenge" const name = cleanName(file.name)
? await TaskAssets.uploadChallenge( if (!name || name.startsWith(".")) {
props.display, message.error(`文件名“${file.name}”不能用,请改个名字再传`)
uploadName.value, continue
uploadFile.value, }
) try {
: await TaskAssets.uploadTutorial( const asset =
props.display, props.taskType === "challenge"
uploadName.value, ? await TaskAssets.uploadChallenge(props.display, name, file)
uploadFile.value, : await TaskAssets.uploadTutorial(props.display, name, file)
) const i = assets.value.findIndex((a) => a.name === asset.name)
const idx = assets.value.findIndex((a) => a.name === asset.name) // 同名替换后浏览器可能还用着旧图的缓存
if (idx >= 0) assets.value[idx] = asset const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
else assets.value.push(asset) if (i >= 0) assets.value[i] = fresh
message.success(`${asset.name} 上传成功`) else assets.value.push(fresh)
showUpload.value = false done++
uploadName.value = "" } catch (err: any) {
uploadFile.value = null message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
} catch (err: any) { }
message.error(err?.response?.data?.detail ?? "上传失败")
} finally {
uploading.value = false
} }
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) { async function remove(name: string) {
@@ -139,7 +133,7 @@ async function remove(name: string) {
? await TaskAssets.deleteChallenge(props.display, name) ? await TaskAssets.deleteChallenge(props.display, name)
: await TaskAssets.deleteTutorial(props.display, name) : await TaskAssets.deleteTutorial(props.display, name)
assets.value = assets.value.filter((a) => a.name !== name) assets.value = assets.value.filter((a) => a.name !== name)
message.success("删除成功") message.success("已删除")
} catch (err: any) { } catch (err: any) {
message.error(err?.response?.data?.detail ?? "删除失败") message.error(err?.response?.data?.detail ?? "删除失败")
} }
@@ -147,3 +141,85 @@ async function remove(name: string) {
watch(() => props.display, load, { immediate: true }) watch(() => props.display, load, { immediate: true })
</script> </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>
+69 -106
View File
@@ -1,99 +1,69 @@
<template> <template>
<div class="container"> <div class="container">
<n-flex align="center" justify="space-between" class="title"> <n-flex align="center" justify="space-between" class="title" :wrap="false">
<n-flex align="center"> <n-flex align="center" :wrap="false">
<Icon <span class="panel-name">教程</span>
:icon="
taskTab === TASK_TYPE.Tutorial
? 'twemoji:books'
: 'twemoji:crossed-swords'
"
:width="20"
></Icon>
<n-tabs
style="width: 150px"
type="segment"
animated
:value="taskTab"
@update:value="changeTab"
>
<n-tab name="tutorial" tab="教程"></n-tab>
<n-tab name="challenge" tab="挑战"></n-tab>
</n-tabs>
<template v-if="!hideNav"> <template v-if="!hideNav">
<n-button text @click="prev()" :disabled="prevDisabled()"> <n-tooltip>
<Icon :width="24" icon="pepicons-pencil:arrow-left"></Icon> <template #trigger>
</n-button> <n-button text @click="prev()" :disabled="prevDisabled()">
<Icon :width="18" icon="lucide:arrow-left" />
</n-button>
</template>
上一节
</n-tooltip>
<span v-if="progressText" class="progress-text"> <span v-if="progressText" class="progress-text">
{{ progressText }} {{ progressText }}
</span> </span>
<n-button text @click="next()" :disabled="nextDisabled()"> <n-tooltip>
<Icon :width="24" icon="pepicons-pencil:arrow-right"></Icon> <template #trigger>
</n-button> <n-button text @click="next()" :disabled="nextDisabled()">
<Icon :width="18" icon="lucide:arrow-right" />
</n-button>
</template>
下一节
</n-tooltip>
</template> </template>
</n-flex> </n-flex>
<n-flex> <n-flex align="center" :wrap="false">
<n-tooltip <n-button
v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial" v-if="tutorialAssets.length && taskTab === TASK_TYPE.Tutorial"
trigger="hover" size="small"
quaternary
@click="showAssets = true"
> >
<template #trigger> <template #icon><Icon icon="lucide:image" /></template>
<n-button text @click="showAssets = true">
<Icon :width="16" icon="lucide:image"></Icon>
</n-button>
</template>
素材 素材
</n-tooltip> </n-button>
<n-tooltip v-if="authed" trigger="hover"> <n-button
<template #trigger> v-if="authed && taskTab === TASK_TYPE.Tutorial && taskId"
<n-button size="small"
text quaternary
@click=" @click="showPeers = true"
$router.push({ name: 'submissions', params: { page: 1 } }) >
" 作品
> </n-button>
<Icon :width="16" icon="lucide:list"></Icon> <n-button v-if="roleSuper" size="small" quaternary @click="edit">
</n-button> <template #icon><Icon icon="lucide:edit" /></template>
</template>
提交记录
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'creation-list' })">
<Icon :width="16" icon="lucide:sparkles"></Icon>
</n-button>
</template>
自由创作
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'showcase' })">
<Icon :width="16" icon="lucide:award"></Icon>
</n-button>
</template>
创意工坊
</n-tooltip>
<n-tooltip v-if="roleSuper" trigger="hover">
<template #trigger>
<n-button text @click="edit">
<Icon :width="16" icon="lucide:edit"></Icon>
</n-button>
</template>
编辑 编辑
</n-tooltip> </n-button>
<n-tooltip trigger="hover"> <n-tooltip>
<template #trigger> <template #trigger>
<n-button text @click="$emit('hide')"> <n-button text @click="$emit('hide')">
<Icon :width="24" icon="material-symbols:close-rounded"></Icon> <Icon :width="18" icon="lucide:x" />
</n-button> </n-button>
</template> </template>
关闭 收起教程,编辑器占满屏幕
</n-tooltip> </n-tooltip>
</n-flex> </n-flex>
</n-flex> </n-flex>
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" /> <TutorialContent />
<ChallengeList v-else />
</div> </div>
<PeersDrawer
v-model:show="showPeers"
:task-id="taskId"
:asset-base-url="`/media/tasks/tutorial/${step}/`"
/>
<n-modal <n-modal
v-model:show="showAssets" v-model:show="showAssets"
preset="card" preset="card"
@@ -119,7 +89,7 @@
</n-modal> </n-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "../../icons"
import { computed, ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { import {
step, step,
@@ -130,16 +100,17 @@ import {
nextDisabled, nextDisabled,
} from "../../store/tutorial" } from "../../store/tutorial"
import { authed, roleSuper } from "../../store/user" import { authed, roleSuper } from "../../store/user"
import PeersDrawer from "../review/PeersDrawer.vue"
import { taskTab, taskId, challengeDisplay } from "../../store/task" import { taskTab, taskId, challengeDisplay } from "../../store/task"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../../utils/const" import { TASK_TYPE } from "../../utils/const"
import { TaskAssets } from "../../api" import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type" import type { TaskAsset } from "../../utils/type"
import ChallengeList from "./ChallengeList.vue"
import TutorialContent from "./TutorialContent.vue" import TutorialContent from "./TutorialContent.vue"
const tutorialAssets = ref<TaskAsset[]>([]) const tutorialAssets = ref<TaskAsset[]>([])
const showAssets = ref(false) const showAssets = ref(false)
const showPeers = ref(false)
async function loadTutorialAssets(display: number) { async function loadTutorialAssets(display: number) {
if (!display) { if (!display) {
@@ -159,24 +130,23 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
// 路由同步:初始执行 + watch 响应 SPA 内部导航 // 路由同步:初始执行 + watch 响应 SPA 内部导航
// 这个面板只用在教程页(挑战有自己的页面)
function syncRoute(routeName: string) { function syncRoute(routeName: string) {
if (routeName.startsWith("home-tutorial")) { taskTab.value = TASK_TYPE.Tutorial
taskTab.value = TASK_TYPE.Tutorial if (routeName.startsWith("home-tutorial") && route.params.display) {
if (route.params.display) step.value = Number(route.params.display) step.value = Number(route.params.display)
} else if (routeName.startsWith("home-challenge")) {
taskTab.value = TASK_TYPE.Challenge
if (route.params.display)
challengeDisplay.value = Number(route.params.display)
} }
} }
syncRoute(route.name as string) syncRoute(route.name as string)
watch(() => route.name as string, syncRoute) // 只换了 display(比如浏览器后退到上一节教程)也要同步
watch(
() => [route.name as string, route.params.display] as const,
([name]) => syncRoute(name),
)
defineEmits(["hide"]) defineEmits(["hide"])
const hideNav = computed( const hideNav = computed(() => tutorialIds.value.length <= 1)
() => taskTab.value !== TASK_TYPE.Tutorial || tutorialIds.value.length <= 1,
)
const progressText = computed(() => { const progressText = computed(() => {
const ids = tutorialIds.value const ids = tutorialIds.value
@@ -185,20 +155,6 @@ const progressText = computed(() => {
return i === -1 ? "" : `${i + 1} / ${ids.length}` return i === -1 ? "" : `${i + 1} / ${ids.length}`
}) })
function changeTab(v: TASK_TYPE) {
taskId.value = 0
taskTab.value = v
if (v === TASK_TYPE.Tutorial) {
router.push(
step.value
? { name: "home-tutorial", params: { display: step.value } }
: { name: "home-tutorial-list" },
)
} else if (v === TASK_TYPE.Challenge) {
router.push({ name: "home-challenge-list" })
}
}
function edit() { function edit() {
const name = const name =
taskTab.value === TASK_TYPE.Tutorial taskTab.value === TASK_TYPE.Tutorial
@@ -218,15 +174,22 @@ function edit() {
.title { .title {
height: 43px; height: 43px;
padding: 0 20px; padding: 0 12px 0 16px;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid rgb(239, 239, 245); border-bottom: 1px solid var(--wp-line);
background: var(--wp-surface);
box-sizing: border-box; box-sizing: border-box;
} }
.panel-name {
font-weight: 600;
color: var(--wp-ink);
}
.progress-text { .progress-text {
font-size: 12px; font-size: 12px;
color: #999; color: var(--wp-faint);
font-family: var(--wp-font-mono);
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
user-select: none; user-select: none;
File diff suppressed because it is too large. Load diff
+25 -13
View File
@@ -3,15 +3,18 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, useTemplateRef, watch } from "vue" import { onMounted, ref, useTemplateRef, watch } from "vue"
import { marked } from "marked" import { Marked } from "marked"
import { trustedExtensions } from "../../utils/markdown"
import copyFn from "copy-text-to-clipboard" import copyFn from "copy-text-to-clipboard"
import { css, html, js, tab } from "../../store/editors" import { css, html, js, tab } from "../../store/editors"
import { Tutorial } from "../../api" import { Tutorial } from "../../api"
import { readTaskCode } from "../../composables/useTaskCode"
import { step, tutorialIds, loadTutorials } from "../../store/tutorial" import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
import { taskId, assetBaseUrl } from "../../store/task" import { taskId, assetBaseUrl } from "../../store/task"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
marked.use({ // 独立实例:带“复制/替换”按钮的代码块只用在教程里,不能改全局的 marked
const tutorialMarked = new Marked(...trustedExtensions, {
renderer: { renderer: {
code({ text, lang }) { code({ text, lang }) {
const language = lang?.toLowerCase() ?? "html" const language = lang?.toLowerCase() ?? "html"
@@ -36,15 +39,24 @@ const router = useRouter()
const content = ref("") const content = ref("")
const $content = useTemplateRef<any>("$content") const $content = useTemplateRef<any>("$content")
// 连点“下一节”时只用最后一次请求的结果
let renderSeq = 0
async function render() { async function render() {
const seq = ++renderSeq
const data = await Tutorial.get(step.value) const data = await Tutorial.get(step.value)
if (seq !== renderSeq) return
// 这一节写过代码就接着写,没写过才放示例代码(以前每次切换都会被示例覆盖)
const saved = readTaskCode(data.task_ptr)
taskId.value = data.task_ptr taskId.value = data.task_ptr
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/` assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
html.value = data.example_html ?? "" html.value = saved?.html ?? data.example_html ?? ""
css.value = data.example_css ?? "" css.value = saved?.css ?? data.example_css ?? ""
js.value = data.example_js ?? "" js.value = saved?.js ?? data.example_js ?? ""
const merged = `# ${data.display}. ${data.title}\n${data.content}` const merged = `# ${data.display}. ${data.title}\n${data.content}`
content.value = await marked.parse(merged, { async: true }) const rendered = await tutorialMarked.parse(merged, { async: true })
if (seq !== renderSeq) return
content.value = rendered
} }
function flash(btn: HTMLButtonElement, done: string, original: string) { function flash(btn: HTMLButtonElement, done: string, original: string) {
@@ -110,12 +122,12 @@ watch(step, (v) => {
.markdown-body pre code { .markdown-body pre code {
padding: 0; padding: 0;
font-size: 1rem; font-size: 1rem;
font-family: Monaco; font-family: var(--wp-font-mono);
} }
.markdown-body .codeblock-wrapper { .markdown-body .codeblock-wrapper {
padding: 1rem; padding: 1rem;
background-color: #f6f8fa; background-color: var(--wp-paper);
border-radius: 6px; border-radius: 6px;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow: auto; overflow: auto;
@@ -161,15 +173,15 @@ watch(step, (v) => {
padding: 0 14px; padding: 0 14px;
font-size: 14px; font-size: 14px;
border-radius: 3px; border-radius: 3px;
border: 1px solid #d9d9d9; border: 1px solid var(--wp-line);
background-color: #fff; background-color: var(--wp-surface);
color: #333; color: var(--wp-ink);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.codeblock-action .action-btn:hover { .codeblock-action .action-btn:hover {
border-color: #18a058; border-color: var(--wp-brand);
color: #18a058; color: var(--wp-brand);
} }
</style> </style>
@@ -0,0 +1,83 @@
<template>
<n-tabs
v-model:value="tab"
type="card"
size="small"
class="code-tabs"
:class="`tab-active-${tab}`"
pane-class="code-pane"
>
<template #prefix><slot name="prefix" /></template>
<n-tab-pane name="html">
<template #tab><span class="lang-tab lang-html">HTML</span></template>
<Editor v-model:value="code.html" :font-size="size" language="html" />
</n-tab-pane>
<n-tab-pane name="css">
<template #tab><span class="lang-tab lang-css">CSS</span></template>
<Editor v-model:value="code.css" :font-size="size" language="css" />
</n-tab-pane>
<n-tab-pane name="js">
<template #tab><span class="lang-tab lang-js">JS</span></template>
<Editor v-model:value="code.js" :font-size="size" language="js" />
</n-tab-pane>
<template #suffix>
<n-flex align="center" :wrap="false" :size="4" class="suffix">
<n-dropdown :options="sizeOptions" @select="(v: number) => (size = v)">
<n-button quaternary size="small">字号 {{ size }}</n-button>
</n-dropdown>
<slot name="suffix" />
</n-flex>
</template>
</n-tabs>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import Editor from "../editor/Editor.vue"
import { size } from "../../store/editors"
import type { Code } from "../../utils/code"
// 直接改传进来的响应式对象,工作台按任务保存它
defineProps<{ code: Code }>()
const tab = ref<"html" | "css" | "js">("html")
const sizeOptions = [12, 14, 16, 18, 20, 24, 28, 32].map((n) => ({
label: `${n}`,
key: n,
}))
</script>
<style scoped>
.code-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.code-tabs :deep(.n-tabs-pane-wrapper) {
flex: 1;
min-height: 0;
}
.code-tabs :deep(.code-pane) {
height: 100%;
padding: 0;
overflow: auto;
}
.code-tabs :deep(.n-tabs-nav) {
background: var(--wp-surface);
}
.code-tabs :deep(.n-tabs-tab[data-name="html"]) {
background-color: var(--wp-html-soft) !important;
}
.code-tabs :deep(.n-tabs-tab[data-name="css"]) {
background-color: var(--wp-css-soft) !important;
}
.code-tabs :deep(.n-tabs-tab[data-name="js"]) {
background-color: var(--wp-js-soft) !important;
}
.suffix {
padding-right: 8px;
}
</style>
@@ -0,0 +1,89 @@
<template>
<n-modal
v-model:show="show"
preset="card"
title="粘贴外部 AI 生成的代码"
style="width: 640px; max-width: 94vw"
>
<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 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
/>
</n-form-item>
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
<n-input
v-model:value="raw"
type="textarea"
:autosize="{ minRows: 8, maxRows: 16 }"
placeholder="粘贴整页 HTML,里面的 <style> 和 <script> 会自动拆到 CSS 和 JS"
class="code-input"
/>
</n-form-item>
<n-text depth="3" class="hint">
保存后会成为一个新版本,提示词会记进你的提示词记录里。
</n-text>
<template #footer>
<n-flex justify="end">
<n-button @click="show = false">取消</n-button>
<n-button
type="primary"
:disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
:loading="saving"
@click="save"
>
存为新版本
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue"
import { splitHtml, type Code } from "../../utils/code"
const show = defineModel<boolean>("show", { required: true })
const props = defineProps<{
onSave: (prompt: string, code: Code) => Promise<void>
}>()
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)
async function save() {
saving.value = true
try {
await props.onSave(prompt.value.trim(), splitHtml(raw.value))
prompt.value = ""
raw.value = ""
show.value = false
} finally {
saving.value = false
}
}
</script>
<style scoped>
.code-input :deep(textarea) {
font-family: var(--wp-font-mono);
font-size: 13px;
}
.hint {
display: block;
margin-top: 8px;
font-size: 12px;
}
</style>
@@ -0,0 +1,200 @@
<template>
<n-modal
:show="!!(current || revealed)"
preset="card"
:title="done ? 'AI 写好了' : '等 AI 写代码的时候,给同学的作品打个分'"
:closable="false"
:mask-closable="false"
:close-on-esc="false"
:auto-focus="false"
style="width: 90vw; max-width: 1000px"
>
<template #header-extra>
<n-button v-if="done" type="primary" size="small" @click="close"
>看结果</n-button
>
<n-button v-else-if="current" text size="small" @click="next(true)"
>跳过这份</n-button
>
</template>
<div v-if="revealed" class="revealed">
刚才那份是 <b>{{ revealed }}</b> 的作品,谢谢你的评分
</div>
<template v-else-if="current">
<div class="task">{{ current.task_title }}</div>
<div class="large">
<iframe
:key="current.submission_id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
></iframe>
</div>
<div class="rates">
<button
v-for="s in scores"
:key="s.value"
class="rate"
:disabled="rating"
@click="rate(s.value)"
>
<b>{{ s.value }}</b> {{ s.label }}
</button>
</div>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue"
import { useMessage } from "naive-ui"
import { Submission } from "../../api"
import {
buildPreviewDocument,
PREVIEW_SANDBOX,
} from "../../utils/previewDocument"
import type { RandomRatingItem } from "../../utils/type"
// AI 生成期间弹框遮住页面,让学生给一份同学的作品打分(学生只关心结果,不用盯着代码一行行出来)。
// 打分前不显示作者,减少给熟人打高分。AI 写完时正在看的这份不收走,换成“看结果”按钮
const props = defineProps<{ active: boolean }>()
const message = useMessage()
const scores = [
{ value: 1, label: "没完成题目要求" },
{ value: 2, label: "完成了一小部分" },
{ value: 3, label: "基本完成" },
{ value: 4, label: "完成得不错" },
{ value: 5, label: "完成且有自己的想法" },
]
const current = ref<RandomRatingItem | null>(null)
const revealed = ref("")
const rating = ref(false)
const done = ref(false)
let seq = 0
const doc = computed(() =>
current.value
? buildPreviewDocument({
html: current.value.html ?? "",
css: current.value.css ?? "",
js: current.value.js ?? "",
})
: "",
)
async function next(skip = false) {
const s = ++seq
const exclude = skip ? current.value?.submission_id : undefined
try {
const item = await Submission.randomForRating(exclude)
// 请求期间 AI 已经回复完了,就不要再显示
if (s !== seq || !props.active) return
current.value = item
} catch {
if (s === seq) current.value = null
}
}
function close() {
seq++
current.value = null
revealed.value = ""
done.value = false
}
async function rate(score: number) {
if (!current.value) return
rating.value = true
try {
const res = await Submission.updateScore(current.value.submission_id, score)
revealed.value = res.username ?? "同学"
current.value = null
const s = seq
setTimeout(() => {
if (s !== seq) return
revealed.value = ""
if (props.active) next()
else done.value = false
}, 1800)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败,请重试")
} finally {
rating.value = false
}
}
watch(
() => props.active,
(active) => {
if (active) {
done.value = false
next()
} else if (current.value) {
seq++
done.value = true
} else {
close()
}
},
{ immediate: true },
)
</script>
<style scoped>
.task {
font-weight: 600;
margin-bottom: 8px;
}
.large {
height: 60vh;
border: 1px solid var(--wp-line);
border-radius: 6px;
overflow: hidden;
}
.large iframe {
width: 100%;
height: 100%;
border: 0;
}
.rates {
display: flex;
gap: 8px;
margin-top: 12px;
}
.rate {
flex: 1;
padding: 8px;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: 13px var(--wp-font-sans);
cursor: pointer;
}
.rate b {
font-family: var(--wp-font-mono);
margin-right: 4px;
}
.rate:hover:not(:disabled) {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.rate:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 1px;
}
.revealed {
padding: 48px 0;
text-align: center;
font-size: 15px;
color: var(--wp-muted);
}
</style>
@@ -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>
@@ -0,0 +1,166 @@
<template>
<div class="version-bar">
<span class="label">版本</span>
<div class="chips">
<span v-if="!versions.length" class="empty">
还没有版本:让 AI 生成,或者在“代码”里自己写
</span>
<n-tooltip v-for="v in versions" :key="v.id">
<template #trigger>
<button
:class="['chip', { on: v.id === selectedId && !modified }]"
@click="emit('select', v.id)"
>
v{{ v.n }}
<span v-if="v.source === 'edit'" class="src">手改</span>
<span v-else-if="v.source === 'manual'" class="src">外部</span>
<Icon v-if="v.submitted" icon="lucide:check" :width="12" class="ok" />
</button>
</template>
{{ describe(v) }}
</n-tooltip>
<span v-if="modified" class="chip on draft">当前修改</span>
</div>
<n-button quaternary size="small" @click="emit('history')">全部历史</n-button>
<template v-if="kind === 'challenge'">
<span v-if="submittedLabel" class="hint">{{ submittedLabel }}</span>
<n-button
type="primary"
size="small"
:disabled="!canSubmit || selectedSubmitted"
:loading="busy"
@click="emit('submit')"
>
{{ modified ? "交当前修改" : selectedSubmitted ? "已交这一版" : "交这一版" }}
</n-button>
</template>
<n-button
v-else
type="primary"
size="small"
:disabled="!canSubmit"
:loading="busy"
@click="emit('submit')"
>
保存作品
</n-button>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import { Icon } from "../../icons"
export interface VersionItem {
id: number
n: number
source: string
submitted: boolean
}
const props = defineProps<{
kind: "challenge" | "creation"
versions: VersionItem[]
selectedId: number | null
modified: boolean
canSubmit: boolean
busy: boolean
}>()
const emit = defineEmits<{ select: [id: number]; submit: []; history: [] }>()
const selectedSubmitted = computed(
() =>
!props.modified &&
!!props.versions.find((v) => v.id === props.selectedId)?.submitted,
)
const submittedLabel = computed(() => {
const done = props.versions.filter((v) => v.submitted).map((v) => `v${v.n}`)
if (done.length > 3) return `已交 ${done.length} 个版本`
return done.length ? `已交 ${done.join("、")}` : ""
})
function describe(v: VersionItem) {
const from =
v.source === "edit" ? "你手改的" : v.source === "manual" ? "外部 AI 生成的" : "AI 生成的"
return `${from}第 ${v.n} 版${v.submitted ? ",已提交" : ""},点击查看`
}
</script>
<style scoped>
.version-bar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--wp-line);
background: var(--wp-surface);
min-width: 0;
}
.label {
font-size: 12px;
color: var(--wp-muted);
flex: none;
}
.chips {
flex: 1;
min-width: 0;
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: thin;
}
.chip {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border: 1px solid var(--wp-line);
border-radius: 6px;
background: var(--wp-surface);
color: var(--wp-ink);
font: 12px/20px var(--wp-font-mono);
cursor: pointer;
}
.chip:hover {
border-color: var(--wp-brand);
}
.chip.on {
border-color: var(--wp-brand);
color: var(--wp-brand);
font-weight: 600;
}
.chip.draft {
cursor: default;
border-style: dashed;
font-family: var(--wp-font-sans);
}
.chip:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 1px;
}
.src {
font-family: var(--wp-font-sans);
font-size: 11px;
color: var(--wp-muted);
}
.ok {
color: var(--wp-brand);
}
.empty,
.hint {
font-size: 12px;
color: var(--wp-faint);
white-space: nowrap;
}
</style>
@@ -0,0 +1,507 @@
<template>
<n-split
class="workbench"
:default-size="0.38"
min="360px"
:max="0.62"
>
<template #1>
<div class="left">
<div class="left-head">
<n-tooltip>
<template #trigger>
<n-button text @click="$router.push(backTo)">
<Icon icon="lucide:arrow-left" :width="18" />
</n-button>
</template>
{{ backLabel }}
</n-tooltip>
<span class="title">{{ title }}</span>
<slot name="badges" />
<span class="spacer"></span>
<slot name="tools" />
</div>
<div v-if="$slots.brief" class="brief">
<button class="brief-toggle" @click="briefOpen = !briefOpen">
<b>{{ briefLabel }}</b>
<span>{{ briefOpen ? "收起" : "展开" }}</span>
</button>
<div v-show="briefOpen" class="brief-body">
<slot name="brief" />
</div>
</div>
<PromptPanel
class="chat"
:before-send="beforeSend"
:rating="kind === 'challenge'"
@deleted="historyKey++"
/>
</div>
</template>
<template #2>
<div class="right">
<div class="right-body">
<div v-if="mode === 'preview'" class="preview-only">
<Preview
:html="code.html"
:css="code.css"
:js="code.js"
:asset-base-url="assetBaseUrl"
:storage-task-id="taskId"
>
<template #title>
<n-radio-group v-model:value="mode" size="small">
<n-radio-button value="preview">预览</n-radio-button>
<n-radio-button value="code">代码</n-radio-button>
</n-radio-group>
</template>
</Preview>
</div>
<n-split v-else direction="vertical" :default-size="0.55" min="160px" max="0.8">
<template #1>
<CodeTabs :code="code">
<template #prefix>
<n-radio-group v-model:value="mode" size="small" class="mode">
<n-radio-button value="preview">预览</n-radio-button>
<n-radio-button value="code">代码</n-radio-button>
</n-radio-group>
</template>
<template #suffix>
<n-button quaternary size="small" @click="format">整理代码</n-button>
<n-button quaternary size="small" @click="showPaste = true">
粘贴外部代码
</n-button>
<n-button quaternary size="small" @click="clear">清空</n-button>
</template>
</CodeTabs>
</template>
<template #2>
<div class="preview-only">
<Preview
:html="code.html"
:css="code.css"
:js="code.js"
:asset-base-url="assetBaseUrl"
:storage-task-id="taskId"
/>
</div>
</template>
</n-split>
</div>
<VersionBar
:kind="kind"
:versions="versions"
:selected-id="selectedId"
:modified="modified"
:can-submit="canSubmit"
:busy="busy"
@select="select"
@submit="submit"
@history="openHistory"
/>
</div>
</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
:task-id="taskId"
:active="showHistory"
:asset-base-url="assetBaseUrl"
:refresh-key="historyKey"
@select="adopt"
@deleted="removeMessagePair"
/>
</n-drawer-content>
</n-drawer>
</template>
<script lang="ts" setup>
import { computed, h, onUnmounted, ref, toRef, watch } from "vue"
import { useStorage } from "@vueuse/core"
import type { RouteLocationRaw } from "vue-router"
import { NCode, useDialog, useMessage } from "naive-ui"
import { Icon } from "../../icons"
import PromptPanel from "../ai/PromptPanel.vue"
import PromptHistoryPanel from "../ai/PromptHistoryPanel.vue"
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 {
addVersionPair,
connectPrompt,
disconnectPrompt,
markMessageSubmitted,
messages,
removeMessagePair,
sendPrompt,
setCodeSink,
} from "../../store/prompt"
import { closeGuidance } from "../../store/guidance"
import { authed } from "../../store/user"
import {
emptyCode,
formatCode,
isEmptyCode,
sameCode,
type Code,
} from "../../utils/code"
// 挑战和自由创作共用的工作台:左边题目 + AI 对话,右边预览 / 代码,底部是版本条。
// 每轮 AI 回复、每次手改或粘贴外部代码都是一个版本;挑战由学生自己点“交这一版”提交
const props = defineProps<{
kind: "challenge" | "creation"
// 任务还没加载完时是 0
taskId: number
title: string
backLabel: string
backTo: RouteLocationRaw
briefLabel?: string
assetBaseUrl?: string
// 自由创作:保存作品(AI 生成完也会自动保存)
saveWork?: (code: Code) => Promise<void>
}>()
const message = useMessage()
const dialog = useDialog()
const { code, restored } = useTaskCode(toRef(props, "taskId"))
const mode = ref<"preview" | "code">("preview")
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)
type CodeOut = { html: string | null; css: string | null; js: string | null }
const normalize = (c: CodeOut): Code => ({
html: c.html ?? "",
css: c.css ?? "",
js: c.js ?? "",
})
const versionMessages = computed(() =>
messages.value.filter(
(m) => m.role === "assistant" && m.id && m.code && (m.code.html || m.code.css || m.code.js),
),
)
const versions = computed<VersionItem[]>(() =>
versionMessages.value.map((m, i) => ({
id: m.id!,
n: i + 1,
source: m.source ?? "conversation",
submitted: !!m.submitted,
})),
)
function versionCode(id: number | null): Code | null {
const m = versionMessages.value.find((v) => v.id === id)
return m?.code ? normalize(m.code) : null
}
const modified = computed(() => {
const base = versionCode(selectedId.value)
return base ? !sameCode(code, base) : !isEmptyCode(code)
})
const canSubmit = computed(() => !!props.taskId && !isEmptyCode(code))
function matchVersion(): number | null {
const hit = [...versionMessages.value]
.reverse()
.find((m) => sameCode(code, normalize(m.code!)))
return hit?.id ?? null
}
// 连上时:本地存着这个任务的代码就继续用它,否则打开最新一版。AI 生成完:换成新代码
function onCode(c: CodeOut, id: number, reason: "init" | "complete") {
if (reason === "init" && restored.value) {
selectedId.value = matchVersion() ?? id
return
}
Object.assign(code, normalize(c))
selectedId.value = id
if (reason === "complete" && props.kind === "creation") {
props.saveWork?.({ ...code }).catch(() => {})
}
}
watch(
() => props.taskId,
(id) => {
if (!id || !authed.value) return
setCodeSink(onCode)
connectPrompt(id)
},
{ immediate: true },
)
onUnmounted(() => {
disconnectPrompt()
closeGuidance()
})
// 打开时已经有对话的,题目默认收起,给对话留地方
let briefDecided = false
watch(
() => messages.value.length,
(n) => {
if (briefDecided || !n) return
briefDecided = true
briefOpen.value = false
},
)
async function saveAsVersion(prompt?: string): Promise<number> {
const res = await Prompt.saveVersion(props.taskId, { ...code }, prompt)
addVersionPair(prompt || "(手动修改了代码)", res.source, res.message_id, { ...code })
selectedId.value = res.message_id
historyKey.value++
return res.message_id
}
// 发给 AI 之前,先把还没存的手改存成版本,AI 才会在手改后的代码上继续改
async function beforeSend() {
briefOpen.value = false
briefDecided = true
if (!modified.value || isEmptyCode(code)) return
try {
await saveAsVersion()
message.info("已把你的修改存为新版本,AI 会在它的基础上改")
} catch {
message.error("保存你的修改失败,AI 会在上一版的基础上改")
}
}
function select(id: number) {
const apply = () => {
const c = versionCode(id)
if (!c) return
Object.assign(code, c)
selectedId.value = id
}
if (!modified.value) return apply()
dialog.warning({
title: "放弃当前修改?",
content: "当前的修改还没有存成版本,切换后会丢失。想留着的话,可以先点“交当前修改”或发给 AI。",
positiveText: "放弃并切换",
negativeText: "取消",
onPositiveClick: apply,
})
}
// 从“全部历史”里选了一份代码
function adopt(c: Code) {
Object.assign(code, c)
selectedId.value = matchVersion()
showHistory.value = false
}
function openHistory() {
historyKey.value++
showHistory.value = true
}
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 {
const id =
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
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
}
}
async function savePasted(prompt: string, c: Code) {
Object.assign(code, c)
try {
await saveAsVersion(prompt)
message.success("已存为新版本")
} catch (err: any) {
message.error(err.response?.data?.detail ?? "保存失败,请重试")
throw err
}
}
async function format() {
try {
Object.assign(code, await formatCode({ ...code }))
} catch (err: any) {
dialog.error({
title: "整理失败,代码里可能有语法错误",
content: () => h(NCode, { code: err.message }),
style: { width: "auto" },
})
}
}
function clear() {
dialog.warning({
title: "清空代码?",
content: "只清空编辑器里的代码,已经存下的版本不受影响。",
positiveText: "清空",
negativeText: "取消",
onPositiveClick: () => {
Object.assign(code, emptyCode())
},
})
}
// 和 PromptPanel 里的模型选择是同一个存储
const model = useStorage("prompt-model", "deepseek-flash")
defineExpose({
// 页面自己发提示词(自由创作的“用这个主题生成”)
async send(text: string) {
await beforeSend()
sendPrompt(text, model.value)
},
// 页面用来放入示例代码,或自由创作从服务器读到的作品
setCode(c: Code) {
Object.assign(code, c)
selectedId.value = matchVersion()
},
restored,
})
</script>
<style scoped>
.workbench {
height: 100%;
}
.left,
.right {
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--wp-surface);
}
.left {
border-right: 1px solid var(--wp-line);
}
.left-head {
height: 43px;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--wp-line);
}
.title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.spacer {
flex: 1;
}
.brief {
flex: none;
border-bottom: 1px solid var(--wp-line);
}
.brief-toggle {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 0;
background: var(--wp-paper);
color: var(--wp-ink);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.brief-toggle span {
color: var(--wp-muted);
font-size: 12px;
}
.brief-toggle:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: -2px;
}
.brief-body {
max-height: 42vh;
overflow-y: auto;
}
.chat {
flex: 1;
min-height: 0;
}
.right-body {
flex: 1;
min-height: 0;
}
.preview-only {
height: 100%;
}
.mode {
margin: 0 8px;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
import { reactive, ref, watch, type Ref } from "vue"
import { user } from "../store/user"
import { emptyCode, type Code } from "../utils/code"
const key = (taskId: number) => `web-code:${user.username}:${taskId}`
export function readTaskCode(taskId: number): Code | null {
return user.username ? read(taskId) : null
}
function read(taskId: number): Code | null {
try {
const saved = JSON.parse(localStorage.getItem(key(taskId)) ?? "null")
return saved && typeof saved.html === "string" ? { ...emptyCode(), ...saved } : null
} catch {
return null
}
}
// 直接写入某个任务的代码(比如把别人的提交复制过去),下次打开那个任务的工作台时读到
export function saveTaskCode(taskId: number, code: Code) {
try {
localStorage.setItem(key(taskId), JSON.stringify(code))
} catch {}
}
// 工作台里正在编辑的代码,每个任务各存一份(按用户 + 任务存在 localStorage)。
// 以前挑战、教程、自由创作共用一份,在挑战页点“清空”会把教程里的代码也清掉
export function useTaskCode(taskId: Ref<number>) {
const code = reactive(emptyCode())
// 打开时本地有没有存着这个任务的代码;没有的话由调用方填入最新版本
const restored = ref(false)
// 用户名也在 key 里,刚打开页面时可能还没取到
watch(
() => [taskId.value, user.username] as const,
([id]) => {
const saved = id && user.username ? read(id) : null
Object.assign(code, saved ?? emptyCode())
restored.value = !!saved
},
{ immediate: true },
)
let timer: ReturnType<typeof setTimeout> | undefined
watch(
() => ({ ...code }),
(value) => {
if (!taskId.value || !user.username) return
clearTimeout(timer)
timer = setTimeout(() => {
try {
localStorage.setItem(key(taskId.value), JSON.stringify(value))
} catch {}
}, 300)
},
)
return { code, restored }
}
-1
View File
@@ -3,7 +3,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly PUBLIC_WEB_URL: string readonly PUBLIC_WEB_URL: string
readonly PUBLIC_BASE_URL: string readonly PUBLIC_BASE_URL: string
readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_WS_URL: string readonly PUBLIC_WS_URL: string
} }
+65 -2
View File
@@ -4,10 +4,26 @@
body { body {
margin: 0; margin: 0;
background: var(--wp-paper);
color: var(--wp-ink);
font-family: var(--wp-font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* 顶栏下面的内容区高度;页面用它代替 100vh */
:root {
--wp-topbar-h: 48px;
--wp-main-h: calc(100vh - var(--wp-topbar-h));
}
code,
kbd,
pre,
samp {
font-family: var(--wp-font-mono);
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -18,10 +34,57 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #d0d0d0; background: var(--wp-line);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #b0b0b0; background: var(--wp-faint);
}
/* iframe 里都是学生写的网页,网页默认是白底;不设的话会透出页面的底色 */
iframe {
background: #fff;
}
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
.lang-tab {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--wp-font-mono);
font-weight: 600;
font-size: 13px;
}
.lang-tab.lang-html::before,
.lang-tab.lang-css::before,
.lang-tab.lang-js::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.lang-html {
color: var(--wp-html);
}
.lang-html::before {
background: var(--wp-html);
}
.lang-css {
color: var(--wp-css);
}
.lang-css::before {
background: var(--wp-css);
}
.lang-js {
color: var(--wp-js);
}
.lang-js::before {
background: var(--wp-js);
}
/* markdown 区域用页面的底色,暗色时不出现一块 GitHub 的深蓝底 */
:root[data-theme="dark"] body .markdown-body {
background-color: transparent;
color: var(--wp-ink);
} }
+111
View File
@@ -0,0 +1,111 @@
import { defineComponent, h, type Component } from "vue"
import {
ArrowLeft,
ArrowRight,
Award,
BookOpen,
Bug,
ChartNoAxesColumn,
Check,
ChevronDown,
ChevronRight,
ChevronUp,
CircleCheck,
Download,
GraduationCap,
Eye,
Globe,
Image,
Info,
Lightbulb,
List,
Lock,
Maximize2,
Minimize2,
LogOut,
Monitor,
Moon,
Play,
Plus,
RefreshCw,
Save,
Search,
Smartphone,
Sparkles,
Sun,
SunMoon,
SquarePen,
Star,
Swords,
Tablet,
Trash2,
Users,
X,
} from "lucide-vue-next"
// 全站只用 lucide 一套图标,并且打包进前端(以前走在线的 iconify 接口,机房断网时图标会全部消失)。
// 沿用 <Icon icon="集合:名字" /> 的写法;原来其他图标集里用到的几个映射到 lucide 的对应图标
const registry: Record<string, Component> = {
"arrow-left": ArrowLeft,
"arrow-right": ArrowRight,
award: Award,
"book-open": BookOpen,
bug: Bug,
"bar-chart-2": ChartNoAxesColumn,
check: Check,
"chevron-down": ChevronDown,
"chevron-right": ChevronRight,
"chevron-up": ChevronUp,
globe: Globe,
lock: Lock,
"maximize-2": Maximize2,
"minimize-2": Minimize2,
"check-circle": CircleCheck,
download: Download,
eye: Eye,
image: Image,
info: Info,
lightbulb: Lightbulb,
list: List,
"log-out": LogOut,
play: Play,
plus: Plus,
"refresh-cw": RefreshCw,
save: Save,
search: Search,
sparkles: Sparkles,
edit: SquarePen,
star: Star,
swords: Swords,
"trash-2": Trash2,
users: Users,
"graduation-cap": GraduationCap,
x: X,
sun: Sun,
moon: Moon,
"sun-moon": SunMoon,
// 原 material-symbols / pepicons
"close-rounded": X,
"desktop-windows-outline": Monitor,
"smartphone-outline": Smartphone,
"tablet-outline": Tablet,
}
export const Icon = defineComponent({
name: "Icon",
props: {
icon: { type: String, required: true },
width: { type: [Number, String], default: 16 },
},
setup(props) {
return () => {
const name = props.icon.slice(props.icon.indexOf(":") + 1)
const comp = registry[name]
if (!comp) {
if (import.meta.env.DEV) console.warn(`图标 ${props.icon} 没有登记在 icons.ts 里`)
return null
}
return h(comp, { size: props.width, strokeWidth: 2, "aria-hidden": "true" })
}
},
})
+7 -39
View File
@@ -1,49 +1,23 @@
import { createApp } from "vue" import { createApp } from "vue"
import { create } from "naive-ui" import { create } from "naive-ui"
import App from "./App.vue" import App from "./App.vue"
import "@fontsource/jetbrains-mono/400.css"
import "@fontsource/jetbrains-mono/600.css"
import "./global.css" import "./global.css"
import { addAPIProvider } from "@iconify/vue" import "./store/theme"
//@ts-ignore //@ts-ignore
import "github-markdown-css/github-markdown-light.css" import "github-markdown-css/github-markdown-light.css"
import { marked } from "marked" import { marked } from "marked"
import alert from "marked-alert" import { trustedExtensions } from "./utils/markdown"
import { markedHighlight } from "marked-highlight"
import preview from "marked-code-preview"
import { alertVariants } from "./utils/const"
import hljs from "highlight.js/lib/core"
import xml from "highlight.js/lib/languages/xml"
import css from "highlight.js/lib/languages/css"
import javascript from "highlight.js/lib/languages/javascript"
import plaintext from "highlight.js/lib/languages/plaintext"
//@ts-ignore //@ts-ignore
import "highlight.js/styles/github.min.css" import "highlight.js/styles/github.min.css"
// 暗色时覆盖上面两份亮色样式
import "./styles/dark-content.css"
import { router } from "./router" import { router } from "./router"
hljs.registerLanguage("html", xml) marked.use(...trustedExtensions)
hljs.registerLanguage("css", css)
hljs.registerLanguage("js", javascript)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("plaintext", plaintext)
marked.use({
gfm: true,
})
marked.use(
markedHighlight({
langPrefix: "hljs language-",
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : "plaintext"
return hljs.highlight(code, { language }).value
},
}),
)
marked.use(alert({ variants: alertVariants }))
const template = `<div class="markedown-body-preview">{preview}</div>`
marked.use(preview({ template }))
const app = createApp(App) const app = createApp(App)
const naive = create() const naive = create()
@@ -51,9 +25,3 @@ const naive = create()
app.use(naive) app.use(naive)
app.use(router) app.use(router)
app.mount("#app") app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
}
+153 -316
View File
@@ -1,104 +1,60 @@
<template> <template>
<div class="challenge-layout"> <Workbench
<div class="challenge-sider"> ref="wb"
<n-tabs v-model:value="activeTab" type="line" class="left-tabs"> kind="challenge"
<template #prefix> :task-id="taskId"
<n-button text @click="back" style="margin: 0 8px"> :title="title"
<Icon :width="20" icon="pepicons-pencil:arrow-left" /> back-label="返回挑战列表"
:back-to="{ name: 'home-challenge-list' }"
brief-label="题目"
:asset-base-url="assetBaseUrl"
>
<template #badges>
<n-tag v-if="score" size="small" :bordered="false" type="warning">
{{ score }} 分
</n-tag>
</template>
<template #tools>
<n-button quaternary size="small" @click="showPeers = true">作品</n-button>
<n-button v-if="assets.length" quaternary size="small" @click="showAssets = true">
素材
</n-button>
<n-tooltip v-if="roleStaff">
<template #trigger>
<n-button text @click="showStats = true">
<Icon :width="16" icon="lucide:bar-chart-2" />
</n-button> </n-button>
</template> </template>
<template #suffix> 课堂总结
<n-flex style="margin: 0 8px"> </n-tooltip>
<n-button <n-tooltip v-if="roleSuper">
v-if="roleAdmin || roleSuper" <template #trigger>
text <n-button text @click="edit">
@click="showStats = true" <Icon :width="16" icon="lucide:edit" />
> </n-button>
<Icon :width="16" icon="lucide:bar-chart-2" />
</n-button>
<n-button
v-if="authed"
text
@click="
$router.push({ name: 'submissions', params: { page: 1 } })
"
>
<Icon :width="16" icon="lucide:list" />
</n-button>
<n-button v-if="roleSuper" text @click="edit">
<Icon :width="16" icon="lucide:edit" />
</n-button>
</n-flex>
</template> </template>
<n-tab-pane name="desc" tab="挑战描述" display-directive="show"> 编辑这道题
<div class="desc-pane"> </n-tooltip>
<div class="challenge-meta"> </template>
<n-flex align="center" justify="space-between"> <template #brief>
<n-text depth="3"> <div class="brief">
出题人:{{ challengeAuthor || "未设置" }} <n-flex align="center" justify="space-between" class="meta">
</n-text> <n-text depth="3">出题人:{{ author || "未设置" }}</n-text>
<n-flex align="center" size="small"> <n-button v-if="exampleCode" size="tiny" @click="useExample">
<n-button 放入示例代码
v-if="assets.length" </n-button>
size="small" </n-flex>
@click="showAssets = true" <div
> class="markdown-body challenge-desc no-select"
看素材 v-html="content"
</n-button> @copy.prevent
<n-button @click="onCopyClick"
v-if="exampleCode" />
size="small" </div>
@click="previewExample" </template>
> </Workbench>
看示例 <TaskStatsModal v-model:show="showStats" :task-id="taskId" :title="title" />
</n-button> <PeersDrawer v-model:show="showPeers" :task-id="taskId" :asset-base-url="assetBaseUrl" />
</n-flex>
</n-flex>
</div>
<div
class="markdown-body content no-select"
v-html="challengeContent"
ref="$desc"
@copy.prevent
/>
</div>
</n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel
@deleted="historyRefreshKey++"
@submitted="historyRefreshKey++"
/>
</n-tab-pane>
<n-tab-pane name="external" tab="手动提交" display-directive="show">
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
</n-tab-pane>
<n-tab-pane name="history" tab="历史对话" display-directive="show">
<PromptHistoryPanel
:task-id="taskId"
:active="activeTab === 'history'"
:asset-base-url="assetBaseUrl"
:refresh-key="historyRefreshKey"
@select="previewHistoryItem"
@deleted="removeMessagePair"
/>
</n-tab-pane>
</n-tabs>
</div>
<div class="challenge-content">
<Preview
:html="html"
:css="css"
:js="js"
:asset-base-url="assetBaseUrl"
show-code-button
clearable
@showCode="showCode = true"
@clear="clearAll"
/>
</div>
</div>
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
<RandomRatingModal v-if="authed" />
<n-modal <n-modal
v-model:show="showAssets" v-model:show="showAssets"
preset="card" preset="card"
@@ -116,104 +72,66 @@
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-modal> </n-modal>
<n-modal
v-model:show="showCode"
preset="card"
title="代码"
style="width: 700px"
>
<n-tabs type="line">
<n-tab-pane name="html" tab="HTML">
<n-code :code="html" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<n-code :code="css" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<n-code :code="js" language="javascript" />
</n-tab-pane>
</n-tabs>
</n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue" import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui" import { Marked } from "marked"
import { Icon } from "@iconify/vue"
import { marked, type MarkedOptions } from "marked"
import copyFn from "copy-text-to-clipboard" import copyFn from "copy-text-to-clipboard"
import PromptPanel from "../components/ai/PromptPanel.vue" import { Icon } from "../icons"
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue" import Workbench from "../components/workbench/Workbench.vue"
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
import Preview from "../components/editor/Preview.vue"
import TaskStatsModal from "../components/task/TaskStatsModal.vue" import TaskStatsModal from "../components/task/TaskStatsModal.vue"
import RandomRatingModal from "../components/ai/RandomRatingModal.vue" import PeersDrawer from "../components/review/PeersDrawer.vue"
import { Challenge, Submission, TaskAssets } from "../api" import { Challenge, TaskAssets } from "../api"
import type { TaskAsset } from "../utils/type" import type { TaskAsset } from "../utils/type"
import { html, css, js } from "../store/editors"
import { taskId, taskTab, challengeDisplay } from "../store/task" import { taskId, taskTab, challengeDisplay } from "../store/task"
import { TASK_TYPE } from "../utils/const" import { TASK_TYPE } from "../utils/const"
import { authed, roleAdmin, roleSuper } from "../store/user" import { roleStaff, roleSuper } from "../store/user"
import { import { trustedExtensions } from "../utils/markdown"
connectPrompt, import type { Code } from "../utils/code"
disconnectPrompt,
streaming,
setOnCodeComplete,
removeMessagePair,
markMessageSubmitted,
} from "../store/prompt"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage()
const challengeRenderer = new marked.Renderer() // 题目里的代码块带“复制”按钮;题目正文本身不让复制
challengeRenderer.code = ({ text, lang }) => { const challengeMarked = new Marked(...trustedExtensions, {
const language = lang?.toLowerCase() ?? "text" renderer: {
return `<div class="codeblock-wrapper" data-lang="${language}"> code({ text, lang }) {
const language = lang?.toLowerCase() ?? "text"
return `<div class="codeblock-wrapper" data-lang="${language}">
<div class="codeblock-action"> <div class="codeblock-action">
<span class="lang">${language.toUpperCase()}</span> <span class="lang">${language.toUpperCase()}</span>
<button class="action-btn" data-action="copy">复制</button> <button class="action-btn" data-action="copy">复制</button>
</div> </div>
<pre><code class="language-${language}">${text}</code></pre> <pre><code class="language-${language}">${text}</code></pre>
</div>` </div>`
} },
challengeRenderer.link = ({ href, text }) => link({ href, text }) {
`<a href="${href}" target="_blank">${text}</a>` return `<a href="${href}" target="_blank">${text}</a>`
},
},
})
function setupCodeCopy() { const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
$desc.value?.addEventListener("click", (e: MouseEvent) => { const title = ref("")
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']") const score = ref(0)
if (!btn) return const author = ref("")
const wrapper = btn.closest<HTMLElement>("[data-lang]")! const content = ref("")
const code = wrapper.querySelector("code")?.textContent ?? "" const assets = ref<TaskAsset[]>([])
copyFn(code) const exampleCode = ref<Code | null>(null)
btn.textContent = "已复制"
setTimeout(() => { btn.textContent = "复制" }, 1000)
})
}
const activeTab = ref("desc")
const challengeContent = ref("")
const challengeAuthor = ref("")
const $desc = useTemplateRef<HTMLElement>("$desc")
const showCode = ref(false)
const showStats = ref(false) const showStats = ref(false)
const showAssets = ref(false) const showAssets = ref(false)
const assets = ref<TaskAsset[]>([]) const showPeers = ref(false)
const historyRefreshKey = ref(0)
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
const assetBaseUrl = computed( const assetBaseUrl = computed(
() => `/media/tasks/challenge/${challengeDisplay.value}/`, () => `/media/tasks/challenge/${challengeDisplay.value}/`,
) )
watch(streaming, (val) => { // 加载还没完成就离开页面时,不要再改全局状态
if (val) activeTab.value = "chat" let disposed = false
})
async function loadChallenge() { async function load() {
const display = Number(route.params.display) const display = Number(route.params.display)
taskTab.value = TASK_TYPE.Challenge taskTab.value = TASK_TYPE.Challenge
challengeDisplay.value = display challengeDisplay.value = display
@@ -221,143 +139,58 @@ async function loadChallenge() {
Challenge.get(display), Challenge.get(display),
TaskAssets.listChallenge(display), TaskAssets.listChallenge(display),
]) ])
taskId.value = data.task_ptr if (disposed) return
challengeAuthor.value = data.author_name ?? "" title.value = `${data.display}. ${data.title}`
if (data.example_html || data.example_css || data.example_js) { score.value = data.score
exampleCode.value = { author.value = data.author_name ?? ""
html: data.example_html ?? "",
css: data.example_css ?? "",
js: data.example_js ?? "",
}
} else {
exampleCode.value = null
}
challengeContent.value = await marked.parse(data.content, {
async: true,
renderer: challengeRenderer,
} as MarkedOptions)
assets.value = fetchedAssets assets.value = fetchedAssets
if (!authed.value) return exampleCode.value =
connectPrompt(data.task_ptr) data.example_html || data.example_css || data.example_js
setOnCodeComplete(async (code, messageId) => { ? {
try { html: data.example_html ?? "",
await Submission.create( css: data.example_css ?? "",
taskId.value, js: data.example_js ?? "",
{ }
html: code.html ?? "", : null
css: code.css ?? "", content.value = await challengeMarked.parse(data.content, { async: true })
js: code.js ?? "", if (disposed) return
}, taskId.value = data.task_ptr
messageId, }
)
markMessageSubmitted(messageId) function useExample() {
historyRefreshKey.value++ if (exampleCode.value) wb.value?.setCode({ ...exampleCode.value })
message.success("已自动提交本次对话生成的代码") }
} catch {
message.error("自动提交失败,请稍后重试") function onCopyClick(e: MouseEvent) {
} const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
}) if (!btn) return
const code = btn.closest<HTMLElement>("[data-lang]")?.querySelector("code")?.textContent ?? ""
copyFn(code)
btn.textContent = "已复制"
setTimeout(() => {
btn.textContent = "复制"
}, 1000)
} }
function edit() { function edit() {
router.push({ router.push({ name: "challenge-editor", params: { display: route.params.display } })
name: "challenge-editor",
params: { display: route.params.display },
})
} }
function previewExample() { onMounted(load)
if (!exampleCode.value) return onUnmounted(() => {
html.value = exampleCode.value.html disposed = true
css.value = exampleCode.value.css
js.value = exampleCode.value.js
}
function clearAll() {
html.value = ""
css.value = ""
js.value = ""
}
function previewHistoryItem(code: { html: string; css: string; js: string }) {
html.value = code.html
css.value = code.css
js.value = code.js
}
function back() {
disconnectPrompt()
taskId.value = 0 taskId.value = 0
router.push({ name: "home-challenge-list" })
}
onMounted(() => {
setupCodeCopy()
loadChallenge()
}) })
onUnmounted(disconnectPrompt)
</script> </script>
<style scoped> <style scoped>
.challenge-layout { .brief {
display: flex; padding: 10px 12px 12px;
height: 100vh;
overflow: hidden;
} }
.challenge-sider { .meta {
width: 40%; margin-bottom: 8px;
height: 100%; font-size: 12px;
overflow: hidden;
border-right: 1px solid var(--n-border-color, #efeff5);
flex-shrink: 0;
}
.challenge-content {
flex: 1;
height: 100%;
overflow: hidden;
}
.left-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tabs-pane-wrapper) {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tab-pane) {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
}
.content {
padding: 12px;
overflow-y: auto;
flex: 1;
min-height: 0;
box-sizing: border-box;
}
.desc-pane {
height: 100%;
display: flex;
flex-direction: column;
}
.challenge-meta {
padding: 10px 12px;
border-bottom: 1px solid var(--n-border-color, #efeff5);
flex-shrink: 0;
} }
.no-select { .no-select {
@@ -366,15 +199,20 @@ onUnmounted(disconnectPrompt)
</style> </style>
<style> <style>
.challenge-sider .codeblock-wrapper { .challenge-desc {
padding: 1rem; font-size: 14px;
background-color: #f6f8fa; background: transparent;
}
.challenge-desc .codeblock-wrapper {
padding: 12px;
background-color: var(--wp-paper);
border-radius: 6px; border-radius: 6px;
margin-bottom: 1rem; margin-bottom: 12px;
overflow: auto; overflow: auto;
} }
.challenge-sider .codeblock-wrapper pre { .challenge-desc .codeblock-wrapper pre {
padding: 0; padding: 0;
background-color: transparent; background-color: transparent;
border-radius: 0; border-radius: 0;
@@ -382,40 +220,39 @@ onUnmounted(disconnectPrompt)
overflow: visible; overflow: visible;
} }
.challenge-sider .codeblock-wrapper pre code { .challenge-desc .codeblock-wrapper pre code {
padding: 0; padding: 0;
font-size: 1rem; font-size: 13px;
font-family: Monaco, monospace; font-family: var(--wp-font-mono);
user-select: text; user-select: text;
} }
.challenge-sider .codeblock-action { .challenge-desc .codeblock-action {
margin-bottom: 0.5rem; margin-bottom: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.challenge-sider .codeblock-action .lang { .challenge-desc .codeblock-action .lang {
font-size: 0.9rem; font: 600 12px var(--wp-font-mono);
font-weight: bold; color: var(--wp-muted);
} }
.challenge-sider .action-btn { .challenge-desc .action-btn {
height: 28px; height: 24px;
padding: 0 14px; padding: 0 10px;
font-size: 14px; font-size: 12px;
border-radius: 3px; border-radius: 4px;
border: 1px solid #d9d9d9; border: 1px solid var(--wp-line);
background-color: #fff; background-color: var(--wp-surface);
color: #333; color: var(--wp-ink);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
user-select: none; user-select: none;
} }
.challenge-sider .action-btn:hover { .challenge-desc .action-btn:hover {
border-color: #18a058; border-color: var(--wp-brand);
color: #18a058; color: var(--wp-brand);
} }
</style> </style>
-313
View File
@@ -1,313 +0,0 @@
<template>
<n-grid x-gap="10" :cols="8">
<n-gi :span="2" class="col">
<n-flex vertical class="list">
<n-card
v-for="item in list"
:key="item.display"
@click="show(item.display)"
class="card"
:header-style="{
backgroundColor:
item.display === challenge.display
? 'rgba(24, 160, 80, 0.1)'
: '',
}"
:embedded="!item.is_public"
>
<template #header>
<n-flex align="center">
<n-button
text
:disabled="!roleSuper"
@click.stop="togglePublic(item.display)"
>
<Icon
width="24"
:icon="
item.is_public
? 'twemoji:check-mark-button'
: 'twemoji:locked'
"
></Icon>
</n-button>
<span>【{{ item.display }}】{{ item.title }}</span>
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
<n-tag size="small"
>出题人 {{ item.author_name || "未设置" }}</n-tag
>
</n-flex>
</template>
<template #header-extra>
<n-button text type="default" @click="remove(item.display)">
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
</n-button>
</template>
</n-card>
<n-button @click="createNew">新建</n-button>
</n-flex>
</n-gi>
<n-gi :span="6" class="col">
<n-flex vertical>
<n-form inline :show-feedback="false">
<n-form-item label="序号" label-placement="left">
<n-input-number
style="width: 100px"
v-model:value="challenge.display"
/>
</n-form-item>
<n-form-item label="标题" label-placement="left">
<n-input v-model:value="challenge.title" />
</n-form-item>
<n-form-item label="分数" label-placement="left">
<n-input-number
style="width: 100px"
v-model:value="challenge.score"
:min="0"
/>
</n-form-item>
<n-form-item label="公开" label-placement="left">
<n-switch v-model:value="challenge.is_public" />
</n-form-item>
<n-form-item label="出题人" label-placement="left">
<n-text depth="3">{{ challenge.author_name || "未设置" }}</n-text>
</n-form-item>
<n-form-item label-placement="left">
<n-button type="primary" @click="submit" :disabled="!canSubmit">
提交
</n-button>
</n-form-item>
</n-form>
<n-flex>
<TaskAssetManager
v-if="challenge.display"
task-type="challenge"
:display="challenge.display"
/>
<n-button
v-if="challenge.display"
size="small"
@click="showExampleModal = true"
>
示例代码
</n-button>
</n-flex>
<MarkdownEditor
style="height: calc(100vh - 100px)"
v-model="challenge.content"
/>
</n-flex>
</n-gi>
</n-grid>
<n-modal
v-model:show="showExampleModal"
preset="card"
title="示例代码(点击「看示例」时显示效果)"
style="width: 640px"
>
<n-input
type="textarea"
v-model:value="rawCode"
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
:autosize="{ minRows: 10, maxRows: 30 }"
/>
<n-flex v-if="splitResult" style="margin-top: 8px">
<n-tag size="small" type="success"
>HTML · {{ splitResult.html.length }} 字符</n-tag
>
<n-tag size="small" type="info"
>CSS · {{ splitResult.css.length }} 字符</n-tag
>
<n-tag size="small" type="warning"
>JS · {{ splitResult.js.length }} 字符</n-tag
>
</n-flex>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Challenge } from "../api"
import { roleSuper } from "../store/user"
import type { ChallengeSlim } from "../utils/type"
import { useDialog, useMessage } from "naive-ui"
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const confirm = useDialog()
const list = ref<ChallengeSlim[]>([])
const showExampleModal = ref(false)
const rawCode = ref("")
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
function splitHtml(raw: string) {
let result = raw
const cssBlocks: string[] = []
const jsBlocks: string[] = []
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
cssBlocks.push(c.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, c) => {
jsBlocks.push(c.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
watch(rawCode, (val) => {
if (!val.trim()) {
splitResult.value = null
challenge.example_html = null
challenge.example_css = null
challenge.example_js = null
return
}
const split = splitHtml(val)
splitResult.value = split
challenge.example_html = split.html || null
challenge.example_css = split.css || null
challenge.example_js = split.js || null
})
watch(showExampleModal, (visible) => {
if (!visible) return
const parts: string[] = []
if (challenge.example_css)
parts.push(`<style>\n${challenge.example_css}\n</style>`)
if (challenge.example_html) parts.push(challenge.example_html)
if (challenge.example_js)
parts.push(`<script>\n${challenge.example_js}\n<\/script>`)
rawCode.value = parts.join("\n\n")
})
const challenge = reactive({
display: 0,
title: "",
content: "",
score: 0,
is_public: false,
author_name: "",
example_html: null as string | null,
example_css: null as string | null,
example_js: null as string | null,
})
const canSubmit = computed(
() => challenge.display && challenge.title && challenge.content,
)
async function getContent() {
list.value = await Challenge.list()
const display = Number(route.params.display)
const target = list.value.find((item) => item.display === display)
if (target) {
show(display)
} else if (list.value.length > 0) {
show(list.value[0].display)
}
}
function createNew() {
challenge.display = list.value[list.value.length - 1]?.display ?? 0 + 1
challenge.title = ""
challenge.content = ""
challenge.score = 0
challenge.is_public = false
challenge.author_name = ""
challenge.example_html = null
challenge.example_css = null
challenge.example_js = null
}
async function submit() {
try {
await Challenge.createOrUpdate(challenge)
message.success("提交成功")
challenge.display = 0
challenge.title = ""
challenge.content = ""
challenge.score = 0
challenge.is_public = false
challenge.author_name = ""
challenge.example_html = null
challenge.example_css = null
challenge.example_js = null
await getContent()
} catch (error: any) {
message.error(error.response.data.detail)
}
}
async function remove(display: number) {
confirm.warning({
title: "警告",
content: "你确定要删除吗?",
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
await Challenge.remove(display)
message.success("删除成功")
getContent()
},
})
}
async function show(display: number) {
router.push({ name: "challenge-editor", params: { display } })
const item = await Challenge.get(display)
challenge.display = item.display
challenge.title = item.title
challenge.content = item.content
challenge.score = item.score
challenge.is_public = item.is_public
challenge.author_name = item.author_name ?? ""
challenge.example_html = item.example_html ?? null
challenge.example_css = item.example_css ?? null
challenge.example_js = item.example_js ?? null
}
async function togglePublic(display: number) {
const data = await Challenge.togglePublic(display)
message.success(data.message)
list.value = list.value.map((item) => {
if (item.display === display) item.is_public = !item.is_public
return item
})
}
onMounted(getContent)
</script>
<style scoped>
.list {
overflow: auto;
height: calc(100vh - 20px);
}
.col {
padding-top: 10px;
}
.card {
cursor: pointer;
}
.editor {
height: calc(100vh - 200px);
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<main class="challenge-home">
<header class="head">
<div>
<h2>挑战</h2>
<p class="sub">
用提示词让 AI 做出题目要求的网页。已完成 {{ doneCount }} / {{ challenges.length }}
</p>
</div>
<n-radio-group v-model:value="filter" size="small">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="todo">未做</n-radio-button>
<n-radio-button value="done">已交</n-radio-button>
</n-radio-group>
</header>
<n-spin :show="loading">
<n-empty
v-if="!loading && !visible.length"
:description="challenges.length ? '没有符合条件的挑战' : '暂无挑战,敬请期待'"
style="margin-top: 72px"
/>
<ol v-else class="list">
<li v-for="item in visible" :key="item.display">
<router-link
class="row"
:to="{ name: 'home-challenge', params: { display: item.display } }"
>
<span class="num">{{ item.display }}</span>
<span class="main">
<span class="title">{{ item.title }}</span>
<span class="author">出题人:{{ item.author_name || "未设置" }}</span>
</span>
<span class="score">{{ item.score }} 分</span>
<span :class="['state', { done: item.submitted }]">
<template v-if="item.submitted">
<Icon icon="lucide:check" :width="14" /> 已交
</template>
<template v-else>未做</template>
</span>
</router-link>
</li>
</ol>
</n-spin>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue"
import { Icon } from "../icons"
import { Challenge } from "../api"
import { taskId, taskTab } from "../store/task"
import { TASK_TYPE } from "../utils/const"
import type { ChallengeSlim } from "../utils/type"
const challenges = ref<ChallengeSlim[]>([])
const loading = ref(true)
const filter = ref<"all" | "todo" | "done">("all")
const doneCount = computed(() => challenges.value.filter((c) => c.submitted).length)
const visible = computed(() =>
challenges.value.filter((c) =>
filter.value === "todo" ? !c.submitted : filter.value === "done" ? c.submitted : true,
),
)
onMounted(async () => {
taskTab.value = TASK_TYPE.Challenge
taskId.value = 0
try {
challenges.value = await Challenge.listDisplay()
} finally {
loading.value = false
}
})
</script>
<style scoped>
.challenge-home {
max-width: 880px;
margin: 0 auto;
padding: 28px 20px 48px;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
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: 44px 1fr auto 72px;
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;
}
.num {
font: 600 15px var(--wp-font-mono);
color: var(--wp-faint);
font-variant-numeric: tabular-nums;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.author {
font-size: 12px;
color: var(--wp-muted);
}
.score {
font: 13px var(--wp-font-mono);
color: var(--wp-warn);
}
.state {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
font-size: 13px;
color: var(--wp-faint);
}
.state.done {
color: var(--wp-brand);
font-weight: 600;
}
</style>
+75 -218
View File
@@ -1,159 +1,93 @@
<template> <template>
<div class="creation-layout"> <Workbench
<div class="creation-sider"> ref="wb"
<n-tabs v-model:value="activeTab" type="line" class="left-tabs"> kind="creation"
<template #prefix> :task-id="taskId"
<n-button text @click="back" style="margin: 0 8px"> :title="title || '自由创作'"
<Icon :width="20" icon="pepicons-pencil:arrow-left" /> back-label="返回我的创作"
</n-button> :back-to="{ name: 'creation-list' }"
</template> brief-label="我的主题"
<template #suffix> :save-work="saveWork"
<n-flex style="margin: 0 8px">
<n-tooltip trigger="hover">
<template #trigger>
<n-button text :loading="savingCode" @click="saveCode(true)">
<Icon :width="16" icon="lucide:save" />
</n-button>
</template>
保存当前作品
</n-tooltip>
</n-flex>
</template>
<n-tab-pane name="topic" tab="我的主题" display-directive="show">
<div class="topic-pane">
<n-form-item label="主题名称" :show-feedback="false">
<n-input
v-model:value="title"
placeholder="给你的主题起个名字"
maxlength="100"
/>
</n-form-item>
<n-form-item
label="主题描述"
:show-feedback="false"
style="margin-top: 12px"
>
<n-input
v-model:value="content"
type="textarea"
:autosize="{ minRows: 8 }"
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
/>
</n-form-item>
<n-flex style="margin-top: 16px">
<n-button
:loading="savingTopic"
:disabled="!title.trim()"
@click="saveTopic"
>
保存主题
</n-button>
<n-button
type="primary"
:disabled="!content.trim() || streaming"
@click="generate"
>
用这个主题生成
</n-button>
</n-flex>
<n-text depth="3" class="topic-hint">
自由创作只属于你自己,不会进入提交记录和评分。
</n-text>
</div>
</n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel :allow-submit="false" @deleted="historyRefreshKey++" />
</n-tab-pane>
<n-tab-pane name="history" tab="历史对话" display-directive="show">
<PromptHistoryPanel
:task-id="taskId"
:active="activeTab === 'history'"
:refresh-key="historyRefreshKey"
@select="previewHistoryItem"
@deleted="removeMessagePair"
/>
</n-tab-pane>
</n-tabs>
</div>
<div class="creation-content">
<Preview
:html="html"
:css="css"
:js="js"
show-code-button
clearable
@showCode="showCode = true"
@clear="clearAll"
/>
</div>
</div>
<n-modal
v-model:show="showCode"
preset="card"
title="代码"
style="width: 700px"
> >
<n-tabs type="line"> <template #brief>
<n-tab-pane name="html" tab="HTML"> <div class="topic">
<n-code :code="html" language="html" /> <n-form-item label="主题名称" :show-feedback="false">
</n-tab-pane> <n-input
<n-tab-pane name="css" tab="CSS"> v-model:value="title"
<n-code :code="css" language="css" /> placeholder="给你的主题起个名字"
</n-tab-pane> maxlength="100"
<n-tab-pane name="js" tab="JS"> />
<n-code :code="js" language="javascript" /> </n-form-item>
</n-tab-pane> <n-form-item label="主题描述" :show-feedback="false" style="margin-top: 10px">
</n-tabs> <n-input
</n-modal> v-model:value="content"
type="textarea"
:autosize="{ minRows: 4, maxRows: 10 }"
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
/>
</n-form-item>
<n-flex style="margin-top: 12px">
<n-button
size="small"
:loading="savingTopic"
:disabled="!title.trim()"
@click="saveTopic"
>
保存主题
</n-button>
<n-button
size="small"
type="primary"
:disabled="!content.trim() || streaming"
@click="generate"
>
用这个主题生成
</n-button>
</n-flex>
<n-text depth="3" class="hint">
自由创作只属于你自己,不会进入提交记录和评分。
</n-text>
</div>
</template>
</Workbench>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from "vue" import { onMounted, onUnmounted, ref, useTemplateRef } from "vue"
import { useRoute, useRouter } from "vue-router" import { useRoute } from "vue-router"
import { useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue" import Workbench from "../components/workbench/Workbench.vue"
import PromptPanel from "../components/ai/PromptPanel.vue"
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
import Preview from "../components/editor/Preview.vue"
import { Creation } from "../api" import { Creation } from "../api"
import { html, css, js } from "../store/editors" import { streaming } from "../store/prompt"
import { import type { Code } from "../utils/code"
connectPrompt,
disconnectPrompt,
removeMessagePair,
sendPrompt,
setOnCodeComplete,
streaming,
} from "../store/prompt"
const route = useRoute() const route = useRoute()
const router = useRouter()
const message = useMessage() const message = useMessage()
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
const display = Number(route.params.display) const display = Number(route.params.display)
const activeTab = ref("topic")
const taskId = ref(0) const taskId = ref(0)
const title = ref("") const title = ref("")
const content = ref("") const content = ref("")
const showCode = ref(false)
const savingTopic = ref(false) const savingTopic = ref(false)
const savingCode = ref(false)
const historyRefreshKey = ref(0)
watch(streaming, (val) => { // 加载还没完成就离开页面时,不要再改状态
if (val) activeTab.value = "chat" let disposed = false
})
async function load() { async function load() {
const data = await Creation.get(display) const data = await Creation.get(display)
taskId.value = data.task_id if (disposed) return
title.value = data.title title.value = data.title
content.value = data.content content.value = data.content
html.value = data.html taskId.value = data.task_id
css.value = data.css // 本地没有存着修改到一半的代码,就用服务器上保存的作品
js.value = data.js if (!wb.value?.restored) {
connectPrompt(data.task_id) wb.value?.setCode({ html: data.html, css: data.css, js: data.js })
setOnCodeComplete(() => saveCode()) }
}
async function saveWork(code: Code) {
await Creation.saveCode(display, code)
} }
async function saveTopic() { async function saveTopic() {
@@ -172,103 +106,26 @@ async function saveTopic() {
} }
} }
async function saveCode(notify = false) {
savingCode.value = true
try {
await Creation.saveCode(display, {
html: html.value,
css: css.value,
js: js.value,
})
if (notify) message.success("作品已保存")
} catch {
if (notify) message.error("保存失败,请重试")
} finally {
savingCode.value = false
}
}
function generate() { function generate() {
const text = content.value.trim() const text = content.value.trim()
if (!text || streaming.value) return if (!text || streaming.value) return
activeTab.value = "chat" wb.value?.send(text)
sendPrompt(text)
}
function clearAll() {
html.value = ""
css.value = ""
js.value = ""
}
function previewHistoryItem(code: { html: string; css: string; js: string }) {
html.value = code.html
css.value = code.css
js.value = code.js
}
function back() {
disconnectPrompt()
router.push({ name: "creation-list" })
} }
onMounted(load) onMounted(load)
onUnmounted(disconnectPrompt) onUnmounted(() => {
disposed = true
})
</script> </script>
<style scoped> <style scoped>
.creation-layout { .topic {
display: flex;
height: 100vh;
overflow: hidden;
}
.creation-sider {
width: 40%;
height: 100%;
overflow: hidden;
border-right: 1px solid var(--n-border-color, #efeff5);
flex-shrink: 0;
}
.creation-content {
flex: 1;
height: 100%;
overflow: hidden;
}
.left-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tabs-pane-wrapper) {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tab-pane) {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
}
.topic-pane {
padding: 12px; padding: 12px;
overflow-y: auto;
flex: 1;
min-height: 0;
box-sizing: border-box;
} }
.topic-hint { .hint {
display: block; display: block;
margin-top: 12px; margin-top: 10px;
font-size: 12px; font-size: 12px;
} }
</style> </style>
+200 -90
View File
@@ -1,64 +1,48 @@
<template> <template>
<main class="creation-home"> <main class="creation-home">
<n-flex <header class="head">
justify="space-between"
align="flex-end"
style="margin-bottom: 32px"
>
<div> <div>
<n-flex align="center" :size="8"> <h2>自由创作</h2>
<n-button text @click="$router.push({ name: 'home' })"> <p class="sub">自己定一个主题,和 AI 一起把它做出来。不用交,也不打分</p>
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
<n-h2 style="margin: 0">自由创作</n-h2>
</n-flex>
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
</div> </div>
<n-button type="primary" @click="openCreate"> <n-button type="primary" @click="openCreate()">
<template #icon> <template #icon><Icon icon="lucide:plus" /></template>
<Icon icon="lucide:plus" :width="16" />
</template>
新建主题 新建主题
</n-button> </n-button>
</n-flex> </header>
<n-spin :show="loading"> <n-spin :show="loading">
<n-empty <div v-if="!loading && !creations.length" class="empty">
v-if="!loading && !creations.length" <p>还没有创作。不知道做什么?从这些开始:</p>
description="还没有创作,点右上角新建一个主题吧" <div class="ideas">
style="margin-top: 72px" <button v-for="idea in IDEAS" :key="idea.title" class="idea" @click="openCreate(idea)">
/> <b>{{ idea.title }}</b>
<div v-else class="card-grid"> <span>{{ idea.content }}</span>
<n-card </button>
v-for="item in creations" </div>
: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> </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-spin>
<n-modal <n-modal
@@ -70,6 +54,7 @@
<n-form> <n-form>
<n-form-item label="主题名称"> <n-form-item label="主题名称">
<n-input <n-input
ref="titleRef"
v-model:value="form.title" v-model:value="form.title"
placeholder="例如:我的番茄钟" placeholder="例如:我的番茄钟"
maxlength="100" maxlength="100"
@@ -103,22 +88,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from "vue" import { nextTick, onMounted, reactive, ref } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue" import { Icon } from "../icons"
import { Creation } from "../api" import { Creation } from "../api"
import type { CreationSlim } from "../utils/type" import type { CreationSlim } from "../utils/type"
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const loading = ref(true) const loading = ref(true)
const creating = ref(false) const creating = ref(false)
const showCreate = ref(false) const showCreate = ref(false)
const creations = ref<CreationSlim[]>([]) const creations = ref<CreationSlim[]>([])
const form = reactive({ title: "", content: "" }) 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) { function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { return new Date(value).toLocaleString("zh-CN", {
@@ -129,14 +122,11 @@ function formatTime(value: string) {
}) })
} }
function open(item: CreationSlim) { function openCreate(idea?: { title: string; content: string }) {
router.push({ name: "creation", params: { display: item.display } }) form.title = idea?.title ?? ""
} form.content = idea?.content ?? ""
function openCreate() {
form.title = ""
form.content = ""
showCreate.value = true showCreate.value = true
nextTick(() => titleRef.value?.focus())
} }
async function submit() { async function submit() {
@@ -154,24 +144,14 @@ async function submit() {
} }
} }
function remove(item: CreationSlim) { async function remove(item: CreationSlim) {
dialog.warning({ try {
title: "删除主题", await Creation.remove(item.display)
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`, creations.value = creations.value.filter((c) => c.display !== item.display)
positiveText: "删除", message.success("已删除")
negativeText: "取消", } catch {
onPositiveClick: async () => { message.error("删除失败,请重试")
try { }
await Creation.remove(item.display)
creations.value = creations.value.filter(
(c) => c.display !== item.display,
)
message.success("已删除")
} catch {
message.error("删除失败,请重试")
}
},
})
} }
onMounted(async () => { onMounted(async () => {
@@ -185,23 +165,153 @@ onMounted(async () => {
<style scoped> <style scoped>
.creation-home { .creation-home {
max-width: 1180px; max-width: 880px;
margin: 0 auto; margin: 0 auto;
padding: 32px 20px 48px; padding: 28px 20px 48px;
} }
.card-grid { .head {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); justify-content: space-between;
align-items: flex-end;
gap: 16px; 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; cursor: pointer;
} }
.creation-desc { .idea:hover {
font-size: 13px; border-color: var(--wp-brand);
color: #666; }
.idea b {
font-size: 14px;
}
.idea span {
font-size: 12px;
line-height: 1.6;
color: var(--wp-muted);
} }
</style> </style>
+114 -81
View File
@@ -1,126 +1,159 @@
<template> <template>
<n-flex class="container" :wrap="false"> <div class="teach">
<div class="sidebar"> <nav class="sidebar">
<div class="back-btn" @click="() => goHome($router, taskTab, step)"> <template v-for="group in groups" :key="group.label">
← 返回 <div class="group-label">{{ group.label }}</div>
</div> <router-link
<n-divider style="margin: 8px 0" /> v-for="item in group.items"
<div :key="item.label"
v-for="item in menu" :to="item.route"
:key="item.label" :class="['nav-item', { active: item.names.includes(String($route.name)) }]"
:class="['nav-item', { active: $route.name === item.route.name }]" >
@click="$router.push(item.route)" <Icon :icon="item.icon" :width="16" />
> <span>{{ item.label }}</span>
{{ item.label }} </router-link>
</div> </template>
</div> </nav>
<div class="content"> <div class="content">
<router-view></router-view> <router-view></router-view>
</div> </div>
</n-flex> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue" import { computed } from "vue"
import { taskTab } from "../store/task" import { Icon } from "../icons"
import { step } from "../store/tutorial" import { step } from "../store/tutorial"
import { roleAdmin, roleSuper } from "../store/user" import { roleSuper } from "../store/user"
import { goHome } from "../utils/helper"
const menu = computed(() => // admin 只能出挑战、看提交、管作品展;其余是超管的(和后端权限一致)
const groups = computed(() =>
[ [
{ {
label: "教程", label: "出题",
route: { name: "tutorial-editor", params: { display: step.value } }, items: [
show: roleSuper.value, {
label: "教程",
icon: "lucide:book-open",
route: { name: "tutorial-editor", params: { display: step.value } },
names: ["tutorial-editor"],
show: roleSuper.value,
},
{
label: "挑战",
icon: "lucide:swords",
route: { name: "challenge-editor", params: { display: 0 } },
names: ["challenge-editor"],
show: true,
},
],
}, },
{ {
label: "挑战", label: "班级",
route: { name: "challenge-editor", params: { display: 0 } }, items: [
show: roleAdmin.value || roleSuper.value, {
label: "批改",
icon: "lucide:check-circle",
route: { name: "review" },
names: ["review"],
show: true,
},
{
label: "成绩册",
icon: "lucide:graduation-cap",
route: { name: "gradebook" },
names: ["gradebook"],
show: roleSuper.value,
},
{
// 全站提交列表留给超管排查问题;平时批改用上面的“批改”
label: "全部提交",
icon: "lucide:list",
route: { name: "teach-submissions", params: { page: 1 } },
names: ["teach-submissions"],
show: roleSuper.value,
},
],
}, },
{ {
label: "用户", label: "管理",
route: { name: "user-manage", params: { page: 1 } }, items: [
show: roleSuper.value, {
label: "作品展",
icon: "lucide:award",
route: { name: "showcase-manage" },
names: ["showcase-manage"],
show: true,
},
{
label: "用户",
icon: "lucide:users",
route: { name: "user-manage", params: { page: 1 } },
names: ["user-manage"],
show: roleSuper.value,
},
],
}, },
{ ]
label: "工坊", .map((g) => ({ ...g, items: g.items.filter((i) => i.show) }))
route: { name: "showcase-manage" }, .filter((g) => g.items.length),
show: roleSuper.value,
},
{
label: "成绩",
route: { name: "gradebook" },
show: roleSuper.value,
},
{
label: "提交",
route: { name: "submissions", params: { page: 1 } },
show: roleAdmin.value || roleSuper.value,
},
].filter((item) => item.show),
) )
</script> </script>
<style scoped> <style scoped>
.container { .teach {
height: 100vh; height: 100%;
width: 100vw; display: flex;
box-sizing: border-box;
} }
.sidebar { .sidebar {
width: 110px; width: 148px;
min-width: 110px; flex: none;
padding: 12px 8px; padding: 8px;
border-right: 1px solid #efeff5; border-right: 1px solid var(--wp-line);
background: #fafafa; background: var(--wp-surface);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
overflow-y: auto;
} }
.back-btn { .group-label {
padding: 7px 10px; padding: 12px 10px 4px;
font-size: 13px; font-size: 12px;
color: #888; color: var(--wp-faint);
cursor: pointer; letter-spacing: 0.04em;
border-radius: 6px;
transition:
background-color 0.15s,
color 0.15s;
user-select: none;
}
.back-btn:hover {
background-color: #f0f0f0;
color: #444;
} }
.nav-item { .nav-item {
padding: 8px 10px; display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
cursor: pointer; color: var(--wp-muted);
color: #444; text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
user-select: none;
} }
.nav-item:hover { .nav-item:hover {
background-color: #f0f0f0; background: var(--wp-paper);
color: var(--wp-ink);
} }
.nav-item.active { .nav-item.active {
background-color: #e8f8f0; background: var(--wp-brand-soft);
color: #18a058; color: var(--wp-brand);
font-weight: 500; font-weight: 600;
}
.nav-item:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 1px;
} }
.content { .content {
flex: 1; flex: 1;
box-sizing: border-box; min-width: 0;
overflow: hidden; overflow: hidden;
background: var(--wp-surface);
} }
</style> </style>
+405 -258
View File
@@ -1,102 +1,125 @@
<template> <template>
<n-flex <div class="gradebook">
vertical <div class="toolbar">
:size="12" <n-select
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;" v-model:value="query.classname"
> class="class-select"
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;"> :options="classOptions"
<n-flex align="center" :size="8" wrap style="min-width: 0;"> placeholder="班级"
<n-select size="small"
v-model:value="query.classname" :loading="classesLoading"
class="class-select" />
:options="classOptions" <n-radio-group v-model:value="query.task_type" size="small">
placeholder="班级" <n-radio-button value="">全部</n-radio-button>
:loading="classesLoading" <n-radio-button value="tutorial">教程</n-radio-button>
/> <n-radio-button value="challenge">挑战</n-radio-button>
<n-select </n-radio-group>
v-model:value="query.task_type" <n-input
class="type-select" v-model:value="query.username"
:options="taskTypeOptions" class="search-input"
/> size="small"
<n-input clearable
v-model:value="query.username" placeholder="搜索学生"
class="search-input" >
clearable <template #prefix><Icon icon="lucide:search" :width="14" /></template>
placeholder="学生搜索" </n-input>
/> <n-checkbox v-model:checked="query.include_all_tasks" size="small">
<n-switch v-model:value="query.include_all_tasks"> 显示不计入的任务
<template #checked>全部有提交任务</template> </n-checkbox>
<template #unchecked>只看计入任务</template>
</n-switch> <span class="spacer"></span>
</n-flex>
<n-flex align="center" :size="8"> <span v-if="gradebook" class="summary">
<n-button {{ gradebook.student_count }} 人 · 计入 {{ gradebook.included_task_count }} 个任务
secondary </span>
title="刷新" <n-popover trigger="click" placement="bottom-end" style="max-width: 340px">
:disabled="!query.classname" <template #trigger>
:loading="loading" <n-button size="small" quaternary>
@click="loadGradebook" <template #icon><Icon icon="lucide:info" /></template>
> 怎么算的
<template #icon> </n-button>
<Icon icon="lucide:refresh-cw" :width="15" /> </template>
</template> <div class="rules">
</n-button> <p>
<n-button <b>计入的任务</b>:班里至少一半人交过的任务才计入(现在是
type="primary" {{ gradebook?.coverage_threshold_count ?? "—" }} 人),灰色的列不计入。
secondary </p>
:disabled="!query.classname || !gradebook" <p><b>每格分数</b>:这个学生在这个任务里分数最高的一次提交,点开可以批改。</p>
:loading="exporting" <p>
@click="exportCsv" <b>总分 / 平时等级</b>:计入任务的分数相加后排名,A 30%、B 40%、C 20%、D 5%、E 5%。
> </p>
<template #icon> <p>
<Icon icon="lucide:download" :width="15" /> <b>考核</b>:计入任务里最高三次的平均分另外排名,A 10%、B 20%、C 40%、D 20%、E 10%;
</template> 分数在等级的分数段内按名次铺开(A 92–99、B 84–91、C 76–83、D 68–75、E 60–67)。
导出 CSV </p>
</n-button> </div>
</n-flex> </n-popover>
</n-flex> <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 = ''"> <n-alert v-if="loadError" type="error" closable @close="loadError = ''">
{{ loadError }} {{ loadError }}
</n-alert> </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 <n-data-table
class="table gradebook-table"
size="small" size="small"
striped
flex-height flex-height
:single-line="false"
:loading="loading" :loading="loading"
:columns="columns" :columns="columns"
:data="rows" :data="rows"
:row-key="(row: GradebookRow) => row.user_id" :row-key="(row: GradebookRow) => row.user_id"
:scroll-x="scrollX" :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"
@changed="loadGradebook"
/>
</n-drawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ReviewPanel from "../components/review/ReviewPanel.vue"
import { computed, h, onMounted, reactive, ref, watch } from "vue" import { computed, h, onMounted, reactive, ref, watch } from "vue"
import { Icon } from "@iconify/vue" import { Icon } from "../icons"
import { watchDebounced } from "@vueuse/core" import { watchDebounced } from "@vueuse/core"
import { import { useMessage, type DataTableColumn } from "naive-ui"
NButton,
NTag,
NText,
useMessage,
type DataTableColumn,
} from "naive-ui"
import { useRouter } from "vue-router"
import { Account, Gradebook } from "../api" import { Account, Gradebook } from "../api"
import { displayGradebookStudentName } from "../utils/gradebook" import { displayGradebookStudentName } from "../utils/gradebook"
import type { import type {
@@ -105,10 +128,11 @@ import type {
GradebookQuery, GradebookQuery,
GradebookRow, GradebookRow,
GradebookTask, GradebookTask,
GradebookTaskType,
} from "../utils/type" } from "../utils/type"
const router = useRouter() // 和批改页共用“当前班级”(那边的“全部班级”在这里不适用)
const CLASS_KEY = "review-class"
const message = useMessage() const message = useMessage()
const classesLoading = ref(false) const classesLoading = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -124,183 +148,155 @@ const query = reactive<GradebookQuery>({
include_all_tasks: false, include_all_tasks: false,
}) })
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
{ label: "全部", value: "" },
{ label: "教程", value: "tutorial" },
{ label: "挑战", value: "challenge" },
]
const classOptions = computed(() => 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 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) { function formatScore(value: number | null) {
if (value === null) return "-" if (value === null) return "—"
return Number.isInteger(value) ? String(value) : value.toFixed(2) return Number.isInteger(value) ? String(value) : value.toFixed(2)
} }
function taskTitle(task: GradebookTask) { // 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战" const reviewId = ref<string | null>(null)
return `${typeLabel}${task.display}`
}
function openSubmission(cell: GradebookCell) { function openSubmission(cell: GradebookCell) {
if (!cell.submitted || !cell.submission_id) return if (!cell.submitted || !cell.submission_id) return
const { href } = router.resolve({ reviewId.value = cell.submission_id
name: "submission",
params: { id: cell.submission_id },
})
window.open(href, "_blank")
} }
function gradeTagType(grade: string) { function renderGrade(grade: string, extra?: string | number) {
if (grade === "A") return "success" return h("span", { class: ["grade", `g-${grade}`] }, [
if (grade === "B") return "info" grade,
if (grade === "C") return "default" extra === undefined ? null : h("small", String(extra)),
return "warning" ])
} }
function renderTaskHeader(task: GradebookTask) { function renderTaskHeader(task: GradebookTask) {
return h("div", { class: ["task-header", { muted: !task.included }] }, [ const pct = Math.round(task.coverage * 100)
h("div", { class: "task-title", title: task.title }, taskTitle(task)), const studentCount = gradebook.value?.student_count ?? 0
h("div", { class: "task-meta" }, [ return h(
h("span", `${Math.round(task.coverage * 100)}%`), "div",
task.included {
? null class: ["task-head", { excluded: !task.included }],
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }), 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) { function renderScore(row: GradebookRow, task: GradebookTask) {
const cell = row.scores[task.id] const cell = row.scores[task.id]
if (!cell || !cell.submitted) { if (!cell?.submitted)
return h( return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺")
NText,
{ class: "missing-cell", type: "error" },
{ default: () => "缺交" },
)
}
return h( return h(
NButton, "button",
{ {
text: true, class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }],
type: task.included ? "primary" : "default", title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`,
class: ["score-link", { muted: !task.included }], onClick: () => openSubmission(cell),
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(cell)
},
}, },
{ default: () => formatScore(cell.score) }, cell.score === 0 ? "未评" : cell.score.toFixed(1),
) )
} }
function renderMissingCount(value: number) { function taskColumn(task: GradebookTask): DataTableColumn<GradebookRow> {
if (value <= 0) return "0" return {
return h(NText, { type: "error" }, { default: () => String(value) }) 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 columns = computed<DataTableColumn<GradebookRow>[]>(() => {
const tasks = gradebook.value?.tasks ?? [] 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 [ return [
{ {
title: "排名", title: "#",
key: "rank", key: "rank",
width: 60, width: 40,
fixed: "left", fixed: "left",
}, align: "right",
{ sorter: (a, b) => a.rank - b.rank,
title: "平时等级", render: (row) => h("span", { class: "rank" }, row.rank),
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,
}, },
{ {
title: "学生", title: "学生",
key: "username", key: "username",
width: 80, width: 92,
fixed: "left", fixed: "left",
render: (row) => { ellipsis: { tooltip: true },
const studentName = displayGradebookStudentName(row) render: (row) => displayGradebookStudentName(row),
return h(NText, { title: studentName }, { default: () => studentName })
},
}, },
...tasks.map((task) => ({ {
title: () => renderTaskHeader(task), title: "平时",
key: `task-${task.id}`, key: "grade",
width: 96, 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, align: "center" as const,
className: task.included ? "" : "excluded-task-column", children: g.items.map(taskColumn),
render: (row: GradebookRow) => renderScore(row, task),
})), })),
{
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: "总分", title: "总分",
key: "total_score", key: "total_score",
width: 82, width: 72,
fixed: "right", fixed: "right",
render: (row) => formatScore(row.total_score), align: "right",
}, sorter: (a, b) => a.total_score - b.total_score,
{ render: (row) =>
title: "平均", h(
key: "average_score", "span",
width: 82, {
fixed: "right", class: "total",
render: (row) => formatScore(row.average_score), title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`,
},
formatScore(row.total_score),
),
}, },
{ {
title: "已交", title: "已交",
key: "submitted_task_count", key: "submitted_task_count",
width: 70, width: 64,
fixed: "right", fixed: "right",
}, align: "right",
{ sorter: (a, b) => a.missing_task_count - b.missing_task_count,
title: "缺交", render: (row) =>
key: "missing_task_count", h(
width: 70, "span",
fixed: "right", {
render: (row) => renderMissingCount(row.missing_task_count), 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}`,
),
}, },
] ]
}) })
@@ -310,27 +306,39 @@ async function loadClasses() {
try { try {
classes.value = await Account.listClasses() classes.value = await Account.listClasses()
if (!query.classname && classes.value.length > 0) { 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 { } finally {
classesLoading.value = false classesLoading.value = false
} }
} }
// 快速切换班级时只用最后一次请求的结果
let loadSeq = 0
async function loadGradebook() { async function loadGradebook() {
const seq = ++loadSeq
if (!query.classname) { if (!query.classname) {
gradebook.value = null gradebook.value = null
loading.value = false
return return
} }
loading.value = true loading.value = true
loadError.value = "" loadError.value = ""
try { try {
gradebook.value = await Gradebook.get(query) const data = await Gradebook.get(query)
classes.value = gradebook.value.classes if (seq !== loadSeq) return
gradebook.value = data
classes.value = data.classes
} catch (err: any) { } catch (err: any) {
if (seq !== loadSeq) return
loadError.value = err.response?.data?.detail ?? "成绩册加载失败" loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
} finally { } finally {
loading.value = false if (seq === loadSeq) loading.value = false
} }
} }
@@ -346,6 +354,16 @@ async function exportCsv() {
} }
} }
watch(
() => query.classname,
(c) => {
if (!c) return
try {
localStorage.setItem(CLASS_KEY, c)
} catch {}
},
)
watch( watch(
() => [query.classname, query.task_type, query.include_all_tasks], () => [query.classname, query.task_type, query.include_all_tasks],
() => loadGradebook(), () => loadGradebook(),
@@ -358,73 +376,202 @@ watchDebounced(
) )
onMounted(async () => { onMounted(async () => {
const before = query.classname
await loadClasses() await loadClasses()
await loadGradebook() // loadClasses 选了默认班级时,上面的 watch 已经会加载
if (query.classname === before) loadGradebook()
}) })
</script> </script>
<style scoped> <style scoped>
.class-select { .gradebook {
width: 150px; height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
box-sizing: border-box;
overflow: hidden;
} }
.type-select { .toolbar {
width: 112px; flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
.class-select {
width: 120px;
} }
.search-input { .search-input {
width: 160px; width: 150px;
} }
.task-header { .spacer {
min-width: 0; flex: 1;
line-height: 1.2;
} }
.task-header.muted { .summary {
color: #888;
}
.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: #999;
font-size: 11px;
}
.missing-cell {
color: #d03050;
font-size: 12px; font-size: 12px;
color: var(--wp-muted);
} }
.score-link.muted { .rules {
color: #777; font-size: 13px;
line-height: 1.6;
} }
:deep(.excluded-task-column) { .rules p {
background: #fafafa; margin: 0 0 6px;
} }
@media (max-width: 720px) { .rules p:last-child {
.toolbar { margin: 0;
align-items: flex-start; }
}
.class-select, .table {
.type-select, flex: 1;
.search-input { min-height: 0;
width: 100%; }
}
.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);
}
.gradebook-table .rank {
font: 12px var(--wp-font-mono);
color: var(--wp-faint);
}
.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> </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>
+682
View File
@@ -0,0 +1,682 @@
<template>
<div class="review">
<aside v-show="!fullDetail" class="tasks">
<n-select
v-model:value="classname"
:options="classOptions"
placeholder="选择班级"
size="small"
/>
<div v-if="classname" class="task-list">
<template v-for="group in taskGroups" :key="group.label">
<div class="group-label">{{ group.label }}</div>
<button
v-for="t in group.items"
:key="t.id"
:class="['task', { on: t.id === taskId }]"
@click="taskId = t.id"
>
<span class="task-title">{{ t.display }}. {{ t.title }}</span>
<span class="task-count">{{ progressOf(t.id) }}</span>
<span class="bar"><span :style="{ width: percentOf(t.id) + '%' }"></span></span>
</button>
</template>
</div>
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
</aside>
<main v-show="!fullDetail" class="wall">
<template v-if="task && classname">
<div class="wall-head">
<div class="wall-title">
<b>{{ task.display }}. {{ task.title }}</b>
<span class="chip">已交 {{ submittedCount }} / {{ students.length }}</span>
<span v-if="unratedCount" class="chip warn">我未评 {{ unratedCount }}</span>
</div>
<n-flex :size="8" align="center" :wrap="false">
<n-radio-group v-model:value="filter" size="small">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="unrated">我未评</n-radio-button>
<n-radio-button value="missing">未交</n-radio-button>
<n-radio-button value="present">课上展示</n-radio-button>
</n-radio-group>
<n-button
type="primary"
size="small"
:disabled="!presentList.length"
@click="startPresent"
>
开始讲评({{ 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">
<Icon icon="lucide:refresh-cw" />
</n-button>
</template>
刷新
</n-tooltip>
</n-flex>
</div>
<n-spin :show="loadingWall" class="grid-wrap">
<template v-for="g in groups" :key="g.name">
<div v-if="g.name" class="group-head">
<b>{{ g.name }} 班</b>
<span>已交 {{ g.submitted }} / {{ g.total }}</span>
<span v-if="g.collapsed">· 这个班还没有人交,已收起</span>
</div>
<div v-if="!g.collapsed" class="grid">
<button
v-for="s in g.items"
:key="s.user_id"
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
:disabled="!s.submission"
@click="s.submission && openDetail(s.submission.id)"
>
<div class="thumb">
<iframe
v-if="s.submission"
:key="s.submission.id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="docOf(s)"
loading="lazy"
tabindex="-1"
></iframe>
<span v-else>未交</span>
</div>
<div class="cap">
<b>{{ s.username }}</b>
<span v-if="!s.submission" class="state grey">未交</span>
<span v-else-if="!s.submission.my_score" class="state warn">我未评</span>
<span v-else class="state mono">{{ s.submission.my_score }} 分</span>
</div>
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
</button>
</div>
</template>
<n-empty
v-if="!loadingWall && !visible.length"
description="没有符合条件的学生"
style="margin-top: 60px"
/>
</n-spin>
</template>
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
</main>
<aside v-if="selectedId" :class="['detail', { full: fullDetail }]">
<ReviewPanel
:submission-id="selectedId"
:wide="fullDetail"
can-widen
@toggle-wide="bigPreview = !bigPreview"
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
@prev="move(-1)"
@next="move(1)"
@close="closeDetail"
@changed="applyPatch"
/>
</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>
<span>{{ presentIndex + 1 }} / {{ presentList.length }} · ← → 切换,Esc 退出</span>
<button class="exit" @click="presenting = false">退出讲评</button>
</div>
<iframe
v-if="presentList[presentIndex]"
:sandbox="PREVIEW_SANDBOX"
:key="presentIndex"
:srcdoc="docOf(presentList[presentIndex]!)"
></iframe>
</div>
</template>
<script lang="ts" setup>
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"
// 老师批改:选班级、选任务,看全班的作品墙,点开右侧详情打分
const route = useRoute()
const router = useRouter()
const options = ref<ReviewOptions>({ classes: [], tasks: [] })
const progress = ref<ReviewProgress | null>(null)
const students = ref<ReviewStudent[]>([])
const loadingWall = ref(false)
const classname = ref<string | null>((route.query.class as string) || null)
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
// 打开的作品放在地址栏(?sub=),浏览器“后退”就能回到作品墙
const selectedId = ref<string | null>((route.query.sub as string) || null)
watch(
() => route.query.sub,
(v) => {
selectedId.value = (v as string) || null
},
)
// 是不是从作品墙点开的(点开时记了一步历史,关闭时退回那一步)
let pushedDetail = false
function openDetail(id: string) {
pushedDetail = true
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
router.back()
} else {
const { sub: _, ...rest } = route.query
router.replace({ query: rest })
}
}
const presenting = ref(false)
// 点开作品默认用大图(预览占满中间),老师也可以切回旁边的小面板;记住选择
const bigPreview = useStorage("review-big-preview", true)
const fullDetail = computed(() => !!selectedId.value && bigPreview.value)
const presentIndex = ref(0)
// 全部班级:一道题全校的作品放在一面墙上,按班级分组
const ALL = "__all__"
const classOptions = computed(() => [
{ label: "全部班级", value: ALL },
...options.value.classes.map((c) => ({ label: `${c} 班`, value: c })),
])
const classParam = () => (classname.value === ALL ? undefined : classname.value ?? undefined)
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
const taskGroups = computed(() => [
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
{ label: "挑战", items: options.value.tasks.filter((t) => t.task_type === "challenge") },
])
const progressOf = (id: number) =>
progress.value ? `${progress.value.submitted[id] ?? 0}/${progress.value.student_count}` : ""
const percentOf = (id: number) =>
progress.value?.student_count
? Math.round(((progress.value.submitted[id] ?? 0) / progress.value.student_count) * 100)
: 0
const submittedCount = computed(() => students.value.filter((s) => s.submission).length)
const unratedCount = computed(
() => students.value.filter((s) => s.submission && !s.submission.my_score).length,
)
const visible = computed(() =>
students.value.filter((s) => {
if (filter.value === "unrated") return s.submission && !s.submission.my_score
if (filter.value === "missing") return !s.submission
if (filter.value === "present") return s.submission?.flag === "present"
return true
}),
)
const groups = computed(() => {
if (classname.value !== ALL) {
return [{ name: "", items: visible.value, submitted: 0, total: 0, collapsed: false }]
}
const map = new Map<string, ReviewStudent[]>()
for (const s of visible.value) {
if (!map.has(s.classname)) map.set(s.classname, [])
map.get(s.classname)!.push(s)
}
return [...map].map(([name, items]) => {
const all = students.value.filter((s) => s.classname === name)
const submitted = all.filter((s) => s.submission).length
// 全校一起看时,没人交的班(这道题没布置给它)只留一行标题,不然一屏都是“未交”
return { name, items, submitted, total: all.length, collapsed: !submitted && filter.value !== "missing" }
})
})
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
const docs = new Map<string, string>()
function docOf(s: ReviewStudent) {
const sub = s.submission!
let d = docs.get(sub.id)
if (!d) {
const t = task.value
d = buildPreviewDocument({
html: sub.html,
css: sub.css,
js: sub.js,
// 学生代码里用相对路径引用题目素材
assetBaseUrl: t ? `/media/tasks/${t.task_type}/${t.display}/` : undefined,
})
docs.set(sub.id, d)
}
return d
}
let wallSeq = 0
async function loadWall() {
if (!classname.value || !taskId.value) return
const s = ++wallSeq
loadingWall.value = true
try {
const data = await Review.classWall(classParam(), taskId.value)
if (s === wallSeq) students.value = data
} finally {
if (s === wallSeq) loadingWall.value = false
}
}
async function loadProgress() {
progress.value = null
if (classname.value) progress.value = await Review.progress(classParam())
}
watch(classname, (c) => {
if (c) localStorage.setItem("review-class", c)
loadProgress()
})
watch([classname, taskId], () => {
router.replace({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined } })
pushedDetail = false
filter.value = "all"
students.value = []
loadWall()
})
// 作品墙里有提交的学生,按当前筛选的顺序切换
function neighbor(step: number): string | null {
const list = visible.value.filter((s) => s.submission)
const i = list.findIndex((s) => s.submission!.id === selectedId.value)
return list[i + step]?.submission?.id ?? null
}
// 切换学生不新增历史,免得要连按很多次后退
function move(step: number) {
const id = neighbor(step)
if (id) router.replace({ query: { ...route.query, sub: id } })
}
function applyPatch(p: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
const s = students.value.find((x) => x.submission?.id === p.id)?.submission
if (!s) return
if (p.my_score !== undefined) s.my_score = p.my_score
if (p.score !== undefined) s.score = p.score
if (p.flag !== undefined) s.flag = p.flag
}
function startPresent() {
presentIndex.value = 0
presenting.value = true
}
function onKey(e: KeyboardEvent) {
if (presenting.value) {
if (e.key === "Escape") presenting.value = false
else if (e.key === "ArrowRight") presentIndex.value = Math.min(presentIndex.value + 1, presentList.value.length - 1)
else if (e.key === "ArrowLeft") presentIndex.value = Math.max(presentIndex.value - 1, 0)
return
}
const t = e.target as HTMLElement
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === "Escape" && selectedId.value) closeDetail()
// 按键盘上的位置:J 在左边是上一个,K 在右边是下一个(和面板上的左右箭头一致)
else if (e.key === "j" || e.key === "J") move(-1)
else if (e.key === "k" || e.key === "K") move(1)
}
onMounted(async () => {
window.addEventListener("keydown", onKey)
options.value = await Review.options()
if (!classname.value) {
const saved = localStorage.getItem("review-class")
classname.value =
saved && (saved === ALL || options.value.classes.includes(saved)) ? saved : null
} else {
loadProgress()
loadWall()
}
})
onUnmounted(() => window.removeEventListener("keydown", onKey))
</script>
<style scoped>
.review {
height: 100%;
display: flex;
min-width: 0;
}
.tasks {
width: 232px;
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-right: 1px solid var(--wp-line);
background: var(--wp-surface);
min-height: 0;
}
.task-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-label {
padding: 8px 6px 2px;
font-size: 12px;
color: var(--wp-faint);
}
.task {
display: grid;
grid-template-columns: 1fr auto;
gap: 2px 8px;
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-count {
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.bar {
grid-column: 1 / -1;
height: 3px;
border-radius: 2px;
background: var(--wp-line);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: var(--wp-brand);
}
.wall {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--wp-paper);
}
.wall-head {
flex: none;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--wp-line);
background: var(--wp-surface);
flex-wrap: wrap;
}
.wall-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.chip {
padding: 1px 8px;
border-radius: 999px;
background: var(--wp-brand-soft);
color: var(--wp-brand);
font-size: 12px;
white-space: nowrap;
}
.chip.warn {
background: var(--wp-warn-soft);
color: var(--wp-warn);
}
.grid-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.group-head {
display: flex;
align-items: baseline;
gap: 10px;
padding: 14px 14px 0;
}
.group-head span {
font-size: 12px;
color: var(--wp-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 12px;
padding: 14px;
}
.card {
position: relative;
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-surface);
overflow: hidden;
text-align: left;
font: inherit;
color: var(--wp-ink);
cursor: pointer;
}
.card:hover:not(:disabled) {
border-color: var(--wp-brand);
}
.card.sel {
outline: 2px solid var(--wp-brand);
outline-offset: -1px;
}
.card:disabled {
cursor: default;
}
.card:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: 1px;
}
.thumb {
height: 100px;
position: relative;
overflow: hidden;
background: var(--wp-surface);
display: grid;
place-items: center;
color: var(--wp-faint);
font-size: 12px;
}
.missing .thumb {
background: var(--wp-paper);
}
/* 按 4 倍大小渲染再缩小 */
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 400%;
height: 400%;
border: 0;
transform: scale(0.25);
transform-origin: 0 0;
pointer-events: none;
}
.cap {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
padding: 6px 8px;
font-size: 13px;
}
.cap b {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.state {
flex: none;
font-size: 11px;
padding: 0 6px;
border-radius: 999px;
}
.state.warn {
background: var(--wp-warn-soft);
color: var(--wp-warn);
}
.state.grey {
background: var(--wp-paper);
color: var(--wp-muted);
}
.state.mono {
font-family: var(--wp-font-mono);
color: var(--wp-brand);
}
.present {
position: absolute;
top: 6px;
right: 6px;
padding: 0 6px;
border-radius: 4px;
background: var(--wp-brand);
color: var(--wp-surface);
font-size: 11px;
}
.detail {
width: 380px;
flex: none;
border-left: 1px solid var(--wp-line);
min-height: 0;
}
.detail.full {
flex: 1;
width: auto;
border-left: 0;
}
.present-mode {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
flex-direction: column;
background: var(--wp-ink);
}
.present-head {
height: 48px;
flex: none;
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
color: var(--wp-surface);
}
.present-head span {
flex: 1;
opacity: 0.7;
font-size: 13px;
}
.exit {
padding: 4px 12px;
border: 1px solid var(--wp-faint);
border-radius: 6px;
background: transparent;
color: var(--wp-surface);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.exit:hover {
border-color: var(--wp-surface);
}
.present-mode iframe {
flex: 1;
border: 0;
}
</style>
+381 -91
View File
@@ -1,165 +1,455 @@
<template> <template>
<main class="showcase"> <main class="showcase">
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;"> <header class="page-head">
<div> <div>
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2> <h2>作品展</h2>
<n-text depth="3">优秀作品展示</n-text> <p class="muted">老师从大家交的作品里挑出来的,点开能看大图和它是怎么问出来的</p>
</div> </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-spin :show="loading">
<n-empty <n-empty
v-if="!loading && awards.length === 0" v-if="!loading && !awards.length"
description="暂无展示作品" description="还没有展出的作品"
style="margin-top: 72px;" style="margin-top: 72px"
/> />
<section <section v-for="(section, si) in awards" :id="`award-${section.id}`" :key="section.id">
v-for="section in awards" <div class="section-head">
:key="section.id" <h3>{{ section.name }}</h3>
style="margin-bottom: 48px;" <span class="muted">{{ section.items.length }} 件</span>
> </div>
<n-flex vertical :size="4" style="margin-bottom: 16px;"> <p v-if="section.description" class="desc">{{ section.description }}</p>
<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>
<div class="card-grid"> <div class="grid">
<n-card <button
v-for="item in section.items" v-for="(item, ii) in section.items"
:key="item.submission_id" :key="item.submission_id"
class="work-card" class="card"
content-style="padding: 0;" @click="open(si, ii)"
hoverable
@click="openDetail(item)"
> >
<div class="card-preview"> <div class="thumb">
<iframe <iframe
:srcdoc="buildSrcdoc(item)" :srcdoc="docOf(item)"
sandbox="allow-scripts" :sandbox="PREVIEW_SANDBOX"
loading="lazy"
scrolling="no" scrolling="no"
class="preview-iframe" tabindex="-1"
/> ></iframe>
<div class="preview-overlay" />
</div> </div>
<div class="card-info"> <div class="cap">
<n-flex justify="space-between" align="center" :wrap="false"> <div class="who">
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;"> <b>{{ item.username }}</b>
{{ item.username }} <span v-if="item.score" class="score">
</n-ellipsis> <Icon icon="lucide:star" :width="12" />{{ item.score.toFixed(1) }}
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;"> </span>
<n-flex align="center" :size="3"> </div>
<Icon icon="lucide:star" :width="13" /> <div class="task">{{ item.task_display }}. {{ item.task_title }}</div>
<n-text style="font-size: 12px; color: #666;">
{{ 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: #666;">
{{ 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: #888;"
>
{{ item.task_title }}
</n-ellipsis>
</div> </div>
</n-card> </button>
</div> </div>
</section> </section>
</n-spin> </n-spin>
</main> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue" import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { Icon } from "@iconify/vue" 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" import type { AwardSection, ShowcaseItem } from "../utils/type"
const router = useRouter() const router = useRouter()
const loading = ref(true) const loading = ref(true)
const awards = ref<AwardSection[]>([]) const awards = ref<AwardSection[]>([])
// 正在看第几个奖项的第几件
const viewing = ref<{ section: number; item: number } | null>(null)
const showChain = ref(false)
function buildSrcdoc(item: ShowcaseItem): string { const current = computed(() =>
const css = item.css ? `<style>${item.css}</style>` : "" viewing.value ? (awards.value[viewing.value.section]?.items[viewing.value.item] ?? null) : null,
const js = item.js ? `<script>${item.js}<\/script>` : "" )
return `<!DOCTYPE html><html><head><meta charset="utf-8"><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 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({ function awardsOf(submissionId: string) {
name: "submission", return awards.value
params: { id: item.submission_id }, .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") 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 { try {
awards.value = await Showcase.list() awards.value = await Showcase.list()
} finally { } finally {
loading.value = false loading.value = false
} }
} })
onUnmounted(() => window.removeEventListener("keydown", onKey))
onMounted(init)
</script> </script>
<style scoped> <style scoped>
.showcase { .showcase {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; 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; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px; gap: 16px;
} }
.work-card { .card {
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 10px;
overflow: hidden; overflow: hidden;
background: var(--wp-surface);
color: var(--wp-ink);
font: inherit;
text-align: left;
cursor: pointer; 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); transform: translateY(-2px);
} }
.card-preview { .thumb {
position: relative; position: relative;
height: 160px; aspect-ratio: 16 / 10;
overflow: hidden; overflow: hidden;
background: #f7f8fa; border-bottom: 1px solid var(--wp-line);
} }
.preview-iframe { /* 按 2 倍大小渲染再缩小 */
.thumb iframe {
position: absolute;
top: 0;
left: 0;
width: 200%; width: 200%;
height: 200%; height: 200%;
border: none; border: 0;
transform: scale(0.5); transform: scale(0.5);
transform-origin: top left; transform-origin: 0 0;
pointer-events: none; pointer-events: none;
} }
.preview-overlay { .cap {
position: absolute; padding: 10px 12px 12px;
inset: 0;
} }
.card-info { .who {
min-height: 72px; display: flex;
padding: 10px 12px; align-items: center;
border-top: 1px solid #f0f0f0; 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> </style>
File diff suppressed because it is too large. Load diff
+9 -18
View File
@@ -1,39 +1,30 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, useTemplateRef } from "vue" import { onMounted, ref } from "vue"
import { Submission } from "../api" import { Submission } from "../api"
import type { SubmissionAll } from "../utils/type" import type { SubmissionAll } from "../utils/type"
import { buildPreviewDocument } from "../utils/previewDocument" import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
interface Props { interface Props {
id: string id: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const iframe = useTemplateRef<HTMLIFrameElement>("iframe") const content = ref("")
async function init() { async function init() {
const submission: SubmissionAll = await Submission.get(props.id) const submission: SubmissionAll = await Submission.get(props.id)
Submission.incrementView(props.id) Submission.incrementView(props.id)
content.value = buildPreviewDocument({
if (!iframe.value) return html: submission.html,
const doc = iframe.value.contentDocument css: submission.css,
if (doc) { js: submission.js,
doc.open() })
doc.write(
buildPreviewDocument({
html: submission.html,
css: submission.css,
js: submission.js,
}),
)
doc.close()
}
} }
onMounted(init) onMounted(init)
</script> </script>
<template> <template>
<iframe class="iframe" ref="iframe"></iframe> <iframe v-if="content" class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
</template> </template>
<style scoped> <style scoped>
.iframe { .iframe {
+231 -365
View File
@@ -1,433 +1,299 @@
<template> <template>
<n-split <div class="subs">
class="container" <main class="list">
direction="horizontal" <div class="toolbar">
:default-size="0.5" <n-input
:min="0.2" v-model:value="query.username"
:max="0.8" size="small"
> clearable
<template #1> placeholder="搜索学生"
<n-flex style="width: 150px"
vertical >
style="height: 100%; padding-right: 10px; overflow: hidden" <template #prefix><Icon icon="lucide:search" :width="14" /></template>
> </n-input>
<n-flex align="center" justify="end"> <n-radio-group v-model:value="query.task_type" size="small">
<n-select <n-radio-button value="">全部</n-radio-button>
:value="query.flag" <n-radio-button value="tutorial">教程</n-radio-button>
style="width: 100px" <n-radio-button value="challenge">挑战</n-radio-button>
clearable </n-radio-group>
placeholder="标记" <n-select
:options="flagFilterOptions" v-model:value="query.zone"
@update:value="handleFlagSelect" size="small"
/> clearable
<n-select placeholder="得分分区"
v-model:value="query.zone" :options="zoneOptions"
style="width: 100px" style="width: 110px"
clearable />
placeholder="从夯到拉" <n-select
:options="[ v-model:value="query.ordering"
{ label: '夯爆了', value: 'featured' }, size="small"
{ label: 'NPC', value: 'pending' }, :options="orderOptions"
{ label: '拉完了', value: 'low' }, style="width: 130px"
]" />
/> <n-checkbox v-model:checked="query.grouped" size="small">每人每题只看最新一次</n-checkbox>
<n-input <span class="spacer"></span>
style="width: 120px" <span class="muted">共 {{ count }} 条</span>
v-model:value="query.username" <n-tooltip>
clearable <template #trigger>
/> <n-button size="small" quaternary :loading="loading" @click="load">
<n-pagination <template #icon><Icon icon="lucide:refresh-cw" /></template>
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" />
</n-button> </n-button>
</n-flex> </template>
<n-data-table 刷新
flex-height </n-tooltip>
striped </div>
:columns="columns"
:data="data" <n-data-table
:row-key="(row: SubmissionOut) => row.id" class="table"
:expanded-row-keys="expandedKeys" size="small"
@update:expanded-row-keys="handleExpand" flex-height
:row-props="rowProps" :loading="loading"
:row-class-name="rowClassName" :columns="columns"
style="flex: 1; min-height: 0" :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> </n-flex>
</template> </main>
<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>
<CodeModal <aside v-if="selectedId" class="detail">
v-model:show="codeModal" <ReviewPanel
:html="html" :submission-id="selectedId"
:css="css" can-delete
:js="js" @close="selectedId = null"
@copy-to-editor="copyToEditor" @changed="applyPatch"
/> @deleted="onDeleted"
/>
<ChainModal </aside>
v-model:show="chainModal" </div>
:submission-id="chainSubmissionId"
:username="chainUsername"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue" import { h, onMounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui" import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Submission } from "../api"
import type { SubmissionOut, FlagType } from "../utils/type"
import { parseTime } from "../utils/helper"
import { goHome } from "../utils/helper"
import { TASK_TYPE } from "../utils/const"
import { watchDebounced } from "@vueuse/core" import { watchDebounced } from "@vueuse/core"
import { useRouter, useRoute } from "vue-router" import type { DataTableColumn } from "naive-ui"
import { Icon } from "../icons"
import { Submission } from "../api"
import ReviewPanel from "../components/review/ReviewPanel.vue"
import { TASK_TYPE } from "../utils/const"
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" const PAGE_SIZE = 20
import CodeModal from "../components/submissions/CodeModal.vue"
import ChainModal from "../components/submissions/ChainModal.vue"
import FlagCell from "../components/submissions/FlagCell.vue"
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
import { submission } from "../store/submission"
import { taskTab, challengeDisplay } from "../store/task"
import { step } from "../store/tutorial"
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
import { roleAdmin, roleSuper, user } from "../store/user"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage()
// 列表数据
const data = ref<SubmissionOut[]>([]) const data = ref<SubmissionOut[]>([])
const count = ref(0) const count = ref(0)
const loading = ref(false)
const selectedId = ref<string | null>(null)
const query = reactive({ const query = reactive({
page: Number(route.params.page), page: Number(route.params.page) || 1,
username: (Array.isArray(route.query.username) username: typeof route.query.username === "string" ? route.query.username : "",
? "" task_type: "" as "" | "tutorial" | "challenge",
: (route.query.username ?? "")) as string,
flag: null as string | null,
zone: null as string | null, zone: null as string | null,
ordering: "-created",
grouped: true,
}) })
// 当前选中提交的代码 // 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts)
const html = computed(() => submission.value.html) const ZONES: Record<string, string> = { featured: "优秀", pending: "待定", low: "待提高" }
const css = computed(() => submission.value.css) const zoneOptions = Object.entries(ZONES).map(([value, label]) => ({ label, value }))
const js = computed(() => submission.value.js) const orderOptions = [
{ label: "最新的在前", value: "-created" },
{ label: "得分从高到低", value: "-score" },
{ label: "得分从低到高", value: "score" },
]
// 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)
// Flag 过滤选项
const flagFilterOptions = computed(() => {
const opts = [
{ label: "红旗", value: "red" },
{ label: "蓝旗", value: "blue" },
{ label: "绿旗", value: "green" },
{ label: "黄旗", value: "yellow" },
{ label: "全部", value: "any" },
]
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
return opts
})
function handleFlagSelect(value: string | null) {
if (value === "_clear_all") {
clearAllFlags()
return
}
query.flag = value
}
async function updateFlag(row: SubmissionOut, flag: FlagType) {
await Submission.updateFlag(row.id, flag)
row.flag = flag
}
async function clearAllFlags() {
await Submission.clearAllFlags()
data.value = data.value.map((d) => ({ ...d, flag: null }))
query.flag = null
}
function showChain(submissionId: string, username: string) {
chainSubmissionId.value = submissionId
chainUsername.value = username
chainModal.value = true
}
// 表格列定义
const columns: DataTableColumn<SubmissionOut>[] = [ 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: "flag",
width: 50,
render: (row) =>
h(FlagCell, {
flag: row.flag ?? null,
isAdmin: isAdmin.value,
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
}),
},
{
title: "",
key: "zone",
width: 42,
render: (row) => {
const map: Record<
string,
{ label: string; type: "success" | "default" | "warning" }
> = {
featured: { label: "夯", type: "success" },
pending: { label: "N", 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: "时间", title: "时间",
key: "created", key: "created",
width: 110, width: 100,
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"), render: (row) => h("span", { class: "mono muted" }, parseTime(row.created, "MM-DD HH:mm")),
},
{
title: "提交者",
key: "user",
width: 80,
render: (row) => row.username,
}, },
{ title: "学生", key: "username", width: 120, ellipsis: { tooltip: true } },
{ {
title: "任务", title: "任务",
key: "task_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: "得分", title: "得分",
key: "score", key: "score",
width: 70, width: 70,
render: (row) => { align: "right",
const myScore = row.my_score > 0 ? String(row.my_score) : "-" render: (row) =>
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-" row.score > 0
return h( ? h("span", { class: "mono" }, row.score.toFixed(2))
"div", : h("span", { class: "muted" }, "未评"),
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
],
)
},
}, },
{ {
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", key: "submit_count",
width: 60, width: 60,
render: (row) => row.submit_count || "-", align: "right",
}, render: (row) => h("span", { class: "mono muted" }, `${row.submit_count} 次`),
{
title: "浏览",
key: "view_count",
width: 60,
render: (row) => row.view_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) { function rowProps(row: SubmissionOut) {
return { return {
style: { cursor: "pointer" }, style: { cursor: "pointer" },
onClick: () => { onClick: () => (selectedId.value = row.id),
getSubmissionByID(row.id)
handleExpand(
expandedKeys.value.includes(row.id)
? expandedKeys.value.filter((k) => k !== row.id)
: [...expandedKeys.value, row.id],
)
},
} }
} }
function rowClassName(row: SubmissionOut) { // 快速翻页时只用最后一次请求的结果
return submission.value.id === row.id ? "row-active" : "" let seq = 0
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
}
} }
async function init() { function applyPatch(patch: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
expandedKeys.value = [] const row = data.value.find((d) => d.id === patch.id)
expandedData.clear() if (row) Object.assign(row, patch)
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
} }
async function getSubmissionByID(id: string) { function onDeleted() {
submission.value = await Submission.get(id) selectedId.value = null
load()
} }
function afterScore() { // 换了筛选条件就回到第一页
data.value = data.value.map((d) => { function reset() {
if (d.id === submission.value.id) d.my_score = submission.value.my_score if (query.page !== 1) query.page = 1
return d else load()
})
}
function copyToEditor() {
eHtml.value = html.value
eCss.value = css.value
eJs.value = js.value
goHome(router, submission.value.task_type, submission.value.task_display)
} }
watch( watch(
() => query.page, () => query.page,
(page) => {
load()
router.push({ params: { page } })
},
)
// 浏览器前进 / 后退时,页码跟着地址栏走
watch(
() => route.params.page,
(v) => { (v) => {
init() const page = Number(v) || 1
router.push({ params: { page: v } }) if (page !== query.page) query.page = page
},
)
watchDebounced(
() => query.username,
() => {
query.page = 1
init()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.flag,
() => {
query.page = 1
init()
},
)
watch(
() => query.zone,
() => {
query.page = 1
init()
}, },
) )
watch(() => [query.task_type, query.zone, query.ordering, query.grouped], reset)
watchDebounced(() => query.username, reset, { debounce: 500, maxWait: 1000 })
onMounted(init) onMounted(load)
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(),
}
})
</script> </script>
<style scoped> <style scoped>
.container { .subs {
padding: 10px; height: 100%;
box-sizing: border-box; display: flex;
width: 100%; min-width: 0;
} }
:deep(.row-active td) { .list {
background-color: rgba(24, 160, 80, 0.1) !important; 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> </style>
+342
View File
@@ -0,0 +1,342 @@
<template>
<div class="editor-page">
<aside class="list">
<div class="list-head">
<b>{{ kindLabel }}</b>
<n-button size="small" @click="createNew">
<template #icon><Icon icon="lucide:plus" /></template>
新建
</n-button>
</div>
<div class="items">
<div
v-for="item in list"
:key="item.display"
:class="['item', { on: item.display === form.display, draft: !item.is_public }]"
@click="show(item.display)"
>
<n-tooltip>
<template #trigger>
<n-button text @click.stop="togglePublic(item.display)">
<Icon :icon="item.is_public ? 'lucide:globe' : 'lucide:lock'" :width="16" />
</n-button>
</template>
{{ item.is_public ? "已公开,点击设为不公开" : "未公开,点击公开" }}
</n-tooltip>
<span class="item-title">{{ item.display }}. {{ item.title }}</span>
<span v-if="kind === 'challenge'" class="item-score">{{ item.score }}分</span>
<n-tooltip>
<template #trigger>
<n-button text class="item-del" @click.stop="remove(item.display)">
<Icon icon="lucide:trash-2" :width="14" />
</n-button>
</template>
删除
</n-tooltip>
</div>
</div>
</aside>
<main class="form">
<n-form inline :show-feedback="false" class="fields">
<n-form-item label="序号" label-placement="left">
<n-input-number style="width: 96px" v-model:value="form.display" />
</n-form-item>
<n-form-item label="标题" label-placement="left">
<n-input v-model:value="form.title" style="width: 220px" />
</n-form-item>
<n-form-item v-if="kind === 'challenge'" label="分数" label-placement="left">
<n-input-number style="width: 96px" v-model:value="form.score" :min="0" />
</n-form-item>
<n-form-item label="公开" label-placement="left">
<n-switch v-model:value="form.is_public" />
</n-form-item>
<n-form-item v-if="kind === 'challenge'" label="出题人" label-placement="left">
<n-text depth="3">{{ form.author_name || "未设置" }}</n-text>
</n-form-item>
</n-form>
<n-flex align="center" class="actions">
<n-button type="primary" :disabled="!canSave" :loading="saving" @click="save">
保存
</n-button>
<TaskAssetManager v-if="form.display" :task-type="kind" :display="form.display" />
<n-button v-if="form.display" @click="showExampleModal = true">示例代码</n-button>
<n-text depth="3" class="hint">{{ exampleHint }}</n-text>
</n-flex>
<MarkdownEditor class="md" v-model="form.content" />
</main>
</div>
<n-modal
v-model:show="showExampleModal"
preset="card"
:title="`示例代码(${exampleHint})`"
style="width: 640px"
>
<n-input
type="textarea"
v-model:value="rawCode"
placeholder="粘贴完整的网页代码,会自动拆成 HTML / CSS / JS"
:autosize="{ minRows: 10, maxRows: 30 }"
/>
<n-flex v-if="splitResult" style="margin-top: 8px">
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
</n-flex>
<template #footer>
<n-text depth="3">关掉窗口后点“保存”才会存下来。</n-text>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui"
import { Icon } from "../icons"
import { Challenge, Tutorial } from "../api"
import { splitHtml, type Code } from "../utils/code"
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
// 教程和挑战的出题页(以前是两个几乎一样的文件)
const props = defineProps<{ kind: "tutorial" | "challenge" }>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const api = props.kind === "tutorial" ? Tutorial : Challenge
const kindLabel = props.kind === "tutorial" ? "教程" : "挑战"
const routeName = props.kind === "tutorial" ? "tutorial-editor" : "challenge-editor"
const exampleHint =
props.kind === "tutorial" ? "打开这一节教程时自动放进编辑器" : "学生点“放入示例代码”时使用"
interface ListItem {
display: number
title: string
is_public: boolean
score?: number
}
const list = ref<ListItem[]>([])
const saving = ref(false)
const showExampleModal = ref(false)
const rawCode = ref("")
const splitResult = ref<Code | null>(null)
const blank = () => ({
display: 0,
title: "",
content: "",
score: 0,
is_public: false,
author_name: "",
example_html: null as string | null,
example_css: null as string | null,
example_js: null as string | null,
})
const form = reactive(blank())
const canSave = computed(() => !!(form.display && form.title && form.content))
watch(rawCode, (val) => {
if (!val.trim()) {
splitResult.value = null
Object.assign(form, { example_html: null, example_css: null, example_js: null })
return
}
const split = splitHtml(val)
splitResult.value = split
form.example_html = split.html || null
form.example_css = split.css || null
form.example_js = split.js || null
})
watch(showExampleModal, (visible) => {
if (!visible) return
const parts: string[] = []
if (form.example_css) parts.push(`<style>\n${form.example_css}\n</style>`)
if (form.example_html) parts.push(form.example_html)
if (form.example_js) parts.push(`<script>\n${form.example_js}\n<\/script>`)
rawCode.value = parts.join("\n\n")
})
async function loadList() {
list.value = await api.list()
}
async function show(display: number) {
router.replace({ name: routeName, params: { display } })
const item: any = await api.get(display)
Object.assign(form, blank(), {
display: item.display,
title: item.title,
content: item.content,
score: item.score ?? 0,
is_public: item.is_public,
author_name: item.author_name ?? "",
example_html: item.example_html ?? null,
example_css: item.example_css ?? null,
example_js: item.example_js ?? null,
})
}
function createNew() {
// 列表按序号升序,新的接在最后
Object.assign(form, blank(), {
display: (list.value[list.value.length - 1]?.display ?? 0) + 1,
})
}
async function save() {
saving.value = true
try {
const res = await api.createOrUpdate(form as any)
message.success(res?.message ?? "已保存")
await loadList()
// 保存后留在这一项上,方便接着改
await show(form.display)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "保存失败")
} finally {
saving.value = false
}
}
function remove(display: number) {
const item = list.value.find((i) => i.display === display)
dialog.warning({
title: `删除${kindLabel}「${item?.title ?? display}」?`,
content: "学生在这个任务上的提交和对话也会一起删除,不能恢复。",
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await api.remove(display)
message.success("已删除")
await loadList()
if (form.display === display) {
if (list.value.length) show(list.value[0]!.display)
else createNew()
}
},
})
}
async function togglePublic(display: number) {
const data = await api.togglePublic(display)
message.success(data.message)
const item = list.value.find((i) => i.display === display)
if (item) item.is_public = !item.is_public
if (form.display === display) form.is_public = !form.is_public
}
onMounted(async () => {
await loadList()
const display = Number(route.params.display)
if (list.value.some((item) => item.display === display)) show(display)
else if (list.value.length) show(list.value[0]!.display)
else createNew()
})
</script>
<style scoped>
.editor-page {
height: 100%;
display: flex;
min-width: 0;
}
.list {
width: 280px;
flex: none;
display: flex;
flex-direction: column;
border-right: 1px solid var(--wp-line);
min-height: 0;
}
.list-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--wp-line);
}
.items {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px;
}
.item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.item:hover {
background: var(--wp-paper);
}
.item.on {
background: var(--wp-brand-soft);
color: var(--wp-brand);
}
.item.draft .item-title {
color: var(--wp-muted);
}
.item-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-score {
font: 12px var(--wp-font-mono);
color: var(--wp-warn);
}
.item-del {
opacity: 0;
}
.item:hover .item-del,
.item-del:focus-visible {
opacity: 1;
}
.form {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
min-height: 0;
}
.fields {
flex-wrap: wrap;
row-gap: 8px;
}
.hint {
font-size: 12px;
}
.md {
flex: 1;
min-height: 0;
}
</style>
-275
View File
@@ -1,275 +0,0 @@
<template>
<n-grid x-gap="10" :cols="8">
<n-gi :span="2" class="col">
<n-flex vertical class="list">
<n-card
v-for="item in list"
:key="item.display"
@click="show(item.display)"
class="card"
:header-style="{
backgroundColor:
item.display === tutorial.display ? 'rgba(24, 160, 80, 0.1)' : '',
}"
:embedded="!item.is_public"
>
<template #header>
<n-flex align="center">
<n-button text @click.stop="togglePublic(item.display)">
<Icon
width="24"
:icon="
item.is_public
? 'twemoji:check-mark-button'
: 'twemoji:locked'
"
></Icon>
</n-button>
<span>【{{ item.display }}】{{ item.title }}</span>
</n-flex>
</template>
<template #header-extra>
<n-button text type="default" @click="remove(item.display)">
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
</n-button>
</template>
</n-card>
<n-button @click="createNew">新建</n-button>
</n-flex>
</n-gi>
<n-gi :span="6" class="col">
<n-flex vertical>
<n-form inline :show-feedback="false">
<n-form-item label="序号" label-placement="left">
<n-input-number
style="width: 100px"
v-model:value="tutorial.display"
/>
</n-form-item>
<n-form-item label="标题" label-placement="left">
<n-input v-model:value="tutorial.title" />
</n-form-item>
<n-form-item label="公开" label-placement="left">
<n-switch v-model:value="tutorial.is_public" />
</n-form-item>
<n-form-item label-placement="left">
<n-button type="primary" @click="submit" :disabled="!canSubmit">
提交
</n-button>
</n-form-item>
</n-form>
<n-flex>
<TaskAssetManager
v-if="tutorial.display"
task-type="tutorial"
:display="tutorial.display"
/>
<n-button
v-if="tutorial.display"
size="small"
@click="showExampleModal = true"
>
示例代码
</n-button>
</n-flex>
<MarkdownEditor
style="height: calc(100vh - 100px)"
v-model="tutorial.content"
/>
</n-flex>
</n-gi>
</n-grid>
<n-modal
v-model:show="showExampleModal"
preset="card"
title="示例代码(加载教程时自动填入编辑器)"
style="width: 640px"
>
<n-input
type="textarea"
v-model:value="rawCode"
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
:autosize="{ minRows: 10, maxRows: 30 }"
/>
<n-flex v-if="splitResult" style="margin-top: 8px">
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
</n-flex>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Tutorial } from "../api"
import type { TutorialSlim } from "../utils/type"
import { useDialog, useMessage } from "naive-ui"
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const confirm = useDialog()
const list = ref<TutorialSlim[]>([])
const showExampleModal = ref(false)
const rawCode = ref("")
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
function splitHtml(raw: string) {
let result = raw
const cssBlocks: string[] = []
const jsBlocks: string[] = []
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
cssBlocks.push(c.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, c) => {
jsBlocks.push(c.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
watch(rawCode, (val) => {
if (!val.trim()) {
splitResult.value = null
tutorial.example_html = null
tutorial.example_css = null
tutorial.example_js = null
return
}
const split = splitHtml(val)
splitResult.value = split
tutorial.example_html = split.html || null
tutorial.example_css = split.css || null
tutorial.example_js = split.js || null
})
watch(showExampleModal, (visible) => {
if (!visible) return
const parts: string[] = []
if (tutorial.example_css) parts.push(`<style>\n${tutorial.example_css}\n</style>`)
if (tutorial.example_html) parts.push(tutorial.example_html)
if (tutorial.example_js) parts.push(`<script>\n${tutorial.example_js}\n<\/script>`)
rawCode.value = parts.join("\n\n")
})
const tutorial = reactive({
display: 0,
title: "",
content: "",
is_public: false,
example_html: null as string | null,
example_css: null as string | null,
example_js: null as string | null,
})
const canSubmit = computed(
() => tutorial.display && tutorial.title && tutorial.content,
)
async function getContent() {
list.value = await Tutorial.list()
const display = Number(route.params.display)
const target = list.value.find((item) => item.display === display)
if (target) {
show(display)
} else if (list.value.length > 0) {
show(list.value[0]!.display)
}
}
function createNew() {
tutorial.display = list.value[list.value.length - 1]?.display ?? 0 + 1
tutorial.title = ""
tutorial.content = ""
tutorial.is_public = false
tutorial.example_html = null
tutorial.example_css = null
tutorial.example_js = null
}
async function submit() {
try {
await Tutorial.createOrUpdate(tutorial)
message.success("提交成功")
tutorial.display = 0
tutorial.title = ""
tutorial.content = ""
tutorial.is_public = false
tutorial.example_html = null
tutorial.example_css = null
tutorial.example_js = null
await getContent()
} catch (error: any) {
message.error(error.response.data.detail)
}
}
async function remove(display: number) {
confirm.warning({
title: "警告",
content: "你确定要删除吗?",
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
await Tutorial.remove(display)
message.success("删除成功")
getContent()
},
})
}
async function show(display: number) {
router.push({ name: "tutorial-editor", params: { display } })
const item = await Tutorial.get(display)
tutorial.display = item.display
tutorial.title = item.title
tutorial.content = item.content
tutorial.is_public = item.is_public
tutorial.example_html = item.example_html ?? null
tutorial.example_css = item.example_css ?? null
tutorial.example_js = item.example_js ?? null
}
async function togglePublic(display: number) {
const data = await Tutorial.togglePublic(display)
message.success(data.message)
list.value = list.value.map((item) => {
if (item.display === display) item.is_public = !item.is_public
return item
})
}
onMounted(getContent)
</script>
<style scoped>
.list {
overflow: auto;
height: calc(100vh - 20px);
}
.col {
padding-top: 10px;
}
.card {
cursor: pointer;
}
.editor {
height: calc(100vh - 200px);
}
</style>
+49 -13
View File
@@ -5,6 +5,7 @@
<n-input <n-input
style="width: 200px" style="width: 200px"
v-model:value="query.username" v-model:value="query.username"
placeholder="搜索用户名"
clearable clearable
/> />
</div> </div>
@@ -27,21 +28,26 @@
<n-data-table striped :columns="columns" :data="users"></n-data-table> <n-data-table striped :columns="columns" :data="users"></n-data-table>
<UserFormModal v-model:show="showCreate" @saved="init" /> <UserFormModal v-model:show="showCreate" @saved="init" />
<n-modal <n-modal
style="width: 300px" style="width: 440px"
:mask-closable="false" :mask-closable="false"
preset="card" preset="card"
title="批量新建 (前缀 web)" title="批量新建学生"
v-model:show="showBatch" v-model:show="showBatch"
> >
<n-flex vertical> <n-flex vertical>
<n-text depth="3" style="font-size: 13px">
每行一个名字。用户名是 web + 班级 + 名字(比如
web{{ classname || "241" }}{{ names[0] || "张三" }}),密码随机生成,建好后在列表里点“显示”查看。
</n-text>
<n-input <n-input
placeholder="班级" placeholder="班级,比如 241"
v-model:value="classname" v-model:value="classname"
:disabled="batchLoading" :disabled="batchLoading"
/> />
<n-input <n-input
rows="20" rows="16"
type="textarea" type="textarea"
placeholder="张三&#10;李四&#10;王五"
v-model:value="namesStr" v-model:value="namesStr"
:disabled="batchLoading" :disabled="batchLoading"
/> />
@@ -51,7 +57,7 @@
:loading="batchLoading" :loading="batchLoading"
@click="batchCreateUsers" @click="batchCreateUsers"
> >
提交 创建 {{ names.length }} 个账号
</n-button> </n-button>
</n-flex> </n-flex>
</n-modal> </n-modal>
@@ -61,7 +67,7 @@
import { onMounted, reactive, ref, h, watch, computed } from "vue" import { onMounted, reactive, ref, h, watch, computed } from "vue"
import { Account } from "../api" import { Account } from "../api"
import { parseTime } from "../utils/helper" import { parseTime } from "../utils/helper"
import { useMessage, type DataTableColumn } from "naive-ui" import { NButton, useMessage, type DataTableColumn } from "naive-ui"
import { getRole, Role, type User } from "../utils/type" import { getRole, Role, type User } from "../utils/type"
import UserActions from "../components/dashboard/UserActions.vue" import UserActions from "../components/dashboard/UserActions.vue"
import UserFormModal from "../components/dashboard/UserFormModal.vue" import UserFormModal from "../components/dashboard/UserFormModal.vue"
@@ -82,7 +88,13 @@ const showBatch = ref(false)
const showCreate = ref(false) const showCreate = ref(false)
const classname = ref("") const classname = ref("")
const namesStr = ref("") const namesStr = ref("")
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it)) const names = computed(() =>
namesStr.value
.split("\n")
.map((it) => it.trim())
.filter((it) => !!it),
)
const revealed = reactive(new Set<number>())
const batchLoading = ref(false) const batchLoading = ref(false)
@@ -99,8 +111,19 @@ const columns: DataTableColumn<User>[] = [
key: "username", key: "username",
}, },
{ {
title: "密码", // 密码默认不显示,投屏时不会被学生看到
title: "初始密码",
key: "raw_password", key: "raw_password",
render: (row) =>
!row.raw_password
? "—"
: revealed.has(row.id)
? h("span", { style: "font-family: var(--wp-font-mono)" }, row.raw_password)
: h(
NButton,
{ text: true, size: "small", onClick: () => revealed.add(row.id) },
() => "显示",
),
}, },
{ {
title: "创建时间", title: "创建时间",
@@ -151,8 +174,13 @@ async function batchCreateUsers() {
} }
} }
// 快速翻页时只用最后一次请求的结果
let initSeq = 0
async function init() { async function init() {
const seq = ++initSeq
const data = await Account.list(query) const data = await Account.list(query)
if (seq !== initSeq) return
users.value = data.items users.value = data.items
count.value = data.count count.value = data.count
} }
@@ -160,15 +188,15 @@ async function init() {
watch( watch(
() => query.role, () => query.role,
() => { () => {
query.page = 1 if (query.page !== 1) query.page = 1
init() else init()
}, },
) )
watchDebounced( watchDebounced(
() => query.username, () => query.username,
() => { () => {
query.page = 1 if (query.page !== 1) query.page = 1
init() else init()
}, },
{ debounce: 500, maxWait: 1000 }, { debounce: 500, maxWait: 1000 },
) )
@@ -179,13 +207,21 @@ watch(
router.push({ params: { page: v } }) router.push({ params: { page: v } })
}, },
) )
// 浏览器前进 / 后退时,页码跟着地址栏走
watch(
() => route.params.page,
(v) => {
const page = Number(v) || 1
if (page !== query.page) query.page = page
},
)
onMounted(init) onMounted(init)
</script> </script>
<style scoped> <style scoped>
.container { .container {
padding: 10px 10px 10px 0; padding: 10px 10px 10px 0;
height: 100vh; height: 100%;
overflow: auto; overflow: auto;
} }
</style> </style>
+9 -1
View File
@@ -14,6 +14,7 @@
:css="css" :css="css"
:js="js" :js="js"
:asset-base-url="assetBaseUrl" :asset-base-url="assetBaseUrl"
:storage-task-id="taskId"
/> />
</template> </template>
</n-split> </n-split>
@@ -21,13 +22,20 @@
</n-split> </n-split>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from "vue"
import { useMagicKeys, whenever } from "@vueuse/core" import { useMagicKeys, whenever } from "@vueuse/core"
import Editors from "../components/editor/Editors.vue" import Editors from "../components/editor/Editors.vue"
import Preview from "../components/editor/Preview.vue" import Preview from "../components/editor/Preview.vue"
import TaskPanel from "../components/task/TaskPanel.vue" import TaskPanel from "../components/task/TaskPanel.vue"
import { show, panelSize } from "../store/panel" import { show, panelSize } from "../store/panel"
import { html, css, js } from "../store/editors" import { html, css, js } from "../store/editors"
import { assetBaseUrl } from "../store/task" import { assetBaseUrl, taskId } from "../store/task"
import { saveTaskCode } from "../composables/useTaskCode"
// 每一节教程的代码各存一份;任务 id 和代码一起看,切换小节时不会把新一节的代码存到上一节下面
watch([taskId, html, css, js], ([id]) => {
if (id) saveTaskCode(id, { html: html.value, css: css.value, js: js.value })
})
const { ctrl_s } = useMagicKeys({ const { ctrl_s } = useMagicKeys({
passive: false, passive: false,
+49 -12
View File
@@ -1,11 +1,23 @@
import { createWebHistory, createRouter } from "vue-router" import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"
import { loginModal } from "./store/modal" import { loginModal } from "./store/modal"
import Workspace from "./pages/Workspace.vue" // 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
const Workspace = () => import("./pages/Workspace.vue")
import { hasLogin } from "./token" import { hasLogin } from "./token"
import { authed } from "./store/user" import { authed, roleStaff, roleSuper } from "./store/user"
import { loadUser } from "./store/session"
const routes = [ declare module "vue-router" {
interface RouteMeta {
auth?: boolean
// 教学页面:staff 是 admin 和 super,super 只有超管(和后端的 requireAdmin / requireSuper 对应)
role?: "staff" | "super"
// 不带顶栏的全屏页面
bare?: boolean
}
}
const routes: RouteRecordRaw[] = [
{ path: "/", name: "home", component: Workspace, meta: { auth: true } }, { path: "/", name: "home", component: Workspace, meta: { auth: true } },
{ {
path: "/tutorial", path: "/tutorial",
@@ -22,7 +34,7 @@ const routes = [
{ {
path: "/challenge", path: "/challenge",
name: "home-challenge-list", name: "home-challenge-list",
component: Workspace, component: () => import("./pages/ChallengeHome.vue"),
meta: { auth: true }, meta: { auth: true },
}, },
{ {
@@ -32,9 +44,10 @@ const routes = [
meta: { auth: true }, meta: { auth: true },
}, },
{ {
path: "/submissions/:page", // 学生(和老师自己)的“我的提交”;:page 是旧链接留下的,不用了
path: "/submissions/:page?",
name: "submissions", name: "submissions",
component: () => import("./pages/Submissions.vue"), component: () => import("./pages/MySubmissions.vue"),
meta: { auth: true }, meta: { auth: true },
}, },
{ {
@@ -42,7 +55,7 @@ const routes = [
name: "submission", name: "submission",
component: () => import("./pages/Submission.vue"), component: () => import("./pages/Submission.vue"),
props: true, props: true,
meta: { auth: true }, meta: { auth: true, bare: true },
}, },
{ {
path: "/creation", path: "/creation",
@@ -66,22 +79,26 @@ const routes = [
path: "/dashboard", path: "/dashboard",
name: "dashboard", name: "dashboard",
component: () => import("./pages/Dashboard.vue"), component: () => import("./pages/Dashboard.vue"),
meta: { auth: true }, meta: { auth: true, role: "staff" },
children: [ children: [
{ {
path: "tutorial/:display", path: "tutorial/:display",
name: "tutorial-editor", name: "tutorial-editor",
component: () => import("./pages/TutorialEditor.vue"), component: () => import("./pages/TaskEditor.vue"),
props: { kind: "tutorial" },
meta: { role: "super" },
}, },
{ {
path: "challenge/:display", path: "challenge/:display",
name: "challenge-editor", name: "challenge-editor",
component: () => import("./pages/ChallengeEditor.vue"), component: () => import("./pages/TaskEditor.vue"),
props: { kind: "challenge" },
}, },
{ {
path: "user-manage/:page", path: "user-manage/:page",
name: "user-manage", name: "user-manage",
component: () => import("./pages/UserManage.vue"), component: () => import("./pages/UserManage.vue"),
meta: { role: "super" },
}, },
{ {
path: "showcase", path: "showcase",
@@ -92,6 +109,19 @@ const routes = [
path: "gradebook", path: "gradebook",
name: "gradebook", name: "gradebook",
component: () => import("./pages/Gradebook.vue"), component: () => import("./pages/Gradebook.vue"),
meta: { role: "super" },
},
{
path: "review",
name: "review",
component: () => import("./pages/Review.vue"),
},
{
// 全站提交列表(超管),和学生的“我的提交”是两个页面
path: "submissions/:page",
name: "teach-submissions",
component: () => import("./pages/Submissions.vue"),
meta: { role: "super" },
}, },
], ],
}, },
@@ -102,10 +132,17 @@ export const router = createRouter({
routes, routes,
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const isLoggedIn = authed.value || hasLogin() const isLoggedIn = authed.value || hasLogin()
if (to.meta.auth && !isLoggedIn) { if (to.meta.auth && !isLoggedIn) {
loginModal.value = true loginModal.value = true
return false return false
} }
if (to.meta.role || to.name === "dashboard") {
await loadUser()
const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value
if (!allowed) return { name: "home" }
}
// 教学的默认页是批改
if (to.name === "dashboard") return { name: "review" }
}) })
+1 -1
View File
@@ -9,7 +9,7 @@ export const css = useStorage(STORAGE_KEY.CSS, defaultCSS)
export const js = useStorage(STORAGE_KEY.JS, "") export const js = useStorage(STORAGE_KEY.JS, "")
export const tab = useStorage(STORAGE_KEY.TAB, "html") export const tab = useStorage(STORAGE_KEY.TAB, "html")
export const size = useStorage(STORAGE_KEY.FONTSIZE, 24) export const size = useStorage(STORAGE_KEY.FONTSIZE, 16)
export function reset(lang: "html" | "css" | "js") { export function reset(lang: "html" | "css" | "js") {
if (lang === "html") { if (lang === "html") {
+41 -41
View File
@@ -1,5 +1,5 @@
import { ref } from "vue" import { ref } from "vue"
import { wsUrl } from "../token" import { ReconnectingSocket } from "../utils/socket"
export interface GuidanceMessage { export interface GuidanceMessage {
role: "user" | "assistant" role: "user" | "assistant"
@@ -15,31 +15,19 @@ export const isReady = ref(false)
export const isOpen = ref(false) export const isOpen = ref(false)
export const initialPrompt = ref("") export const initialPrompt = ref("")
let ws: WebSocket | null = null
let _taskId = 0 let _taskId = 0
// 取 token 要等一下,期间又重连或关闭时作废这次连接 // 打开弹窗时带过来的提示词,连上后发一次;重连时不再重发
let connectSeq = 0 let unsentPrompt = ""
async function connect(taskId: number) { const socket = new ReconnectingSocket({
if (ws) ws.close() onOpen() {
ws = null
const seq = ++connectSeq
const url = await wsUrl(`/ws/guidance/${taskId}/`)
if (seq !== connectSeq) return
ws = new WebSocket(url)
setupHandlers(ws)
}
function setupHandlers(socket: WebSocket) {
socket.onopen = () => {
connected.value = true connected.value = true
if (initialPrompt.value) { if (unsentPrompt) {
sendToSocket(initialPrompt.value) sendToSocket(unsentPrompt)
unsentPrompt = ""
} }
} },
onMessage(data) {
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "init") { if (data.type === "init") {
streaming.value = false streaming.value = false
streamingContent.value = "" streamingContent.value = ""
@@ -57,18 +45,38 @@ function setupHandlers(socket: WebSocket) {
streamingContent.value = "" streamingContent.value = ""
messages.value.push({ role: "assistant", content: data.content }) messages.value.push({ role: "assistant", content: data.content })
} }
} },
onDrop() {
socket.onclose = () => {
connected.value = false connected.value = false
} if (streaming.value) dropPendingMessage()
},
onGiveUp() {
messages.value.push({
role: "assistant",
content: "与服务器的连接已断开,请刷新页面重试",
})
},
})
function connect(taskId: number) {
socket.connect(`/ws/guidance/${taskId}/`)
} }
function sendToSocket(content: string) { function sendToSocket(content: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!socket.send({ type: "message", content })) return
streaming.value = true streaming.value = true
messages.value.push({ role: "user", content }) messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content })) }
function dropPendingMessage() {
if (
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
) {
messages.value.pop()
}
streaming.value = false
streamingContent.value = ""
} }
export function openGuidance(taskId: number, prompt: string) { export function openGuidance(taskId: number, prompt: string) {
@@ -76,6 +84,7 @@ export function openGuidance(taskId: number, prompt: string) {
_taskId = taskId _taskId = taskId
initialPrompt.value = prompt initialPrompt.value = prompt
unsentPrompt = prompt
messages.value = [] messages.value = []
isReady.value = false isReady.value = false
streamingContent.value = "" streamingContent.value = ""
@@ -89,26 +98,17 @@ export function sendGuidance(content: string) {
} }
export function stopGuidance() { export function stopGuidance() {
if ( dropPendingMessage()
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
) {
messages.value.pop()
}
streaming.value = false
streamingContent.value = ""
if (_taskId) { if (_taskId) {
initialPrompt.value = "" initialPrompt.value = ""
unsentPrompt = ""
connect(_taskId) connect(_taskId)
} }
} }
export function closeGuidance() { export function closeGuidance() {
connectSeq++ socket.close()
if (ws) { unsentPrompt = ""
ws.close()
ws = null
}
isOpen.value = false isOpen.value = false
messages.value = [] messages.value = []
isReady.value = false isReady.value = false
+90 -90
View File
@@ -1,6 +1,5 @@
import { ref } from "vue" import { ref } from "vue"
import { wsUrl } from "../token" import { ReconnectingSocket } from "../utils/socket"
import { html, css, js } from "./editors"
export interface PromptMessage { export interface PromptMessage {
role: "user" | "assistant" role: "user" | "assistant"
@@ -9,117 +8,111 @@ export interface PromptMessage {
code?: { html: string | null; css: string | null; js: string | null } code?: { html: string | null; css: string | null; js: string | null }
submitted?: boolean // whether this assistant message's code has been submitted submitted?: boolean // whether this assistant message's code has been submitted
created?: string created?: string
// conversation:AI 对话;edit:学生手改;manual:粘贴外部 AI 的代码
source?: string
} }
type CodeOut = { html: string | null; css: string | null; js: string | null }
export const messages = ref<PromptMessage[]>([]) export const messages = ref<PromptMessage[]>([])
export const conversationId = ref<string>("") export const conversationId = ref<string>("")
export const connected = ref(false) export const connected = ref(false)
export const streaming = ref(false) export const streaming = ref(false)
export const streamingContent = ref("") export const streamingContent = ref("")
// 这次提问发出的时间:AI 先思考,第一个字可能一两分钟后才出来,界面据此显示等了多久
export const streamStartedAt = ref(0)
export const currentTaskId = ref(0) export const currentTaskId = ref(0)
let _onCodeComplete: // 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
| (( let _codeSink:
code: { html: string | null; css: string | null; js: string | null }, | ((code: CodeOut, messageId: number, reason: "init" | "complete") => void)
messageId: number
) => void)
| null = null | null = null
export function setOnCodeComplete(fn: typeof _onCodeComplete) { export function setCodeSink(fn: typeof _codeSink) {
_onCodeComplete = fn _codeSink = fn
} }
let ws: WebSocket | null = null function onMessage(data: any) {
// 取 token 要等一下,期间又连了别的题或断开时作废这次连接 if (data.type === "init") {
let connectSeq = 0 streaming.value = false
streamingContent.value = ""
export async function connectPrompt(taskId: number) { const alreadyLoaded = conversationId.value === data.conversation_id
currentTaskId.value = taskId conversationId.value = data.conversation_id
if (ws) ws.close() if (!alreadyLoaded) {
ws = null messages.value = data.messages || []
const lastAssistant = [...messages.value]
const seq = ++connectSeq .reverse()
const url = await wsUrl(`/ws/prompt/${taskId}/`) .find((m) => m.role === "assistant" && m.code)
if (seq !== connectSeq) return if (lastAssistant?.code && lastAssistant.id) {
ws = new WebSocket(url) _codeSink?.(lastAssistant.code, lastAssistant.id, "init")
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "init") {
streaming.value = false
streamingContent.value = ""
const alreadyLoaded = conversationId.value === data.conversation_id
conversationId.value = data.conversation_id
if (!alreadyLoaded) {
messages.value = data.messages || []
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
}
} }
} else if (data.type === "stream") {
streaming.value = true
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
messages.value.push({
role: "assistant",
content: streamingContent.value,
id: data.message_id,
code: data.code,
submitted: false,
})
streamingContent.value = ""
if (data.code) {
applyCode(data.code)
if (_onCodeComplete) {
_onCodeComplete(data.code, data.message_id)
}
}
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
} }
} else if (data.type === "stream") {
streaming.value = true
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
messages.value.push({
role: "assistant",
content: streamingContent.value,
id: data.message_id,
code: data.code,
submitted: false,
source: "conversation",
})
streamingContent.value = ""
if (data.code) _codeSink?.(data.code, data.message_id, "complete")
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
} }
}
ws.onclose = () => { const socket = new ReconnectingSocket({
onOpen() {
connected.value = true
},
onMessage,
onDrop() {
connected.value = false connected.value = false
} // 断开时服务端会删掉还没有回复的提问,这边也去掉
if (streaming.value) dropPendingPrompt()
},
onGiveUp() {
messages.value.push({
role: "assistant",
content: "与服务器的连接已断开,请刷新页面重试",
})
},
})
export function connectPrompt(taskId: number) {
currentTaskId.value = taskId
socket.connect(`/ws/prompt/${taskId}/`)
} }
export function disconnectPrompt() { export function disconnectPrompt() {
connectSeq++ socket.close()
if (ws) {
ws.close()
ws = null
}
messages.value = [] messages.value = []
conversationId.value = "" conversationId.value = ""
connected.value = false connected.value = false
streaming.value = false streaming.value = false
streamingContent.value = "" streamingContent.value = ""
currentTaskId.value = 0 currentTaskId.value = 0
_onCodeComplete = null _codeSink = null
} }
export function sendPrompt(content: string, model: string = "") { export function sendPrompt(content: string, model: string = "") {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!socket.send({ type: "message", content, model })) return
streaming.value = true streaming.value = true
streamStartedAt.value = Date.now()
messages.value.push({ role: "user", content }) messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content, model }))
} }
export function stopPrompt() { function dropPendingPrompt() {
if ( if (
messages.value.length > 0 && messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user" messages.value[messages.value.length - 1].role === "user"
@@ -128,11 +121,28 @@ export function stopPrompt() {
} }
streaming.value = false streaming.value = false
streamingContent.value = "" streamingContent.value = ""
}
export function stopPrompt() {
dropPendingPrompt()
if (currentTaskId.value) { if (currentTaskId.value) {
connectPrompt(currentTaskId.value) connectPrompt(currentTaskId.value)
} }
} }
// 手改或粘贴外部代码存成版本后,把这一问一答加到当前对话里显示
export function addVersionPair(
prompt: string,
source: string,
messageId: number,
code: CodeOut,
) {
messages.value.push(
{ role: "user", content: prompt, source },
{ role: "assistant", content: "", id: messageId, code, submitted: false, source },
)
}
export function markMessageSubmitted(assistantMsgId: number) { export function markMessageSubmitted(assistantMsgId: number) {
const msg = messages.value.find((m) => m.id === assistantMsgId) const msg = messages.value.find((m) => m.id === assistantMsgId)
if (msg) msg.submitted = true if (msg) msg.submitted = true
@@ -144,13 +154,3 @@ export function removeMessagePair(assistantMsgId: number) {
messages.value.splice(idx - 1, 2) messages.value.splice(idx - 1, 2)
} }
} }
function applyCode(code: {
html: string | null
css: string | null
js: string | null
}) {
if (code.html !== null) html.value = code.html
if (code.css !== null) css.value = code.css
if (code.js !== null) js.value = code.js
}
+31
View File
@@ -0,0 +1,31 @@
import { Account } from "../api"
import { Role } from "../utils/type"
import { user } from "./user"
let loading: Promise<void> | null = null
// 取一次当前登录用户(路由守卫要按角色判断,可能比 App 挂载还早)
export function loadUser(): Promise<void> {
if (user.loaded) return Promise.resolve()
loading ??= Account.getMyProfile()
.then((data) => {
user.username = data.username
user.role = data.role
})
.catch(() => {})
.finally(() => {
user.loaded = true
loading = null
})
return loading
}
export async function logout() {
// token 已经先清掉了,退出请求失败(比如断网)界面也要显示为已退出
try {
await Account.logout()
} finally {
user.username = ""
user.role = Role.Normal
}
}
-20
View File
@@ -1,20 +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,
html: "",
css: "",
js: "",
created: new Date(),
modified: new Date(),
})
+25
View File
@@ -0,0 +1,25 @@
import { computed, watch } from "vue"
import { usePreferredDark, useStorage } from "@vueuse/core"
import { applyCssVars, dark, light } from "../theme"
// auto:跟随系统;投屏时老师可以手动切回亮色
export type ThemeMode = "auto" | "light" | "dark"
export const themeMode = useStorage<ThemeMode>("theme-mode", "auto")
const prefersDark = usePreferredDark()
export const isDark = computed(
() => themeMode.value === "dark" || (themeMode.value === "auto" && prefersDark.value),
)
// CSS 变量和 data-theme(markdown、代码高亮的暗色样式按它切换)跟着走
watch(
isDark,
(d) => {
applyCssVars(d ? dark : light)
const root = document.documentElement
root.dataset.theme = d ? "dark" : "light"
root.style.colorScheme = d ? "dark" : "light"
},
{ immediate: true },
)
+2
View File
@@ -16,3 +16,5 @@ export const roleAdmin = computed(
export const roleSuper = computed( export const roleSuper = computed(
() => authed.value && user.role === Role.Super, () => authed.value && user.role === Role.Super,
) )
// 老师端(教学):admin 和 super
export const roleStaff = computed(() => roleAdmin.value || roleSuper.value)
+384
View File
@@ -0,0 +1,384 @@
/* 由 github-markdown-css 的 github-markdown-dark.css 和 highlight.js 的 github-dark.css 生成:
每个选择器前加 :root[data-theme="dark"],只保留颜色相关的声明。暗色模式下覆盖亮色样式 */
:root[data-theme="dark"] .markdown-body {
color-scheme: dark;
color: #f0f6fc;
background-color: #0d1117;
}
:root[data-theme="dark"] .markdown-body .octicon {
fill: currentColor;
}
:root[data-theme="dark"] .markdown-body h1:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h2:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h3:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h4:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h5:hover .anchor .octicon-link:before,
:root[data-theme="dark"] .markdown-body h6:hover .anchor .octicon-link:before {
background-color: currentColor;
}
:root[data-theme="dark"] .markdown-body a {
background-color: rgba(0,0,0,0);
color: #4493f8;
}
:root[data-theme="dark"] .markdown-body abbr[title] {
border-bottom: none;
}
:root[data-theme="dark"] .markdown-body h1 {
border-bottom: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body mark {
background-color: #bb800926;
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body hr {
background: rgba(0,0,0,0);
border-bottom: 1px solid #3d444db3;
background-color: #3d444d;
border: 0;
}
:root[data-theme="dark"] .markdown-body ::-webkit-input-placeholder {
color: inherit;
}
:root[data-theme="dark"] .markdown-body ::placeholder {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body a:focus,
:root[data-theme="dark"] .markdown-body [role=button]:focus,
:root[data-theme="dark"] .markdown-body input[type=radio]:focus,
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus {
outline: 2px solid var(--borderColor-accent-emphasis);
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body input[type=radio]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus:not(:focus-visible) {
outline: solid 1px rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body a:focus-visible,
:root[data-theme="dark"] .markdown-body [role=button]:focus-visible,
:root[data-theme="dark"] .markdown-body input[type=radio]:focus-visible,
:root[data-theme="dark"] .markdown-body input[type=checkbox]:focus-visible {
outline: 2px solid var(--borderColor-accent-emphasis);
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body kbd {
color: #f0f6fc;
background-color: #151b23;
border: solid 1px var(--borderColor-muted);
border-bottom-color: var(--borderColor-muted);
box-shadow: inset 0 -1px 0 var(--borderColor-muted);
}
:root[data-theme="dark"] .markdown-body h2 {
border-bottom: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body h6 {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body blockquote {
color: #9198a1;
border-left: .25em solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .octicon {
fill: currentColor;
}
:root[data-theme="dark"] .markdown-body a:not([href]) {
color: inherit;
}
:root[data-theme="dark"] .markdown-body .absent {
color: #f85149;
}
:root[data-theme="dark"] .markdown-body .anchor:focus {
outline: none;
}
:root[data-theme="dark"] .markdown-body h1 .octicon-link,
:root[data-theme="dark"] .markdown-body h2 .octicon-link,
:root[data-theme="dark"] .markdown-body h3 .octicon-link,
:root[data-theme="dark"] .markdown-body h4 .octicon-link,
:root[data-theme="dark"] .markdown-body h5 .octicon-link,
:root[data-theme="dark"] .markdown-body h6 .octicon-link {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body summary h1,
:root[data-theme="dark"] .markdown-body summary h2 {
border-bottom: 0;
}
:root[data-theme="dark"] .markdown-body table th,
:root[data-theme="dark"] .markdown-body table td {
border: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body table tr {
background-color: #0d1117;
border-top: 1px solid #3d444db3;
}
:root[data-theme="dark"] .markdown-body table tr:nth-child(2n) {
background-color: #151b23;
}
:root[data-theme="dark"] .markdown-body table img {
background-color: rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body .emoji {
background-color: rgba(0,0,0,0);
}
:root[data-theme="dark"] .markdown-body span.frame>span {
border: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body span.frame span span {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body code,
:root[data-theme="dark"] .markdown-body tt {
background-color: #656c7633;
}
:root[data-theme="dark"] .markdown-body pre>code {
background: rgba(0,0,0,0);
border: 0;
}
:root[data-theme="dark"] .markdown-body .highlight pre,
:root[data-theme="dark"] .markdown-body pre {
color: #f0f6fc;
background-color: #151b23;
}
:root[data-theme="dark"] .markdown-body pre code,
:root[data-theme="dark"] .markdown-body pre tt {
background-color: rgba(0,0,0,0);
border: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data .blob-num {
background: #0d1117;
border: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data tr {
border-top: 0;
}
:root[data-theme="dark"] .markdown-body .csv-data th {
background: #151b23;
border-top: 0;
}
:root[data-theme="dark"] .markdown-body .footnotes {
color: #9198a1;
border-top: 1px solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .footnotes li:target::before {
border: 2px solid #1f6feb;
}
:root[data-theme="dark"] .markdown-body .footnotes li:target {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-c {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body .pl-c1,
:root[data-theme="dark"] .markdown-body .pl-s .pl-v {
color: #79c0ff;
}
:root[data-theme="dark"] .markdown-body .pl-e,
:root[data-theme="dark"] .markdown-body .pl-en {
color: #d2a8ff;
}
:root[data-theme="dark"] .markdown-body .pl-smi,
:root[data-theme="dark"] .markdown-body .pl-s .pl-s1 {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-ent {
color: #7ee787;
}
:root[data-theme="dark"] .markdown-body .pl-k {
color: #ff7b72;
}
:root[data-theme="dark"] .markdown-body .pl-s,
:root[data-theme="dark"] .markdown-body .pl-pds,
:root[data-theme="dark"] .markdown-body .pl-s .pl-pse .pl-s1,
:root[data-theme="dark"] .markdown-body .pl-sr,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sre,
:root[data-theme="dark"] .markdown-body .pl-sr .pl-sra {
color: #a5d6ff;
}
:root[data-theme="dark"] .markdown-body .pl-v,
:root[data-theme="dark"] .markdown-body .pl-smw {
color: #ffa657;
}
:root[data-theme="dark"] .markdown-body .pl-bu {
color: #f85149;
}
:root[data-theme="dark"] .markdown-body .pl-ii {
color: var(--fgColor-danger);
background-color: var(--bgColor-danger-muted);
}
:root[data-theme="dark"] .markdown-body .pl-c2 {
color: #f0f6fc;
background-color: #b62324;
}
:root[data-theme="dark"] .markdown-body .pl-sr .pl-cce {
color: #7ee787;
}
:root[data-theme="dark"] .markdown-body .pl-ml {
color: #f2cc60;
}
:root[data-theme="dark"] .markdown-body .pl-mh,
:root[data-theme="dark"] .markdown-body .pl-mh .pl-en,
:root[data-theme="dark"] .markdown-body .pl-ms {
color: #1f6feb;
}
:root[data-theme="dark"] .markdown-body .pl-mi {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-mb {
color: #f0f6fc;
}
:root[data-theme="dark"] .markdown-body .pl-md {
color: #ffdcd7;
background-color: #67060c;
}
:root[data-theme="dark"] .markdown-body .pl-mi1 {
color: #aff5b4;
background-color: #033a16;
}
:root[data-theme="dark"] .markdown-body .pl-mc {
color: #ffdfb6;
background-color: #5a1e02;
}
:root[data-theme="dark"] .markdown-body .pl-mi2 {
color: #f0f6fc;
background-color: #1158c7;
}
:root[data-theme="dark"] .markdown-body .pl-mdr {
color: #d2a8ff;
}
:root[data-theme="dark"] .markdown-body .pl-ba {
color: #9198a1;
}
:root[data-theme="dark"] .markdown-body .pl-sg {
color: #3d444d;
}
:root[data-theme="dark"] .markdown-body .pl-corl {
color: #a5d6ff;
}
:root[data-theme="dark"] .markdown-body [role=button]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body button:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body summary:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body a:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
:root[data-theme="dark"] .markdown-body [tabindex="0"]:focus:not(:focus-visible),
:root[data-theme="dark"] .markdown-body details-dialog:focus:not(:focus-visible) {
outline: none;
}
:root[data-theme="dark"] .markdown-body a:has(>p,
:root[data-theme="dark"] >div,
:root[data-theme="dark"] >pre,
:root[data-theme="dark"] >blockquote):has(.snippet-clipboard-content,
:root[data-theme="dark"] >pre):focus-visible {
outline: 2px solid var(--borderColor-accent-emphasis);
}
:root[data-theme="dark"] .markdown-body .markdown-alert {
color: inherit;
border-left: .25em solid #3d444d;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note {
border-left-color: #1f6feb;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {
color: #4493f8;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important {
border-left-color: #8957e5;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {
color: #ab7df8;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning {
border-left-color: #9e6a03;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {
color: #d29922;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip {
border-left-color: #238636;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {
color: #3fb950;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution {
border-left-color: #da3633;
}
:root[data-theme="dark"] .markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {
color: #f85149;
}
:root[data-theme="dark"] .hljs {
color: #c9d1d9;
background: #0d1117;
}
:root[data-theme="dark"] .hljs-doctag,
:root[data-theme="dark"] .hljs-keyword,
:root[data-theme="dark"] .hljs-meta .hljs-keyword,
:root[data-theme="dark"] .hljs-template-tag,
:root[data-theme="dark"] .hljs-template-variable,
:root[data-theme="dark"] .hljs-type,
:root[data-theme="dark"] .hljs-variable.language_ {
color: #ff7b72;
}
:root[data-theme="dark"] .hljs-title,
:root[data-theme="dark"] .hljs-title.class_,
:root[data-theme="dark"] .hljs-title.class_.inherited__,
:root[data-theme="dark"] .hljs-title.function_ {
color: #d2a8ff;
}
:root[data-theme="dark"] .hljs-attr,
:root[data-theme="dark"] .hljs-attribute,
:root[data-theme="dark"] .hljs-literal,
:root[data-theme="dark"] .hljs-meta,
:root[data-theme="dark"] .hljs-number,
:root[data-theme="dark"] .hljs-operator,
:root[data-theme="dark"] .hljs-variable,
:root[data-theme="dark"] .hljs-selector-attr,
:root[data-theme="dark"] .hljs-selector-class,
:root[data-theme="dark"] .hljs-selector-id {
color: #79c0ff;
}
:root[data-theme="dark"] .hljs-regexp,
:root[data-theme="dark"] .hljs-string,
:root[data-theme="dark"] .hljs-meta .hljs-string {
color: #a5d6ff;
}
:root[data-theme="dark"] .hljs-built_in,
:root[data-theme="dark"] .hljs-symbol {
color: #ffa657;
}
:root[data-theme="dark"] .hljs-comment,
:root[data-theme="dark"] .hljs-code,
:root[data-theme="dark"] .hljs-formula {
color: #8b949e;
}
:root[data-theme="dark"] .hljs-name,
:root[data-theme="dark"] .hljs-quote,
:root[data-theme="dark"] .hljs-selector-tag,
:root[data-theme="dark"] .hljs-selector-pseudo {
color: #7ee787;
}
:root[data-theme="dark"] .hljs-subst {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-section {
color: #1f6feb;
}
:root[data-theme="dark"] .hljs-bullet {
color: #f2cc60;
}
:root[data-theme="dark"] .hljs-emphasis {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-strong {
color: #c9d1d9;
}
:root[data-theme="dark"] .hljs-addition {
color: #aff5b4;
background-color: #033a16;
}
:root[data-theme="dark"] .hljs-deletion {
color: #ffdcd7;
background-color: #67060c;
}
+113
View File
@@ -0,0 +1,113 @@
import type { GlobalThemeOverrides } from "naive-ui"
// 全站配色只在这里定义:同一份值既生成 CSS 变量(组件样式里用 var(--wp-*)),
// 也生成 Naive UI 的主题设置。机房是 Chrome 105,不支持 color-mix(),浅色底色单独列出来
export interface Palette {
brand: string
brandHover: string
brandPressed: string
brandSoft: string
ink: string
muted: string
faint: string
paper: string
surface: string
line: string
html: string
htmlSoft: string
css: string
cssSoft: string
js: string
jsSoft: string
warn: string
warnSoft: string
bad: string
badSoft: string
}
export const light: Palette = {
brand: "#0d7a52",
brandHover: "#118f61",
brandPressed: "#0a6343",
brandSoft: "#e3f2eb",
ink: "#17201c",
muted: "#5d6b65",
faint: "#8a9791",
paper: "#f5f7f6",
surface: "#ffffff",
line: "#dfe5e2",
html: "#d9532c",
htmlSoft: "#fdf3ef",
css: "#2c6bd6",
cssSoft: "#f1f5fd",
js: "#b98900",
jsSoft: "#fdf9ec",
warn: "#b7791f",
warnSoft: "#fbf3e6",
bad: "#c2413b",
badSoft: "#fbeceb",
}
// 暗色(阶段 4 才接上切换开关,值先定好)
export const dark: Palette = {
brand: "#3cc189",
brandHover: "#5ad19d",
brandPressed: "#2ea374",
brandSoft: "#173326",
ink: "#e4ebe7",
muted: "#9aaba3",
faint: "#6f7f78",
paper: "#0f1412",
surface: "#161d1a",
line: "#2a3531",
html: "#f07a52",
htmlSoft: "#2a1c17",
css: "#6c9cf0",
cssSoft: "#18202e",
js: "#e2b93b",
jsSoft: "#262113",
warn: "#e0a34a",
warnSoft: "#2b2316",
bad: "#ec6b65",
badSoft: "#2e1a19",
}
export const fontSans =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", system-ui, sans-serif'
export const fontMono =
'"JetBrains Mono", Consolas, "Microsoft YaHei", monospace'
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())
export function applyCssVars(p: Palette) {
const root = document.documentElement.style
for (const [k, v] of Object.entries(p)) root.setProperty(`--wp-${kebab(k)}`, v)
root.setProperty("--wp-font-sans", fontSans)
root.setProperty("--wp-font-mono", fontMono)
}
export function naiveOverrides(p: Palette): GlobalThemeOverrides {
return {
common: {
primaryColor: p.brand,
primaryColorHover: p.brandHover,
primaryColorPressed: p.brandPressed,
primaryColorSuppl: p.brandHover,
warningColor: p.warn,
errorColor: p.bad,
textColorBase: p.ink,
textColor1: p.ink,
textColor2: p.ink,
textColor3: p.muted,
borderColor: p.line,
dividerColor: p.line,
fontFamily: fontSans,
fontFamilyMono: fontMono,
borderRadius: "6px",
borderRadiusSmall: "4px",
},
Card: {
borderRadius: "8px",
},
}
}
+66
View File
@@ -0,0 +1,66 @@
export interface Code {
html: string
css: string
js: string
}
export const emptyCode = (): Code => ({ html: "", css: "", js: "" })
export const sameCode = (a: Code, b: Code) =>
a.html === b.html && a.css === b.css && a.js === b.js
export const isEmptyCode = (c: Code) => !c.html.trim() && !c.css.trim() && !c.js.trim()
// 把一整页 HTML(外部 AI 常给的格式)拆成 html / css / js 三段
export function splitHtml(raw: string): Code {
const cssBlocks: string[] = []
const jsBlocks: string[] = []
let result = raw.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
cssBlocks.push(content.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, content) => {
jsBlocks.push(content.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
// prettier 加上各语言插件近 1MB,只在点“整理代码”时才加载
export async function formatCode(code: Code): Promise<Code> {
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
await Promise.all([
import("prettier/standalone"),
import("prettier/parser-html"),
import("prettier/parser-postcss"),
import("prettier/parser-babel"),
import("prettier/plugins/estree"),
])
const [html, css, js] = await Promise.all([
prettier.format(code.html, {
parser: "html",
//@ts-ignore
plugins: [htmlParser, babelParser, estreeParser, cssParser],
tabWidth: 4,
}),
prettier.format(code.css, {
parser: "css",
plugins: [cssParser],
tabWidth: 4,
}),
prettier.format(code.js, {
parser: "babel",
//@ts-ignore
plugins: [babelParser, estreeParser],
tabWidth: 2,
}),
])
return { html, css, js }
}
+2 -1
View File
@@ -33,7 +33,8 @@ export const STORAGE_KEY = {
CSS: "web-css", CSS: "web-css",
JS: "web-js", JS: "web-js",
TAB: "web-tab", TAB: "web-tab",
FONTSIZE: "web-fontsize", // 默认字号从 24 改成 16 时换了 key,让机房电脑上存着的旧值失效
FONTSIZE: "web-fontsize-v2",
} }
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
+22
View File
@@ -16,3 +16,25 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
router.push({ name: "home" }) router.push({ name: "home" })
} }
} }
// 一轮对话 / 一个版本是怎么来的
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() : ""
}
+63 -2
View File
@@ -1,5 +1,66 @@
import { marked } from "marked" import { Marked, type MarkedExtension } from "marked"
import alert from "marked-alert"
import { markedHighlight } from "marked-highlight"
import preview from "marked-code-preview"
import hljs from "highlight.js/lib/core"
import xml from "highlight.js/lib/languages/xml"
import css from "highlight.js/lib/languages/css"
import javascript from "highlight.js/lib/languages/javascript"
import plaintext from "highlight.js/lib/languages/plaintext"
import { alertVariants } from "./const"
hljs.registerLanguage("html", xml)
hljs.registerLanguage("css", css)
hljs.registerLanguage("js", javascript)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("plaintext", plaintext)
const highlight = markedHighlight({
langPrefix: "hljs language-",
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : "plaintext"
return hljs.highlight(code, { language }).value
},
})
const alerts = alert({ variants: alertVariants })
// 老师写的教程、挑战描述用:带 preview 的代码块会渲染成真实的 HTML
export const trustedExtensions: MarkedExtension[] = [
{ gfm: true },
highlight,
alerts,
preview({ template: `<div class="markedown-body-preview">{preview}</div>` }),
]
// 提示词和 AI 回复是用户可控的内容,渲染进 v-html 前必须去掉可执行的部分:
// 原始 HTML 一律转义成文本,链接和图片只放行 http(s)/mailto 和相对地址
const escapeHtml = (s: string) =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
const isSafeUrl = (href: string) =>
!/^\s*[a-z][a-z0-9+.-]*:/i.test(href) || /^\s*(https?|mailto):/i.test(href)
// 独立实例,不带 preview 扩展(它会把代码块原样当 HTML 渲染)
const safeMarked = new Marked({ gfm: true }, highlight, alerts, {
renderer: {
html({ text }) {
return escapeHtml(text)
},
link({ href, tokens }) {
if (isSafeUrl(href)) return false
return this.parser.parseInline(tokens)
},
image({ href, text }) {
if (isSafeUrl(href)) return false
return escapeHtml(text)
},
},
})
export function renderMarkdown(text: string): string { export function renderMarkdown(text: string): string {
return marked.parse(text) as string return safeMarked.parse(text) as string
} }
+14 -1
View File
@@ -1,8 +1,18 @@
// 预览里跑的是用户写的代码(可能是别人的提交),不能给 allow-same-origin,
// 否则它和本站同源,能读 localStorage 里的登录 token
export const PREVIEW_SANDBOX =
"allow-scripts allow-modals allow-forms allow-popups allow-pointer-lock"
import { storageShim, type PreviewStorage } from "./previewStorage"
interface PreviewDocumentOptions { interface PreviewDocumentOptions {
html: string html: string
css: string css: string
js: string js: string
assetBaseUrl?: string assetBaseUrl?: string
// 不传:localStorage 替身只存在内存里;传入:带上已存的数据并写回主页面;
// false:不注入替身(下载成文件时用,浏览器里直接打开有真正的 localStorage)
storage?: PreviewStorage | false
} }
export function buildPreviewDocument({ export function buildPreviewDocument({
@@ -10,16 +20,19 @@ export function buildPreviewDocument({
css, css,
js, js,
assetBaseUrl, assetBaseUrl,
storage,
}: PreviewDocumentOptions) { }: PreviewDocumentOptions) {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="zh-Hans-CN"> <html lang="zh-Hans-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
${storage === false ? "" : storageShim(storage)}
<title>预览</title> <title>预览</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""} ${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
<style>${css}</style> <!-- normalize 要在学生的样式前面,否则同样写法的规则(body、h1 的边距等)会被它盖掉 -->
<link rel="stylesheet" href="/normalize.min.css" /> <link rel="stylesheet" href="/normalize.min.css" />
<style>${css}</style>
</head> </head>
<body> <body>
${html} ${html}
+193
View File
@@ -0,0 +1,193 @@
// 预览 iframe 没有 allow-same-origin,学生代码里访问 localStorage 会抛异常。
// 这里在学生代码之前注入一个替身:数据先放在 iframe 的内存里;
// 自己编辑器里的预览再通过 postMessage 交给主页面,存到本站 localStorage 的独立 key 下,
// 下次渲染时注入回去,所以刷新、改代码后数据还在。看别人的作品时只存在内存里。
const KEY_PREFIX = "preview-storage:"
// 本站的登录 token 也在 localStorage 里(配额约 5MB),预览数据不能把它挤满
const SCOPE_MAX_CHARS = 100_000
const TOTAL_MAX_CHARS = 500_000
const MESSAGE_TYPE = "preview-storage"
export interface PreviewStorage {
scope: string
data: Record<string, string>
}
// 在 iframe 里运行,必须自成一体(不能引用外面的变量)
const SHIM = `(function (config, maxChars, type) {
function create(initial, persist) {
var data = new Map(Object.entries(initial || {}))
function post(msg) {
if (!persist) return
msg.type = type
msg.scope = config.scope
parent.postMessage(msg, "*")
}
function size() {
var n = 0
data.forEach(function (v, k) { n += k.length + v.length })
return n
}
var api = {
getItem: function (k) {
k = String(k)
return data.has(k) ? data.get(k) : null
},
setItem: function (k, v) {
k = String(k)
v = String(v)
var old = data.has(k) ? k.length + data.get(k).length : 0
if (size() - old + k.length + v.length > maxChars) {
throw new DOMException("预览里的 localStorage 最多存 " + maxChars + " 个字符", "QuotaExceededError")
}
data.set(k, v)
post({ op: "set", key: k, value: v })
},
removeItem: function (k) {
k = String(k)
if (data.delete(k)) post({ op: "remove", key: k })
},
clear: function () {
data.clear()
post({ op: "clear" })
},
key: function (i) {
var keys = Array.from(data.keys())
return i >= 0 && i < keys.length ? keys[i] : null
},
}
var isApi = function (p) { return p === "length" || Object.prototype.hasOwnProperty.call(api, p) }
// 支持 localStorage.foo = 1、localStorage["foo"]、Object.keys(localStorage) 这些写法
return new Proxy({}, {
get: function (_, p) {
if (p === "length") return data.size
if (isApi(p)) return api[p]
if (typeof p === "string" && data.has(p)) return data.get(p)
return undefined
},
set: function (_, p, v) {
if (typeof p === "string" && !isApi(p)) api.setItem(p, v)
return true
},
deleteProperty: function (_, p) {
if (typeof p === "string") api.removeItem(p)
return true
},
has: function (_, p) {
return isApi(p) || (typeof p === "string" && data.has(p))
},
ownKeys: function () {
return Array.from(data.keys())
},
getOwnPropertyDescriptor: function (_, p) {
if (typeof p !== "string" || !data.has(p)) return undefined
return { value: data.get(p), writable: true, enumerable: true, configurable: true }
},
})
}
function define(name, value) {
try {
Object.defineProperty(window, name, { value: value, configurable: true })
} catch (e) {}
}
define("localStorage", create(config && config.data, !!config))
define("sessionStorage", create(null, false))
})`
// storage 不传时只存在内存里
export function storageShim(storage?: PreviewStorage) {
// 防止数据里的 </script> 提前结束脚本
const config = JSON.stringify(storage ?? null).replace(/</g, "\\u003c")
return `<script>${SHIM}(${config}, ${SCOPE_MAX_CHARS}, "${MESSAGE_TYPE}")<\/script>`
}
interface Saved {
// 最后写入时间,总量超了先删最久没用的
t: number
d: Record<string, string>
}
function read(key: string): Saved | null {
try {
const saved = JSON.parse(localStorage.getItem(key) ?? "null")
return saved && typeof saved.d === "object" ? saved : null
} catch {
return null
}
}
export function loadPreviewStorage(scope: string): PreviewStorage {
return { scope, data: read(KEY_PREFIX + scope)?.d ?? {} }
}
function previewKeys() {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith(KEY_PREFIX)) keys.push(k)
}
return keys
}
function evictOthers(keep: string, all: boolean) {
const others = previewKeys()
.filter((k) => k !== keep)
.map((k) => ({ k, t: read(k)?.t ?? 0, n: localStorage.getItem(k)!.length }))
.sort((a, b) => a.t - b.t)
let total = others.reduce((n, o) => n + o.n, 0) + (localStorage.getItem(keep)?.length ?? 0)
for (const o of others) {
if (!all && total <= TOTAL_MAX_CHARS) break
localStorage.removeItem(o.k)
total -= o.n
}
}
function save(scope: string, data: Record<string, string>) {
const key = KEY_PREFIX + scope
const value = JSON.stringify({ t: Date.now(), d: data } satisfies Saved)
try {
localStorage.setItem(key, value)
evictOthers(key, false)
} catch {
// 配额满了:清掉别的预览数据再试一次,还不行就不存了
try {
evictOthers(key, true)
localStorage.setItem(key, value)
} catch {}
}
}
function apply(scope: string, msg: any) {
const data = { ...loadPreviewStorage(scope).data }
if (msg.op === "set") {
if (typeof msg.key !== "string" || typeof msg.value !== "string") return
data[msg.key] = msg.value
} else if (msg.op === "remove") {
if (typeof msg.key !== "string") return
delete data[msg.key]
} else if (msg.op === "clear") {
for (const k of Object.keys(data)) delete data[k]
} else {
return
}
const size = Object.entries(data).reduce((n, [k, v]) => n + k.length + v.length, 0)
if (size > SCOPE_MAX_CHARS) return
save(scope, data)
}
// 在 win 上接收 iframe 发来的写入。只认这个 iframe、这个 scope 的消息;返回取消监听的函数
export function listenPreviewStorage(
win: Window,
frame: () => HTMLIFrameElement | null | undefined,
scope: () => string,
) {
const handler = (e: MessageEvent) => {
const s = scope()
if (!s || !e.data || e.data.type !== MESSAGE_TYPE || e.data.scope !== s) return
if (!frame() || e.source !== frame()!.contentWindow) return
apply(s, e.data)
}
win.addEventListener("message", handler)
return () => win.removeEventListener("message", handler)
}
+80
View File
@@ -0,0 +1,80 @@
import { getRefreshToken, refreshAccessToken, wsUrl } from "../token"
const MAX_DELAY_MS = 30_000
// 连续这么多次没连上就放弃,让用户刷新页面
const MAX_FAILURES = 6
interface Handlers {
onOpen?: () => void
onMessage: (data: any) => void
// 非主动断开(服务器重启、网络断了),之后会自动重连
onDrop?: () => void
onGiveUp?: () => void
}
// 带断线重连的 WebSocket:非主动关闭时按 1s、2s、4s… 退避重连。
// 握手就失败多半是 access token 失效(比如服务端重启换了密钥),重连前先强制刷新
export class ReconnectingSocket {
private ws: WebSocket | null = null
private path = ""
// 取 token、等重连期间又调用了 connect/close 时,作废旧的那次
private seq = 0
private timer: ReturnType<typeof setTimeout> | undefined
private failures = 0
constructor(private handlers: Handlers) {}
connect(path: string) {
this.close()
this.path = path
this.failures = 0
this.open(this.seq)
}
close() {
this.seq++
clearTimeout(this.timer)
const ws = this.ws
this.ws = null
ws?.close()
}
send(data: unknown) {
if (this.ws?.readyState !== WebSocket.OPEN) return false
this.ws.send(JSON.stringify(data))
return true
}
private async open(seq: number) {
const url = await wsUrl(this.path)
if (seq !== this.seq) return
const socket = new WebSocket(url)
this.ws = socket
let opened = false
socket.onopen = () => {
if (socket !== this.ws) return
opened = true
this.failures = 0
this.handlers.onOpen?.()
}
socket.onmessage = (event) => {
if (socket === this.ws) this.handlers.onMessage(JSON.parse(event.data))
}
socket.onclose = async () => {
if (socket !== this.ws) return
this.ws = null
this.handlers.onDrop?.()
if (++this.failures > MAX_FAILURES || !getRefreshToken()) {
this.handlers.onGiveUp?.()
return
}
if (!opened) await refreshAccessToken()
if (seq !== this.seq) return
const delay = Math.min(1000 * 2 ** (this.failures - 1), MAX_DELAY_MS)
this.timer = setTimeout(() => {
if (seq === this.seq) this.open(seq)
}, delay)
}
}
}
+86 -32
View File
@@ -39,7 +39,8 @@ export function getRole(role: Role) {
}[role] }[role]
} }
export type FlagType = "red" | "blue" | "green" | "yellow" | null // 只用 present(课上展示);以前的四色标记数据里还可能有
export type FlagType = "present" | "red" | "blue" | "green" | "yellow" | null
export interface TaskAsset { export interface TaskAsset {
name: string name: string
@@ -126,6 +127,8 @@ export interface User {
role: Role role: Role
classname: string classname: string
is_active: boolean is_active: boolean
// 批量创建时生成的初始密码;超管和改过密码的为空
raw_password?: string | null
} }
export interface SubmissionOut { export interface SubmissionOut {
@@ -164,6 +167,8 @@ export interface SubmissionAll {
view_count: number view_count: number
created: Date created: Date
modified: Date modified: Date
// 交的时候写的反思,只有老师和作者本人拿得到
reflection?: string | null
} }
export interface UserTag { export interface UserTag {
@@ -178,25 +183,10 @@ export interface SubmissionCountBucket {
count_4_plus: number count_4_plus: number
} }
export interface ScoreBucket { export interface TopScoredItem {
range_1_2: number
range_2_3: number
range_3_4: number
range_4_5: number
range_5: number
}
export interface FlagStats {
red: number
blue: number
green: number
yellow: number
}
export interface TopViewedItem {
username: string username: string
classname: string classname: string
view_count: number score: number
submission_id: string submission_id: string
} }
@@ -208,10 +198,21 @@ export interface TaskStatsOut {
unsubmitted_users: UserTag[] unsubmitted_users: UserTag[]
unrated_users: UserTag[] unrated_users: UserTag[]
submission_count_distribution: SubmissionCountBucket submission_count_distribution: SubmissionCountBucket
score_distribution: ScoreBucket present_count: number
flag_stats: FlagStats
classes: string[] classes: string[]
top_viewed: TopViewedItem[] 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" export type GradebookTaskType = "tutorial" | "challenge"
@@ -274,6 +275,7 @@ export interface ShowcaseItem {
username: string username: string
task_title: string task_title: string
task_display: number task_display: number
task_type: TASK_TYPE
score: number score: number
view_count: number view_count: number
html: string | null html: string | null
@@ -314,28 +316,22 @@ export interface AwardItemUpdateIn {
sort_order: number 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 { export interface AwardItemManageOut {
id: number id: number
submission_id: string submission_id: string
username: string username: string
task_title: string task_title: string
task_display: number task_display: number
task_type: TASK_TYPE
score: number score: number
view_count: number view_count: number
sort_order: number sort_order: number
awarded_at: string awarded_at: string
has_prompt_chain: boolean has_prompt_chain: boolean
is_stale: boolean is_stale: boolean
html: string | null
css: string | null
js: string | null
} }
export interface ShowcaseDetail { export interface ShowcaseDetail {
@@ -343,6 +339,7 @@ export interface ShowcaseDetail {
username: string username: string
task_title: string task_title: string
task_display: number task_display: number
task_type: TASK_TYPE
score: number score: number
view_count: number view_count: number
html: string | null html: string | null
@@ -362,9 +359,9 @@ export interface PromptRound {
js: string | null js: string | null
} }
// 打分前不带作者(打完分 updateScore 返回)
export interface RandomRatingItem { export interface RandomRatingItem {
submission_id: string submission_id: string
username: string
task_title: string task_title: string
task_display: number task_display: number
task_type: TASK_TYPE task_type: TASK_TYPE
@@ -372,3 +369,60 @@ export interface RandomRatingItem {
css: string | null css: string | null
js: string | null js: string | null
} }
export interface ReviewOptions {
classes: string[]
tasks: {
id: number
task_type: "tutorial" | "challenge"
display: number
title: string
is_public: boolean
}[]
}
export interface ReviewProgress {
student_count: number
// task_id → 交了的人数
submitted: Record<string, number>
}
export interface ReviewSubmission {
id: string
score: number
my_score: number
rating_count: number
flag: FlagType
view_count: number
submit_count: number
created: string
html: string
css: string
js: string
}
export interface ReviewStudent {
user_id: number
username: string
classname: string
submission: ReviewSubmission | null
}
export interface PeerWork {
id: string
// 自己打分之前是 null
username: string | null
classname: string
score: number | null
my_score: number
created: string
html: string
css: string
js: string
}
export interface PeerWorks {
// 自己还没交这道题
locked: boolean
items: PeerWork[]
}
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
ROOT="$(cd "$(dirname "$0")" && pwd)"
# 启动后端(--watch:改代码自动重启)
cd "$ROOT/server" || exit 1
bun --watch run src/index.ts &
BACKEND_PID=$!
# 启动前端
cd "$ROOT/client" || exit 1
bun run start &
FRONTEND_PID=$!
# 捕获退出信号,同时终止两个进程
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait
+3 -14
View File
@@ -13,7 +13,9 @@ services:
restart: unless-stopped restart: unless-stopped
webpreview-api: webpreview-api:
build: server build:
context: .
dockerfile: server/Dockerfile
container_name: webpreview-api container_name: webpreview-api
env_file: env_file:
- .env - .env
@@ -21,16 +23,3 @@ services:
- ./sqlite:/app/data - ./sqlite:/app/data
- ./media:/app/media - ./media:/app/media
restart: unless-stopped restart: unless-stopped
# 只在从旧版 Postgres 迁移数据时使用:
# docker compose --profile migrate up -d webpreview-db
# docker compose run --rm webpreview-api bun scripts/migrate-from-postgres.ts
webpreview-db:
image: postgres:17-alpine
container_name: webpreview-db
profiles: ["migrate"]
volumes:
- ./data:/var/lib/postgresql/data
env_file:
- .env
restart: "no"
-75
View File
@@ -1,75 +0,0 @@
# 服务器切换到 Bun 后端(一次性)
后端从 Django + Postgres + Redis 换成了 Bun + SQLite,前后端也合并成了一个仓库
(`git.xuyue.cc:xuyue/web.git` / `github.com/yuetsh/web`)。
每台服务器(debian、school)都要按下面的步骤做一次,之后 push 到 `main` 就会自动部署。
切换时所有人需要重新登录一次(旧的登录会话在 Redis 里,不迁移)。密码不受影响。
## 0. GitHub 仓库设置
在 `yuetsh/web` 的 Settings → Secrets and variables → Actions 里添加和旧仓库一样的 `KEY`、`HOST`。
## 1. 备份
```bash
cd /root/WebPreview
docker compose exec webpreview-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > /root/webpreview-$(date +%F).sql
```
`data/`(Postgres 数据目录)在整个过程中不会被修改,出问题可以随时回退。
## 2. 把目录变成新仓库的 clone
```bash
cd /root/WebPreview
git init -b main
git remote add origin git@git.xuyue.cc:xuyue/web.git
git fetch origin
git reset origin/main # 只建立 git 记录,不改动现有文件
git checkout -- . # 用仓库里的 docker-compose.yaml、Caddyfile 等覆盖旧文件
mv api /root/webpreview-api-old # 旧的 Django 后端,确认没问题后可删
git status # 应该是干净的(.env、data/、build/、media/ 都被忽略)
```
## 3. 迁移数据(Postgres → SQLite)
找一个没人用的时间做,先停掉旧后端,避免迁移期间有新数据写进 Postgres:
```bash
docker stop webpreview-api
docker compose --profile migrate up -d webpreview-db # 旧库本来就在运行的话不会有变化
docker compose build webpreview-api
docker compose run --rm --no-deps webpreview-api bun scripts/migrate-from-postgres.ts
```
脚本会逐表打印行数并做外键检查,最后输出 `导入完成` 才算成功。数据库文件在 `./sqlite/db.sqlite3`。
`.env` 里原有的 `POSTGRES_*` 变量就是脚本要用的,不需要改。
## 4. 启动新服务
```bash
docker compose up -d --remove-orphans # 启动 Caddy + Bun,顺带删掉不再需要的 Redis 容器
docker compose --profile migrate stop webpreview-db
docker stats --no-stream # webpreview-api 应该在几十 MB
```
打开网站登录确认一下。之后就可以 `docker compose --profile migrate rm webpreview-db`,
`data/` 目录建议留一段时间再删(属主是容器里的 postgres 用户,需要 root 才能删)。
## 回退
```bash
docker compose down
mv /root/webpreview-api-old api
git show 47d4679:docker-compose.yaml > docker-compose.yaml
git show 47d4679:Caddyfile > Caddyfile
docker compose up -d --build
```
## 以后的日常操作
- 部署:push 到 `main`,GitHub Actions 会构建前端并在服务器上 `git pull && docker compose up -d --build`
- 新建超级管理员:`docker compose exec webpreview-api bun scripts/create-superuser.ts <用户名> <密码>`
- 补标注提示词层级:`docker compose exec webpreview-api bun scripts/classify-prompts.ts`
- 备份:复制 `sqlite/` 目录即可(或 `sqlite3 sqlite/db.sqlite3 ".backup backup.sqlite3"`)
+14
View File
@@ -0,0 +1,14 @@
{
"name": "webpreview",
"private": true,
"type": "module",
"workspaces": ["client", "server"],
"scripts": {
"dev": "bun run --filter '*' dev",
"build": "bun run --filter webpreview-client build",
"build:staging": "bun run --filter webpreview-client build:staging",
"test": "bun run --filter webpreview-server test",
"typecheck": "bun run --filter '*' typecheck",
"fmt": "bun run --filter webpreview-client fmt"
}
}
+8 -4
View File
@@ -1,3 +1,4 @@
# 构建上下文是仓库根目录(见 docker-compose.yaml),只装 server 这个 workspace 的依赖
FROM oven/bun:1.4.4-alpine FROM oven/bun:1.4.4-alpine
WORKDIR /app WORKDIR /app
@@ -7,11 +8,14 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV BUN_CONFIG_REGISTRY=$NPM_REGISTRY ENV BUN_CONFIG_REGISTRY=$NPM_REGISTRY
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production COPY client/package.json client/
COPY server/package.json server/
RUN bun install --frozen-lockfile --production --filter webpreview-server
COPY tsconfig.json ./ WORKDIR /app/server
COPY src ./src COPY server/tsconfig.json ./
COPY scripts ./scripts COPY server/src ./src
COPY server/scripts ./scripts
ENV ENV=production \ ENV ENV=production \
PORT=8000 \ PORT=8000 \
-74
View File
@@ -1,74 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "server",
"dependencies": {
"hono": "^4.13.14",
"zod": "^4.6.7",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^7.0.2",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.4", "https://registry.npmjs.com/@types/bun/-/bun-1.4.4.tgz", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "https://registry.npmjs.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"bun-types": ["bun-types@1.4.4", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.4.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"hono": ["hono@4.13.14", "https://registry.npmjs.com/hono/-/hono-4.13.14.tgz", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="],
"typescript": ["typescript@7.0.2", "https://registry.npmjs.com/typescript/-/typescript-7.0.2.tgz", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"zod": ["zod@4.6.7", "https://registry.npmjs.com/zod/-/zod-4.6.7.tgz", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ const { values } = parseArgs({
}, },
}) })
const where = ["m.role = 'user'"] const where = ["m.role = 'user'", "m.source != 'edit'"]
const params: Record<string, unknown> = {} const params: Record<string, unknown> = {}
if (values["task-id"]) { if (values["task-id"]) {
where.push("c.task_id = $task_id") where.push("c.task_id = $task_id")
-134
View File
@@ -1,134 +0,0 @@
/**
* 把旧 Django 项目的 Postgres 数据导入 SQLite(一次性迁移)。
*
* POSTGRES_HOST=... POSTGRES_DB=... POSTGRES_USER=... POSTGRES_PASSWORD=... \
* bun scripts/migrate-from-postgres.ts [--force]
*
* 目标库为 DB_PATH(默认 data/db.sqlite3)。目标库已有数据时需要 --force(会先清空)。
* 会话不迁移,所有人需要重新登录一次。
*/
import { SQL } from "bun"
import { config } from "../src/config"
import { db } from "../src/db"
// 按外键依赖顺序
const TABLES = [
"account_user",
"task_task",
"task_tutorial",
"task_challenge",
"task_creation",
"task_taskasset",
"submission_submission",
"submission_rating",
"submission_award",
"submission_submissionaward",
"prompt_conversation",
"prompt_message",
]
const force = process.argv.includes("--force")
const env = process.env
for (const key of ["POSTGRES_HOST", "POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"]) {
if (!env[key]) {
console.error(`缺少环境变量 ${key}`)
process.exit(1)
}
}
const pg = new SQL({
hostname: env.POSTGRES_HOST,
port: Number(env.POSTGRES_PORT ?? 5432),
database: env.POSTGRES_DB,
username: env.POSTGRES_USER,
password: env.POSTGRES_PASSWORD,
})
const existing = db.query(`SELECT COUNT(*) AS n FROM account_user`).get() as { n: number }
if (existing.n > 0 && !force) {
console.error(`目标库 ${config.dbPath} 已有数据,加 --force 会清空后重新导入`)
process.exit(1)
}
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`
// 按 Postgres 的列类型转换成 Django 在 SQLite 中的存储格式
function selectExpr(column: string, pgType: string) {
const c = quote(column)
switch (pgType) {
case "timestamp with time zone":
return `to_char(${c} AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
case "timestamp without time zone":
return `to_char(${c}, 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
case "uuid":
return `replace(${c}::text, '-', '') AS ${c}`
case "boolean":
return `${c}::int AS ${c}`
case "bigint":
return `${c}::text AS ${c}`
default:
return c
}
}
const counts: Record<string, number> = {}
const rowsByTable: Record<string, Record<string, unknown>[]> = {}
for (const table of TABLES) {
const sqliteColumns = (db.query(`PRAGMA table_info(${quote(table)})`).all() as { name: string }[]).map(
(c) => c.name,
)
const pgColumns = await pg`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${table}`
if (!pgColumns.length) throw new Error(`Postgres 中没有表 ${table}`)
const pgTypes = new Map<string, string>(pgColumns.map((c: any) => [c.column_name, c.data_type]))
const missing = sqliteColumns.filter((c) => !pgTypes.has(c))
if (missing.length) throw new Error(`Postgres 表 ${table} 缺少列:${missing.join(", ")}`)
const select = sqliteColumns.map((c) => selectExpr(c, pgTypes.get(c)!)).join(", ")
const rows: Record<string, unknown>[] = await pg.unsafe(`SELECT ${select} FROM ${quote(table)}`)
for (const row of rows) {
for (const c of sqliteColumns) {
if (pgTypes.get(c) === "bigint" && row[c] !== null) row[c] = Number(row[c])
}
}
rowsByTable[table] = rows
console.log(`读取 ${table}: ${rows.length} 行`)
}
await pg.close()
db.transaction(() => {
db.run("PRAGMA defer_foreign_keys = ON")
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
db.run(`DELETE FROM auth_refresh_token`)
for (const table of TABLES) {
const rows = rowsByTable[table]!
if (!rows.length) {
counts[table] = 0
continue
}
const columns = Object.keys(rows[0]!)
const insert = db.prepare(
`INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")})
VALUES (${columns.map((c) => "$" + c).join(", ")})`,
)
for (const row of rows) insert.run(row as any)
counts[table] = rows.length
}
})()
// 校验
let ok = true
for (const table of TABLES) {
const n = (db.query(`SELECT COUNT(*) AS n FROM ${quote(table)}`).get() as { n: number }).n
if (n !== counts[table]) ok = false
console.log(`${n === counts[table] ? "✓" : "✗"} ${table}: ${n}`)
}
const fk = db.query(`PRAGMA foreign_key_check`).all()
if (fk.length) {
ok = false
console.error("外键检查失败:", fk.slice(0, 10))
}
console.log(ok ? `\n导入完成:${config.dbPath}` : "\n导入有问题,请检查上面的输出")
process.exit(ok ? 0 : 1)
+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]}`)
+4
View File
@@ -161,3 +161,7 @@ export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
export const isStaff = (user: User) => export const isStaff = (user: User) =>
user.role === Role.ADMIN || user.role === Role.SUPER user.role === Role.ADMIN || user.role === Role.SUPER
// 未公开的教程只有超管能看,未公开的挑战管理员也能看(和能编辑它们的人一致)
export const canSeeUnpublished = (user: User | null | undefined, kind: "tutorial" | "challenge") =>
!!user && (kind === "tutorial" ? user.role === Role.SUPER : isStaff(user))
+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 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_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level"); 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 -- 登录用 JWT:access token 不落库,refresh token 只存 sha256
DROP TABLE IF EXISTS "auth_session"; DROP TABLE IF EXISTS "auth_session";
+36 -14
View File
@@ -13,7 +13,7 @@ import {
requireUser, requireUser,
setPassword, setPassword,
} from "../auth" } from "../auth"
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Env } from "../http" import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Ctx, type Env } from "../http"
const router = new Hono<Env>() const router = new Hono<Env>()
@@ -54,23 +54,45 @@ async function insertUser(u: {
const userExists = (username: string) => const userExists = (username: string) =>
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username }) !!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
router.post("/register", async (c) => { // 登录失败次数限制(进程内存里计数):同一用户名 10 分钟内失败 10 次、同一 IP 失败 100 次后暂停登录。
const payload = await body( // 机房的电脑共用一个出口 IP,IP 的阈值要放宽,主要防的是对单个账号猜密码
c, const LOGIN_WINDOW_MS = 10 * 60_000
z.object({ const MAX_FAILS_PER_USER = 10
username: z.string(), const MAX_FAILS_PER_IP = 100
email: z.email(), const loginFailures = new Map<string, { n: number; reset: number }>()
password: z.string().min(6).max(20),
}), const failCount = (key: string) => {
) const f = loginFailures.get(key)
await insertUser(payload) return f && f.reset > Date.now() ? f.n : 0
return c.json({ message: "创建成功" }) }
}) function recordFailure(key: string) {
const now = Date.now()
const f = loginFailures.get(key)
if (f && f.reset > now) f.n++
else loginFailures.set(key, { n: 1, reset: now + LOGIN_WINDOW_MS })
}
setInterval(() => {
const now = Date.now()
for (const [k, f] of loginFailures) if (f.reset <= now) loginFailures.delete(k)
}, LOGIN_WINDOW_MS).unref()
// 生产环境经过 Caddy,它会把 X-Forwarded-For 设成真实的客户端地址
const clientIp = (c: Ctx) => c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "local"
router.post("/login", async (c) => { router.post("/login", async (c) => {
const payload = await body(c, z.object({ username: z.string(), password: z.string() })) const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
const userKey = `user:${payload.username}`
const ipKey = `ip:${clientIp(c)}`
if (failCount(userKey) >= MAX_FAILS_PER_USER || failCount(ipKey) >= MAX_FAILS_PER_IP) {
throw new HttpError(429, "登录失败次数太多,请 10 分钟后再试")
}
const user = await authenticate(payload.username, payload.password) const user = await authenticate(payload.username, payload.password)
if (!user) throw new HttpError(401, "账号密码错误") if (!user) {
recordFailure(userKey)
recordFailure(ipKey)
throw new HttpError(401, "账号密码错误")
}
loginFailures.delete(userKey)
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) }) return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
}) })
+27 -27
View File
@@ -1,11 +1,10 @@
import { Hono } from "hono" import { Hono } from "hono"
import { mkdir, rm } from "node:fs/promises" import { mkdir, rename, rm } from "node:fs/promises"
import { existsSync } from "node:fs"
import { dirname, extname, join, resolve, sep } from "node:path" import { dirname, extname, join, resolve, sep } from "node:path"
import { config } from "../config" import { config } from "../config"
import { all, get, run } from "../db" import { all, get, run } from "../db"
import { requireAdmin, requireSuper, type User } from "../auth" import { canSeeUnpublished, requireAdmin, requireSuper, type User } from "../auth"
import { HttpError, ValidationError, intParam, orNotFound, type Ctx, type Env } from "../http" import { HttpError, ValidationError, intParam, notFound, orNotFound, type Ctx, type Env } from "../http"
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符 // 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
export function validFilename(name: string) { export function validFilename(name: string) {
@@ -22,20 +21,6 @@ export function mediaPath(relative: string) {
return full return full
} }
async function saveFile(relative: string, file: File) {
let target = relative
// 同名文件已存在时加随机后缀(Django 存储的行为)
while (existsSync(mediaPath(target))) {
const ext = extname(relative)
const suffix = Math.random().toString(36).slice(2, 9)
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
}
const full = mediaPath(target)
await mkdir(dirname(full), { recursive: true })
await Bun.write(full, file)
return target
}
async function formFile(c: Ctx, field: string) { async function formFile(c: Ctx, field: string) {
const form = await c.req.formData().catch(() => null) const form = await c.req.formData().catch(() => null)
const value = form?.get(field) const value = form?.get(field)
@@ -53,8 +38,8 @@ type Kind = "challenge" | "tutorial"
function findTask(kind: Kind, display: number) { function findTask(kind: Kind, display: number) {
return orNotFound( return orNotFound(
get<{ id: number; task_type: string; display: number }>( get<{ id: number; task_type: string; display: number; is_public: number }>(
`SELECT t.id, t.task_type, t.display FROM task_task t `SELECT t.id, t.task_type, t.display, t.is_public FROM task_task t
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`, JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
{ display }, { display },
), ),
@@ -72,6 +57,7 @@ for (const kind of ["challenge", "tutorial"] as const) {
assets.get(`/${kind}/:display`, (c) => { assets.get(`/${kind}/:display`, (c) => {
const task = findTask(kind, intParam(c, "display")) const task = findTask(kind, intParam(c, "display"))
if (!task.is_public && !canSeeUnpublished(c.get("user"), kind)) notFound()
const rows = all<{ name: string; file: string }>( const rows = all<{ name: string; file: string }>(
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`, `SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
{ id: task.id }, { id: task.id },
@@ -87,21 +73,35 @@ for (const kind of ["challenge", "tutorial"] as const) {
if (typeof name !== "string" || !name) { if (typeof name !== "string" || !name) {
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }]) throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
} }
const existing = get<{ id: number; file: string }>( // 学生代码按这个名字引用素材(<base href> 指向素材目录),存储的文件名必须和它一致
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`, if (name !== validFilename(name) || name.length > 100) {
throw new ValidationError([
{ loc: ["body", "name"], msg: "文件名只能包含字母、数字和 - _ .,最多 100 个字符" },
])
}
const existing = get<{ file: string }>(
`SELECT file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
{ task_id: task.id, name }, { task_id: task.id, name },
) )
if (existing?.file) await rm(mediaPath(existing.file), { force: true }) // 先写临时文件再 rename 覆盖:写失败时旧文件还在,并发上传同名文件也不会留下坏文件
const stored = await saveFile( const stored = `tasks/${task.task_type}/${task.display}/${name}`
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`, const full = mediaPath(stored)
file, const tmp = `${full}.${crypto.randomUUID()}.uploading`
) await mkdir(dirname(full), { recursive: true })
try {
await Bun.write(tmp, file)
await rename(tmp, full)
} finally {
await rm(tmp, { force: true })
}
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE // 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
run( run(
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id) `INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`, ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
{ name, file: stored, task_id: task.id }, { name, file: stored, task_id: task.id },
) )
// 旧数据里同名素材可能存在带随机后缀的文件
if (existing?.file && existing.file !== stored) await rm(mediaPath(existing.file), { force: true })
return c.json(assetOut({ name, file: stored })) return c.json(assetOut({ name, file: stored }))
}) })
+59 -18
View File
@@ -1,15 +1,23 @@
import { Hono } from "hono" import { Hono } from "hono"
import { z } from "zod" import { z } from "zod"
import { all, get, run, tx, iso, uuidOut } from "../db" import { all, get, tx, iso, uuidOut } from "../db"
import { Role, isStaff, requireUser } from "../auth" import { Role, isStaff, requireUser } from "../auth"
import { HttpError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http" import { HttpError, ValidationError, body, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
import { deleteSubmissions } from "../services/cascade" import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
import { import {
classifyConversationMessages, classifyConversationMessages,
classifyMessage,
classifyMessagesBatch, classifyMessagesBatch,
inBackground, inBackground,
} from "../services/classifier" } from "../services/classifier"
import { getPrecedingUserMessage, type MessageRow } from "../services/prompt" import {
EDIT_PROMPT,
canAccessTask,
codeAsMarkdown,
getOrCreateActiveConversation,
insertMessage,
type MessageRow,
} from "../services/prompt"
const router = new Hono<Env>() const router = new Hono<Env>()
@@ -30,6 +38,10 @@ router.get("/conversations", (c) => {
where.push("c.task_id = $task_id") where.push("c.task_id = $task_id")
params.task_id = q.task_id params.task_id = q.task_id
} }
// 不带条件就是全站所有对话(每个还要数一遍消息),会卡住整个服务
if (!where.length) {
throw new ValidationError([{ loc: ["query", "task_id"], msg: "需要 task_id 或 user_id" }])
}
const rows = all( const rows = all(
`SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created, `SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created,
(SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count
@@ -129,6 +141,45 @@ router.get("/history/:task_id", (c) => {
}) })
// 对一个对话里的用户消息做层级分类(管理员,后台执行) // 对一个对话里的用户消息做层级分类(管理员,后台执行)
// 保存一个不是 AI 生成的版本:学生手改的代码(source=edit),或者粘贴外部 AI 的代码(source=manual,带提示词)。
// 存成一问一答放进当前对话,版本条和提示词链里都能看到;之后接着和 AI 对话,AI 也是在这份代码上改
router.post("/versions", async (c) => {
const user = requireUser(c)
const p = await body(
c,
z.object({
task_id: z.number().int(),
html: z.string(),
css: z.string(),
js: z.string(),
prompt: z.string().trim().max(5000).optional(),
}),
)
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: p.task_id }), "任务不存在")
if (!canAccessTask(user, p.task_id)) throw new HttpError(403, "没有权限")
const source = p.prompt ? "manual" : "edit"
const code = { code_html: p.html, code_css: p.css, code_js: p.js }
const ids = tx(() => {
const convId = getOrCreateActiveConversation(user.id, p.task_id)
const userMessageId = insertMessage({
conversation_id: convId,
role: "user",
content: p.prompt || EDIT_PROMPT,
source,
})
const messageId = insertMessage({
conversation_id: convId,
role: "assistant",
content: codeAsMarkdown(code),
source,
...code,
})
return { userMessageId, messageId }
})
if (p.prompt) inBackground(classifyMessage(ids.userMessageId))
return c.json({ message_id: ids.messageId, user_message_id: ids.userMessageId, source })
})
router.post("/conversations/:conversation_id/classify", (c) => { router.post("/conversations/:conversation_id/classify", (c) => {
const user = requireUser(c) const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限") if (!isStaff(user)) throw new HttpError(403, "没有权限")
@@ -147,7 +198,7 @@ router.post("/classify-batch", (c) => {
c, c,
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }), z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
) )
const where = ["m.role = 'user'"] const where = ["m.role = 'user'", "m.source != 'edit'"]
const params: Record<string, unknown> = {} const params: Record<string, unknown> = {}
if (q.task_id) { if (q.task_id) {
where.push("c.task_id = $task_id") where.push("c.task_id = $task_id")
@@ -177,20 +228,10 @@ router.delete("/messages/:message_id/pair", (c) => {
if (asst.owner_id !== user.id && user.role !== Role.SUPER) { if (asst.owner_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的消息") throw new HttpError(403, "只能删除自己的消息")
} }
if (asst.submission_id && user.role !== Role.SUPER) { if (asst.submission_id && user.role !== Role.SUPER && hasRatingsOrAwards(asst.submission_id)) {
const used = get( throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: asst.submission_id },
)
if (used) throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
} }
const userMsg = getPrecedingUserMessage(asst) tx(() => deleteRound(asst))
tx(() => {
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id })
if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id })
})
return c.json({ deleted: true, submission_deleted: !!asst.submission_id }) return c.json({ deleted: true, submission_deleted: !!asst.submission_id })
}) })
+186
View File
@@ -0,0 +1,186 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, iso, uuidOut } from "../db"
import { isStaff, requireUser, type User } from "../auth"
import { HttpError, intParam, orNotFound, query, type Env } from "../http"
// 老师按“班级 × 任务”批改,学生看本班同学的作品。挂在 /submission/review 下
const router = new Hono<Env>()
function requireStaff(user: User) {
if (!isStaff(user)) throw new HttpError(403, "没有权限")
}
// 和成绩册一致:班里的学生包括当助教的 admin
const CLASS_MEMBERS = `SELECT id FROM account_user
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
// 不传班级时:所有有班级的学生
const ALL_MEMBERS = `SELECT id FROM account_user
WHERE classname != '' AND role IN ('normal', 'admin') AND is_active = 1`
function members(classname: string | undefined) {
return classname
? { sql: CLASS_MEMBERS, params: { classname } as Record<string, unknown> }
: { sql: ALL_MEMBERS, params: {} as Record<string, unknown> }
}
// 每个人在这个任务上最新的一次提交,以及提交了几次。
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
function latestByUser(taskId: number, members: string, params: Record<string, unknown>) {
const rows = all<{ rid: number; user_id: number; created: string }>(
`SELECT s.rowid AS rid, s.user_id, s.created FROM submission_submission s
WHERE s.task_id = $task_id AND s.user_id IN (${members})`,
{ task_id: taskId, ...params },
)
const latest = new Map<number, { rid: number; created: string; count: number }>()
for (const r of rows) {
const cur = latest.get(r.user_id)
if (!cur) latest.set(r.user_id, { rid: r.rid, created: r.created, count: 1 })
else {
cur.count++
if (r.created > cur.created) Object.assign(cur, { rid: r.rid, created: r.created })
}
}
return latest
}
function loadRows(rids: number[], me: number) {
if (!rids.length) return new Map<number, any>()
const rows = all(
`SELECT s.rowid AS rid, s.id, s.user_id, s.score, s.html, s.css, s.js, s.flag,
s.view_count, s.created,
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id) AS my_score,
(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) AS rating_count
FROM submission_submission s WHERE s.rowid IN (SELECT value FROM json_each($rids))`,
{ rids: JSON.stringify(rids), me },
)
return new Map(rows.map((r) => [r.user_id as number, r]))
}
// 班级列表和可批改的任务(教程、挑战)
router.get("/options", (c) => {
requireStaff(requireUser(c))
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
const tasks = all(
`SELECT id, task_type, display, title, is_public FROM task_task
WHERE task_type IN ('tutorial', 'challenge')
ORDER BY task_type = 'challenge', display`,
).map((t) => ({ ...t, is_public: !!t.is_public }))
return c.json({ classes, tasks })
})
// 一个班每个任务交了几个人
// 不传 classname 就是全部班级
router.get("/progress", (c) => {
requireStaff(requireUser(c))
const { classname } = query(c, z.object({ classname: z.string().min(1).optional() }))
const m = members(classname)
const studentCount = get<{ n: number }>(`SELECT COUNT(*) AS n FROM (${m.sql})`, m.params)!.n
const rows = all<{ task_id: number; n: number }>(
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
WHERE s.user_id IN (${m.sql}) GROUP BY s.task_id`,
m.params,
)
return c.json({
student_count: studentCount,
submitted: Object.fromEntries(rows.map((r) => [r.task_id, r.n])),
})
})
// 作品墙:班里每个学生在这个任务上最新的一次提交(没交的 submission 为 null)
router.get("/class", (c) => {
const user = requireUser(c)
requireStaff(user)
const q = query(
c,
z.object({ classname: z.string().min(1).optional(), task_id: z.coerce.number().int() }),
)
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
const m = members(q.classname)
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user WHERE id IN (${m.sql})
ORDER BY classname, username, id`,
m.params,
)
const latest = latestByUser(q.task_id, m.sql, m.params)
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
return c.json(
students.map((s) => {
const r = rows.get(s.id)
return {
user_id: s.id,
username: s.username,
classname: s.classname,
submission: r
? {
id: uuidOut(r.id),
score: r.score,
my_score: r.my_score || 0,
rating_count: r.rating_count,
flag: r.flag,
view_count: r.view_count,
submit_count: latest.get(s.id)!.count,
created: iso(r.created),
html: r.html,
css: r.css,
js: r.js,
}
: null,
}
}),
)
})
// 学生看同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分。
// 老师不受这两条限制。scope=class 只看本班(默认,一个班几十份),scope=all 看全校所有班
router.get("/peers/:task_id", (c) => {
const user = requireUser(c)
const taskId = intParam(c, "task_id")
const { scope } = query(c, z.object({ scope: z.enum(["class", "all"]).default("class") }))
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
const staff = isStaff(user)
const submitted =
staff ||
get(`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`, {
me: user.id,
task_id: taskId,
})
if (!submitted) return c.json({ locked: true, items: [] })
const byClass = scope === "class" && !!user.classname
const members = `SELECT id FROM account_user
WHERE role = 'normal' AND is_active = 1 AND id != $me
${byClass ? "AND classname = $classname" : ""}`
const params = byClass ? { classname: user.classname, me: user.id } : { me: user.id }
const latest = latestByUser(taskId, members, params)
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
const people = new Map(
all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user WHERE id IN (${members})`,
params,
).map((u) => [u.id, u]),
)
const items = [...rows.values()]
.sort((a, b) => (a.created < b.created ? 1 : -1))
.map((r) => {
const rated = staff || (r.my_score || 0) > 0
const p = people.get(r.user_id)
return {
id: uuidOut(r.id),
username: rated ? p?.username : null,
// 班级不算身份信息,打分前也显示
classname: p?.classname ?? "",
score: rated ? r.score : null,
my_score: r.my_score || 0,
created: iso(r.created),
html: r.html,
css: r.css,
js: r.js,
}
})
return c.json({ locked: false, items })
})
export default router
+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 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, 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} s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submissionaward sa FROM submission_submissionaward sa
JOIN submission_submission s ON s.id = sa.submission_id JOIN submission_submission s ON s.id = sa.submission_id
@@ -34,12 +34,17 @@ function itemManageOut(r: any) {
username: r.username, username: r.username,
task_title: r.task_title, task_title: r.task_title,
task_display: r.task_display, task_display: r.task_display,
task_type: r.task_type,
score: r.score, score: r.score,
view_count: r.view_count, view_count: r.view_count,
sort_order: r.sort_order, sort_order: r.sort_order,
awarded_at: iso(r.awarded_at), awarded_at: iso(r.awarded_at),
has_prompt_chain: bool(r.has_prompt_chain), has_prompt_chain: bool(r.has_prompt_chain),
is_stale: bool(r.is_stale), 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, username: r.username,
task_title: r.task_title, task_title: r.task_title,
task_display: r.task_display, task_display: r.task_display,
task_type: r.task_type,
score: r.score, score: r.score,
view_count: r.view_count, view_count: r.view_count,
html: r.html, html: r.html,
@@ -162,31 +168,6 @@ router.delete("/manage/awards/:award_id", (c) => {
return c.json({ message: "删除成功" }) 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) => { router.get("/manage/awards/:award_id/items", (c) => {
requireAdmin(c) requireAdmin(c)
const award = orNotFound( const award = orNotFound(
@@ -301,7 +282,7 @@ router.get("/:submission_id", (c) => {
assertAwarded(id) assertAwarded(id)
const s = orNotFound( const s = orNotFound(
get( 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} s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submission s FROM submission_submission s
JOIN account_user u ON u.id = s.user_id JOIN account_user u ON u.id = s.user_id
@@ -320,6 +301,7 @@ router.get("/:submission_id", (c) => {
username: s.username, username: s.username,
task_title: s.task_title, task_title: s.task_title,
task_display: s.task_display, task_display: s.task_display,
task_type: s.task_type,
score: s.score, score: s.score,
view_count: s.view_count, view_count: s.view_count,
html: s.html, html: s.html,
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more