chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70983a12a5
commit
47d4679984
40 files changed
+13723
-34
No files matched your search
@@ -0,0 +1,121 @@
|
||||
# Prompt Chain Feature Design
|
||||
|
||||
## Overview
|
||||
|
||||
在挑战模式下,学生通过平台内置 AI 对话生成网页代码,平台记录完整的 prompt 链条(思维链),提交时代码与 prompt 链条一起关联。老师和学生都能回看思维过程。
|
||||
|
||||
## 技术方案
|
||||
|
||||
WebSocket 实时对话(基于现有 Django Channels + Redis 基础设施),流式输出 AI 回复。调用国内大模型 API(如深度求索、通义千问)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 新增 `prompt` app(替代现有 `chat` app)
|
||||
|
||||
**Conversation**
|
||||
- `id`: UUIDField (PK)
|
||||
- `user`: FK → User
|
||||
- `task`: FK → Task(关联的挑战任务)
|
||||
- `is_active`: BooleanField(提交后锁定为 False)
|
||||
- `created` / `modified`: 时间戳
|
||||
|
||||
**Message**
|
||||
- `id`: AutoField (PK)
|
||||
- `conversation`: FK → Conversation
|
||||
- `role`: CharField("user" | "assistant")
|
||||
- `content`: TextField(消息文本)
|
||||
- `code_html`: TextField nullable(AI 回复中提取的 HTML)
|
||||
- `code_css`: TextField nullable(AI 回复中提取的 CSS)
|
||||
- `code_js`: TextField nullable(AI 回复中提取的 JS)
|
||||
- `created`: 时间戳
|
||||
|
||||
### 修改 Submission 模型
|
||||
|
||||
- 新增 `conversation`: FK → Conversation nullable
|
||||
|
||||
## 后端设计
|
||||
|
||||
### WebSocket Consumer (`prompt/consumers.py`)
|
||||
|
||||
路由:`ws/prompt/{task_id}/`
|
||||
|
||||
**连接流程:**
|
||||
1. 验证用户已登录
|
||||
2. 查找该用户+任务的活跃 Conversation,没有则创建
|
||||
3. 推送历史消息恢复上下文
|
||||
|
||||
**消息处理:**
|
||||
1. 收到学生 prompt → 存入 Message (role=user)
|
||||
2. 组装上下文:系统 prompt + 挑战描述 + 历史消息 → 调用大模型 API
|
||||
3. 流式接收 AI 回复 → 逐片段推送(打字机效果)
|
||||
4. 回复完成 → 提取代码块 → 存入 Message (role=assistant, 含 code 字段)
|
||||
5. 推送 `code_update` 事件 → 前端自动填入编辑器并刷新预览
|
||||
|
||||
**系统 prompt:**
|
||||
- 角色:网页生成助手
|
||||
- 要求用 ```html / ```css / ```js 格式返回代码
|
||||
- 注入当前挑战任务描述作为上下文
|
||||
|
||||
### REST API (`prompt/api.py`)
|
||||
|
||||
- `GET /api/prompt/conversations/?task_id=X` — 当前用户的对话列表
|
||||
- `GET /api/prompt/conversation/{id}/messages/` — 完整消息历史
|
||||
- `POST /api/prompt/conversations/new?task_id=X` — 新建对话
|
||||
- 老师端:`GET /api/prompt/conversations/?user_id=X&task_id=Y` — 查看学生对话
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 挑战模式新布局
|
||||
|
||||
```
|
||||
┌─────────────────────────┬──────────────────────────┐
|
||||
│ 挑战描述 (Markdown) │ │
|
||||
│ ──────────────────── │ │
|
||||
│ │ 预览 (iframe) │
|
||||
│ AI 对话区域 │ │
|
||||
│ ┌───────────────────┐ │ │
|
||||
│ │ 用户: 做一个... │ │ │
|
||||
│ │ AI: 好的... │ │ [查看代码] 按钮 │
|
||||
│ └───────────────────┘ │ │
|
||||
│ [输入 prompt...] [发送]│ │
|
||||
└─────────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
- 左侧上方:挑战任务描述(复用 Challenge 组件)
|
||||
- 左侧下方:AI 对话面板,可拖拽调整比例
|
||||
- 右侧:预览 iframe 占满,右上角"查看代码"按钮弹窗查看代码
|
||||
- 教程模式保持现有布局不变
|
||||
|
||||
### 新增组件
|
||||
|
||||
- `PromptPanel.vue` — AI 对话面板(消息列表 + 输入框 + 发送按钮)
|
||||
- `ChallengeHome.vue` — 挑战模式布局容器
|
||||
|
||||
### 提交流程
|
||||
|
||||
- 提交时附带 `conversation_id`,关联当前对话
|
||||
- 提交后对话锁定,学生可新建对话重新开始
|
||||
|
||||
### 查看 prompt 链条
|
||||
|
||||
- Submissions 页面新增"查看思维链"按钮
|
||||
- 点击展开完整 prompt 对话历史(只读)
|
||||
- 老师可按学生、按挑战筛选查看所有对话记录
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 新增
|
||||
- `prompt/` Django app(models, consumers, api, schemas)
|
||||
- `PromptPanel.vue`, `ChallengeHome.vue` 前端组件
|
||||
- `prompt` store(WebSocket 连接管理、消息状态)
|
||||
|
||||
### 修改
|
||||
- `submission/models.py` — 新增 conversation FK
|
||||
- `submission/schemas.py` — 提交 schema 新增 conversation_id
|
||||
- `api/urls.py` — 注册 prompt router
|
||||
- `api/asgi.py` — 更新 WebSocket 路由
|
||||
- `Home.vue` — 挑战模式下切换到新布局
|
||||
- `Submissions.vue` / `Preview.vue` — 新增查看 prompt 链条入口
|
||||
|
||||
### 删除
|
||||
- `chat/` app(被 `prompt/` 替代)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
# 挑战 AI 对话改进设计文档
|
||||
|
||||
**日期:** 2026-03-09
|
||||
|
||||
## 背景
|
||||
|
||||
挑战页面有 AI 对话功能(思维链),目前存在两个问题:
|
||||
1. AI 回复中的代码块占用大量空间,影响对话可读性
|
||||
2. 用户需要手动点击"提交作品",导致中间过程的代码版本无法被记录
|
||||
|
||||
## 需求
|
||||
|
||||
1. **AI 消息中的代码块默认折叠**,用户可点击展开查看
|
||||
2. **AI 生成代码后自动静默提交**,每轮生成代码的回复都触发一次提交,保留手动提交按钮
|
||||
|
||||
## 方案
|
||||
|
||||
### 需求一:代码块折叠
|
||||
|
||||
**修改文件:** `client/src/components/PromptPanel.vue`
|
||||
|
||||
- 在 `renderMarkdown()` 中使用自定义 marked renderer,覆盖 `code` 渲染逻辑
|
||||
- 代码块输出为原生 `<details>/<summary>` HTML,summary 显示语言名称
|
||||
- 流式输出的 `streamingContent` 使用相同 renderer
|
||||
- 加 scoped CSS 美化折叠组件样式
|
||||
|
||||
```html
|
||||
<details class="code-block">
|
||||
<summary>查看代码(javascript)</summary>
|
||||
<pre><code class="hljs language-javascript">...</code></pre>
|
||||
</details>
|
||||
```
|
||||
|
||||
### 需求二:自动提交
|
||||
|
||||
**修改文件:** `client/src/store/prompt.ts`、`client/src/pages/ChallengeHome.vue`
|
||||
|
||||
**prompt.ts:**
|
||||
- 暴露 `onCodeComplete: ((code: {...}) => void) | null` 回调
|
||||
- `complete` 事件中,当 `data.code` 存在时调用该回调
|
||||
|
||||
**ChallengeHome.vue:**
|
||||
- `loadChallenge()` 中注册 `onCodeComplete` 回调,执行静默提交(不显示 loading,失败不弹提示)
|
||||
- `onUnmounted` 中将 `onCodeComplete` 置为 `null`
|
||||
- 保留现有手动"提交作品"按钮
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `client/src/components/PromptPanel.vue`:代码块折叠渲染
|
||||
- `client/src/store/prompt.ts`:新增 `onCodeComplete` 回调
|
||||
- `client/src/pages/ChallengeHome.vue`:注册回调、自动提交逻辑
|
||||
@@ -0,0 +1,209 @@
|
||||
# Challenge AI Chat Improvements Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 折叠 AI 对话中的代码块,并在 AI 生成代码后自动静默提交作品。
|
||||
|
||||
**Architecture:** 在 `PromptPanel.vue` 中用自定义 marked renderer 将代码块渲染为 `<details>/<summary>`;在 `prompt.ts` 中暴露 `onCodeComplete` 回调,`ChallengeHome.vue` 注册回调触发静默提交。
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, marked (custom renderer), Naive UI, WebSocket (already in use)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 代码块折叠 — 自定义 marked renderer
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/PromptPanel.vue`
|
||||
|
||||
**Step 1: 在 `<script setup>` 中创建局部 marked 实例并覆盖 code renderer**
|
||||
|
||||
在 `PromptPanel.vue` 的 `<script setup>` 中,替换现有的 `renderMarkdown` 函数:
|
||||
|
||||
```ts
|
||||
import { marked, Renderer } from "marked"
|
||||
|
||||
const renderer = new Renderer()
|
||||
const _origCode = renderer.code.bind(renderer)
|
||||
renderer.code = function ({ text, lang }) {
|
||||
const label = lang ? `查看代码(${lang})` : "查看代码"
|
||||
// 对代码内容做 HTML 转义,防止 XSS
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `<details class="code-block"><summary>${label}</summary><pre><code class="hljs${lang ? ` language-${lang}` : ""}">${escaped}</code></pre></details>`
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text, { renderer }) as string
|
||||
}
|
||||
```
|
||||
|
||||
注意:`marked` 已在 `main.ts` 全局配置了 `markedHighlight`,但局部 renderer 不继承全局高亮插件。代码块在聊天中默认折叠,用户主要通过预览区查看效果,折叠后不高亮可接受。若需要高亮,可在 `details` 展开后用 `hljs.highlightAll()` 触发,属于可选优化。
|
||||
|
||||
**Step 2: 在 `<style scoped>` 中添加折叠样式**
|
||||
|
||||
```css
|
||||
.message-content :deep(details.code-block) {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.message-content :deep(details.code-block summary) {
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-content :deep(details.code-block[open] summary) {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.message-content :deep(details.code-block pre) {
|
||||
margin: 0;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 验证**
|
||||
|
||||
启动开发服务器 `cd client && npm run start`,打开一个挑战页面,发送一条会生成代码的提示词(如"做一个蓝色的圆形按钮"),确认:
|
||||
- AI 回复中的代码块显示为折叠的 `<details>` 元素
|
||||
- 点击 summary 可展开查看代码
|
||||
- 流式输出时也正确折叠
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/PromptPanel.vue
|
||||
git commit -m "feat: collapse code blocks in AI chat messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 自动提交 — prompt.ts 暴露回调
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/store/prompt.ts`
|
||||
|
||||
**Step 1: 在文件顶部添加回调导出**
|
||||
|
||||
在 `export const streamingContent` 那行后面添加:
|
||||
|
||||
```ts
|
||||
export let onCodeComplete: ((code: { html: string | null; css: string | null; js: string | null }) => void) | null = null
|
||||
```
|
||||
|
||||
**Step 2: 在 `complete` 事件处理中调用回调**
|
||||
|
||||
找到 `} else if (data.type === "complete") {` 块,在 `applyCode(data.code)` 调用后追加:
|
||||
|
||||
```ts
|
||||
if (data.code && onCodeComplete) {
|
||||
onCodeComplete(data.code)
|
||||
}
|
||||
```
|
||||
|
||||
完整的 `complete` 块变为:
|
||||
|
||||
```ts
|
||||
} else if (data.type === "complete") {
|
||||
streaming.value = false
|
||||
messages.value.push({
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
code: data.code,
|
||||
})
|
||||
streamingContent.value = ""
|
||||
if (data.code) {
|
||||
applyCode(data.code)
|
||||
if (onCodeComplete) {
|
||||
onCodeComplete(data.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 在 `disconnectPrompt` 中清除回调**
|
||||
|
||||
在 `disconnectPrompt()` 函数中添加:
|
||||
|
||||
```ts
|
||||
onCodeComplete = null
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/store/prompt.ts
|
||||
git commit -m "feat: expose onCodeComplete callback in prompt store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 自动提交 — ChallengeHome.vue 注册回调
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/ChallengeHome.vue`
|
||||
|
||||
**Step 1: 导入 `onCodeComplete`**
|
||||
|
||||
在现有 import 行中,将 `prompt` store 的导入补充 `onCodeComplete`:
|
||||
|
||||
```ts
|
||||
import { connectPrompt, disconnectPrompt, conversationId, streaming, onCodeComplete } from "../store/prompt"
|
||||
```
|
||||
|
||||
注意:`onCodeComplete` 是 `let` 导出的普通变量,不是 `ref`,直接赋值即可。
|
||||
|
||||
**Step 2: 在 `loadChallenge` 中注册静默提交回调**
|
||||
|
||||
在 `connectPrompt(data.task_ptr)` 调用后添加:
|
||||
|
||||
```ts
|
||||
onCodeComplete = async () => {
|
||||
if (!conversationId.value) return
|
||||
try {
|
||||
await Submission.create(taskId.value, {
|
||||
html: html.value,
|
||||
css: css.value,
|
||||
js: js.value,
|
||||
conversationId: conversationId.value,
|
||||
})
|
||||
} catch {
|
||||
// 静默失败,不打扰用户
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 验证**
|
||||
|
||||
在挑战页面发送生成代码的请求,打开浏览器 Network 面板,确认:
|
||||
- AI 回复完成(含代码)后,自动发出 `POST /api/submission/` 请求
|
||||
- 无任何 loading 状态或弹窗
|
||||
- 手动点击"提交作品"按钮仍然正常工作
|
||||
- 返回挑战列表再进入,`onCodeComplete` 不会指向旧页面的 submit 函数(`disconnectPrompt` 会清除)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/ChallengeHome.vue
|
||||
git commit -m "feat: auto-submit on AI code generation in challenge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- [ ] AI 对话中所有代码块默认折叠,可点击展开
|
||||
- [ ] 流式输出期间代码块也处于折叠状态
|
||||
- [ ] 每次 AI 生成含代码的回复后,自动静默提交一次
|
||||
- [ ] 自动提交失败时无任何 UI 反馈(静默)
|
||||
- [ ] 手动"提交作品"按钮功能不受影响
|
||||
- [ ] 离开挑战页面后回调被正确清理,不产生内存泄漏
|
||||
@@ -0,0 +1,113 @@
|
||||
# 挑战分数系统设计
|
||||
|
||||
**日期**: 2026-03-09
|
||||
**状态**: 已审批
|
||||
|
||||
## 背景
|
||||
|
||||
`Submission.score` 已有加权平均评分逻辑,`Profile.total_score` 字段存在但从未激活。分数计算出来后无任何展示或用途,需要设计让分数真正发挥作用的功能。
|
||||
|
||||
## 目标
|
||||
|
||||
- 学生:看到自己各题得分和在全班的排名
|
||||
- 老师:快速了解哪些学生表现好,可按条件导出成绩
|
||||
- 为未来解锁机制预留接口
|
||||
|
||||
## 方案选择
|
||||
|
||||
采用**方案 A(积分展示中心)**,在 `Challenge` 模型预留 `pass_score` 字段供日后解锁功能使用。
|
||||
|
||||
---
|
||||
|
||||
## 数据层
|
||||
|
||||
### Profile.recalculate_total_score()
|
||||
|
||||
替换现有 `update_total_score()` 的累加逻辑,改为从数据库重算:
|
||||
|
||||
```python
|
||||
def recalculate_total_score(self):
|
||||
from submission.models import Submission
|
||||
from django.db.models import Max, Sum
|
||||
# 每道挑战取最高分,再求和
|
||||
total = (
|
||||
Submission.objects
|
||||
.filter(user=self.user, task__task_type='challenge')
|
||||
.values('task_id')
|
||||
.annotate(best=Max('score'))
|
||||
.aggregate(total=Sum('best'))['total']
|
||||
) or 0.0
|
||||
self.total_score = total
|
||||
self.save(update_fields=['total_score'])
|
||||
```
|
||||
|
||||
### Rating post_save 信号
|
||||
|
||||
在现有 `update_submission_score_on_save` 之后,追加调用:
|
||||
|
||||
```python
|
||||
instance.submission.user.profile.recalculate_total_score()
|
||||
```
|
||||
|
||||
### Challenge.pass_score 字段
|
||||
|
||||
```python
|
||||
pass_score = models.FloatField(null=True, blank=True, verbose_name="通过分数线")
|
||||
```
|
||||
|
||||
默认 null,不影响现有功能,为解锁机制预留。
|
||||
|
||||
### 用户最佳分数定义
|
||||
|
||||
同一用户同一挑战取 `score` 最高的提交作为该题成绩。
|
||||
|
||||
---
|
||||
|
||||
## API 层
|
||||
|
||||
### 1. 排行榜
|
||||
|
||||
```
|
||||
GET /api/account/leaderboard/
|
||||
```
|
||||
|
||||
- 无需登录
|
||||
- 返回所有用户按 `total_score` 降序,含排名、用户名、总分
|
||||
|
||||
### 2. 我的各题得分
|
||||
|
||||
```
|
||||
GET /api/submission/my-scores/
|
||||
```
|
||||
|
||||
- 需登录
|
||||
- 返回当前用户每道挑战的最高分提交(challenge display、title、score、提交时间)
|
||||
|
||||
### 3. 成绩导出
|
||||
|
||||
```
|
||||
GET /api/submission/export/?task_id=&username=
|
||||
```
|
||||
|
||||
- 仅限 admin/super 角色
|
||||
- 复用现有 `SubmissionFilter` 参数
|
||||
- 返回 CSV 文件流,每行:用户名、挑战题号、挑战标题、最高分、提交时间
|
||||
|
||||
---
|
||||
|
||||
## 前端
|
||||
|
||||
| 改动点 | 说明 |
|
||||
|--------|------|
|
||||
| 挑战列表卡片 | 显示该用户最高得分(灰色小标,未评分不显示) |
|
||||
| 新页面 `/leaderboard` | 排名/用户名/总分表格,所有人可访问,放主页导航 |
|
||||
| 新页面 `/my-scores` | 我的各题得分列表,登录后可见,放主页用户区域 |
|
||||
| Submissions 页面 | 现有筛选栏旁加"导出 CSV"按钮,触发浏览器下载 |
|
||||
|
||||
---
|
||||
|
||||
## 不在本期范围内
|
||||
|
||||
- 解锁机制(`pass_score` 字段已预留,后续单独设计)
|
||||
- 班级/分组维度的统计
|
||||
- 实时分数推送(WebSocket)
|
||||
@@ -0,0 +1,739 @@
|
||||
# 挑战分数系统 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 让挑战评分真正可见可用——激活 Profile 总分、新增排行榜/我的成绩页面、挑战卡片显示最高得分、管理员可导出 CSV。
|
||||
|
||||
**Architecture:** 后端激活 `Profile.total_score` 自动重算逻辑,在 `account` 和 `submission` 路由各新增 2 个端点;前端新增两个独立页面(排行榜、我的成绩),并在挑战卡片和提交列表页做小改动。
|
||||
|
||||
**Tech Stack:** Django + django-ninja(后端);Vue 3 + TypeScript + Naive UI(前端);`uv run` 执行 Django 命令,`npm run start` 启动前端
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 激活 Profile 总分自动重算
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/account/models.py`
|
||||
- Modify: `api/submission/models.py`
|
||||
|
||||
**Step 1: 替换 update_total_score 为 recalculate_total_score**
|
||||
|
||||
打开 `api/account/models.py`,将 `Profile.update_total_score` 方法替换为:
|
||||
|
||||
```python
|
||||
def recalculate_total_score(self):
|
||||
from django.db.models import Max, Sum
|
||||
from submission.models import Submission
|
||||
total = (
|
||||
Submission.objects
|
||||
.filter(user=self.user, task__task_type="challenge", score__gt=0)
|
||||
.values("task_id")
|
||||
.annotate(best=Max("score"))
|
||||
.aggregate(total=Sum("best"))["total"]
|
||||
) or 0.0
|
||||
self.total_score = total
|
||||
self.save(update_fields=["total_score"])
|
||||
```
|
||||
|
||||
**Step 2: 在 Rating post_save 信号里追加调用**
|
||||
|
||||
打开 `api/submission/models.py`,找到 `update_submission_score_on_save` 函数,在 `instance.submission.update_score()` 之后追加:
|
||||
|
||||
```python
|
||||
instance.submission.user.profile.recalculate_total_score()
|
||||
```
|
||||
|
||||
完整信号函数变为:
|
||||
|
||||
```python
|
||||
@receiver(post_save, sender=Rating)
|
||||
def update_submission_score_on_save(sender, instance, **kwargs):
|
||||
instance.submission.update_score()
|
||||
instance.submission.user.profile.recalculate_total_score()
|
||||
```
|
||||
|
||||
**Step 3: 验证**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
期望:`System check identified no issues (0 silenced).`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/account/models.py api/submission/models.py
|
||||
git commit -m "feat: activate Profile.total_score auto-recalculation on rating"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Challenge 模型添加 pass_score 字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/task/models.py`
|
||||
|
||||
**Step 1: 添加字段**
|
||||
|
||||
打开 `api/task/models.py`,在 `Challenge` 类的 `score = models.IntegerField(default=0)` 后追加:
|
||||
|
||||
```python
|
||||
pass_score = models.FloatField(null=True, blank=True, verbose_name="通过分数线")
|
||||
```
|
||||
|
||||
**Step 2: 生成并应用迁移**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run manage.py makemigrations task
|
||||
uv run manage.py migrate
|
||||
```
|
||||
|
||||
期望:迁移成功,无报错。
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add api/task/migrations/ api/task/models.py
|
||||
git commit -m "feat: add pass_score field to Challenge for future unlock mechanism"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 排行榜 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/account/schemas.py`
|
||||
- Modify: `api/account/api.py`
|
||||
|
||||
**Step 1: 添加 LeaderboardEntry schema**
|
||||
|
||||
打开 `api/account/schemas.py`,在文件末尾追加:
|
||||
|
||||
```python
|
||||
class LeaderboardEntry(Schema):
|
||||
rank: int
|
||||
username: str
|
||||
total_score: float
|
||||
```
|
||||
|
||||
**Step 2: 添加端点**
|
||||
|
||||
打开 `api/account/api.py`,在文件末尾追加:
|
||||
|
||||
```python
|
||||
from typing import List as TypingList
|
||||
|
||||
@router.get("/leaderboard", response=TypingList["LeaderboardEntry"])
|
||||
def leaderboard(request):
|
||||
"""
|
||||
排行榜:所有用户按总分降序
|
||||
"""
|
||||
from .schemas import LeaderboardEntry
|
||||
from .models import Profile
|
||||
profiles = (
|
||||
Profile.objects
|
||||
.select_related("user")
|
||||
.filter(total_score__gt=0)
|
||||
.order_by("-total_score")
|
||||
)
|
||||
return [
|
||||
LeaderboardEntry(rank=i + 1, username=p.user.username, total_score=p.total_score)
|
||||
for i, p in enumerate(profiles)
|
||||
]
|
||||
```
|
||||
|
||||
注意:`LeaderboardEntry` 需要在 `api/account/schemas.py` 中导入,在 `api/account/api.py` 顶部的 import 处补充:
|
||||
|
||||
```python
|
||||
from .schemas import (
|
||||
BatchUsersIn,
|
||||
UserListSchema,
|
||||
UserRegistrationSchema,
|
||||
UserLoginSchema,
|
||||
LeaderboardEntry,
|
||||
)
|
||||
```
|
||||
|
||||
并将端点函数里的局部 import 去掉。
|
||||
|
||||
**Step 3: 验证**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/account/schemas.py api/account/api.py
|
||||
git commit -m "feat: add leaderboard API endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 我的成绩 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py`
|
||||
- Modify: `api/submission/api.py`
|
||||
|
||||
**Step 1: 添加 MyScoreOut schema**
|
||||
|
||||
打开 `api/submission/schemas.py`,末尾追加:
|
||||
|
||||
```python
|
||||
class MyScoreOut(Schema):
|
||||
task_id: int
|
||||
task_display: int
|
||||
task_title: str
|
||||
score: float
|
||||
created: str
|
||||
```
|
||||
|
||||
**Step 2: 添加端点**
|
||||
|
||||
打开 `api/submission/api.py`,末尾追加(注意顶部要导入 `MyScoreOut`):
|
||||
|
||||
在顶部 imports 的 schemas 导入里加 `MyScoreOut`:
|
||||
|
||||
```python
|
||||
from .schemas import (
|
||||
FlagIn,
|
||||
SubmissionFilter,
|
||||
SubmissionIn,
|
||||
SubmissionOut,
|
||||
RatingScoreIn,
|
||||
MyScoreOut,
|
||||
)
|
||||
```
|
||||
|
||||
末尾追加端点:
|
||||
|
||||
```python
|
||||
from django.db.models import Max
|
||||
|
||||
@router.get("/my-scores", response=List[MyScoreOut])
|
||||
@login_required
|
||||
def my_scores(request):
|
||||
"""
|
||||
当前用户每道挑战的最高分提交
|
||||
"""
|
||||
from django.db.models import OuterRef, Subquery
|
||||
# 取每个 task 的最高分提交
|
||||
best_submissions = (
|
||||
Submission.objects
|
||||
.filter(user=request.user, task__task_type="challenge")
|
||||
.order_by("task_id", "-score")
|
||||
.distinct("task_id")
|
||||
.select_related("task")
|
||||
)
|
||||
return [
|
||||
MyScoreOut(
|
||||
task_id=s.task_id,
|
||||
task_display=s.task.display,
|
||||
task_title=s.task.title,
|
||||
score=s.score,
|
||||
created=s.created.isoformat(),
|
||||
)
|
||||
for s in best_submissions
|
||||
]
|
||||
```
|
||||
|
||||
> **注意**:`distinct("task_id")` 是 PostgreSQL 专用语法(`DISTINCT ON`)。开发环境使用 SQLite 时这行会报错。需用 Python 端去重替代:
|
||||
>
|
||||
> ```python
|
||||
> seen = {}
|
||||
> for s in Submission.objects.filter(
|
||||
> user=request.user, task__task_type="challenge"
|
||||
> ).order_by("-score").select_related("task"):
|
||||
> if s.task_id not in seen:
|
||||
> seen[s.task_id] = s
|
||||
> best_submissions = seen.values()
|
||||
> ```
|
||||
>
|
||||
> 根据实际运行环境选择。开发用 SQLite 请用 Python 端去重版本。
|
||||
|
||||
**Step 3: 验证**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/schemas.py api/submission/api.py
|
||||
git commit -m "feat: add my-scores API endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CSV 导出 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/api.py`
|
||||
|
||||
**Step 1: 添加端点**
|
||||
|
||||
在 `api/submission/api.py` 末尾追加:
|
||||
|
||||
```python
|
||||
import csv
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
@router.get("/export")
|
||||
@login_required
|
||||
def export_submissions(request, filters: SubmissionFilter = Query(...)):
|
||||
"""
|
||||
导出提交成绩 CSV(仅管理员/超级管理员)
|
||||
"""
|
||||
if request.user.role not in (RoleChoices.SUPER, RoleChoices.ADMIN):
|
||||
raise HttpError(403, "没有权限")
|
||||
|
||||
from django.db.models import Max
|
||||
qs = (
|
||||
Submission.objects
|
||||
.filter(task__task_type="challenge")
|
||||
.select_related("task", "user")
|
||||
)
|
||||
if filters.task_id:
|
||||
qs = qs.filter(task_id=filters.task_id)
|
||||
if filters.username:
|
||||
qs = qs.filter(user__username__icontains=filters.username)
|
||||
|
||||
# 每个用户每道题取最高分(Python 端聚合,兼容 SQLite)
|
||||
best: dict[tuple, dict] = {}
|
||||
for s in qs.order_by("-score"):
|
||||
key = (s.user.username, s.task_id)
|
||||
if key not in best:
|
||||
best[key] = {
|
||||
"username": s.user.username,
|
||||
"task_display": s.task.display,
|
||||
"task_title": s.task.title,
|
||||
"score": s.score,
|
||||
"created": s.created.isoformat(),
|
||||
}
|
||||
|
||||
def rows():
|
||||
yield "用户名,题号,标题,最高分,提交时间\n"
|
||||
for r in best.values():
|
||||
yield f"{r['username']},{r['task_display']},{r['task_title']},{r['score']:.2f},{r['created']}\n"
|
||||
|
||||
response = StreamingHttpResponse(rows(), content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = 'attachment; filename="submissions.csv"'
|
||||
return response
|
||||
```
|
||||
|
||||
**Step 2: 验证**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/api.py
|
||||
git commit -m "feat: add CSV export API for admin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 API 方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/api.ts`
|
||||
|
||||
**Step 1: 添加 Account.leaderboard**
|
||||
|
||||
在 `api.ts` 的 `Account` 对象里,`batchCreate` 方法之后追加:
|
||||
|
||||
```typescript
|
||||
async leaderboard() {
|
||||
const res = await http.get("/account/leaderboard")
|
||||
return res.data as { rank: number; username: string; total_score: number }[]
|
||||
},
|
||||
```
|
||||
|
||||
**Step 2: 添加 Submission.myScores 和 Submission.export**
|
||||
|
||||
在 `api.ts` 的 `Submission` 对象里,`updateFlag` 之后追加:
|
||||
|
||||
```typescript
|
||||
async myScores() {
|
||||
const res = await http.get("/submission/my-scores")
|
||||
return res.data as {
|
||||
task_id: number
|
||||
task_display: number
|
||||
task_title: string
|
||||
score: number
|
||||
created: string
|
||||
}[]
|
||||
},
|
||||
|
||||
async exportCsv(params: { task_id?: number; username?: string }) {
|
||||
const res = await http.get("/submission/export", {
|
||||
params,
|
||||
responseType: "blob",
|
||||
})
|
||||
return res.data as Blob
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/api.ts
|
||||
git commit -m "feat: add leaderboard, myScores, exportCsv API methods"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 排行榜页面和路由
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/Leaderboard.vue`
|
||||
- Modify: `client/src/router.ts`
|
||||
|
||||
**Step 1: 创建 Leaderboard.vue**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="container">
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<n-button secondary @click="$router.back()">返回</n-button>
|
||||
<span style="font-weight: bold; font-size: 18px">排行榜</span>
|
||||
<div style="width: 60px" />
|
||||
</n-flex>
|
||||
<n-data-table :columns="columns" :data="data" :loading="loading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from "vue"
|
||||
import { Account } from "../api"
|
||||
import type { DataTableColumn } from "naive-ui"
|
||||
|
||||
const data = ref<{ rank: number; username: string; total_score: number }[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns: DataTableColumn<{ rank: number; username: string; total_score: number }>[] = [
|
||||
{ title: "排名", key: "rank", width: 70 },
|
||||
{ title: "用户名", key: "username" },
|
||||
{
|
||||
title: "总分",
|
||||
key: "total_score",
|
||||
render: (row) => h("span", { style: "font-weight: bold" }, row.total_score.toFixed(2)),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await Account.leaderboard()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 添加路由**
|
||||
|
||||
打开 `client/src/router.ts`,在 `routes` 数组里(submission 路由之后)追加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/leaderboard",
|
||||
name: "leaderboard",
|
||||
component: () => import("./pages/Leaderboard.vue"),
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Leaderboard.vue client/src/router.ts
|
||||
git commit -m "feat: add leaderboard page and route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 我的成绩页面和路由
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/MyScores.vue`
|
||||
- Modify: `client/src/router.ts`
|
||||
|
||||
**Step 1: 创建 MyScores.vue**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="container">
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||
<n-button secondary @click="$router.back()">返回</n-button>
|
||||
<span style="font-weight: bold; font-size: 18px">我的成绩</span>
|
||||
<div style="width: 60px" />
|
||||
</n-flex>
|
||||
<n-empty v-if="!loading && !data.length" description="暂无评分记录" />
|
||||
<n-data-table v-else :columns="columns" :data="data" :loading="loading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from "vue"
|
||||
import { Submission } from "../api"
|
||||
import type { DataTableColumn } from "naive-ui"
|
||||
import { parseTime } from "../utils/helper"
|
||||
|
||||
type MyScore = {
|
||||
task_id: number
|
||||
task_display: number
|
||||
task_title: string
|
||||
score: number
|
||||
created: string
|
||||
}
|
||||
|
||||
const data = ref<MyScore[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns: DataTableColumn<MyScore>[] = [
|
||||
{ title: "题号", key: "task_display", width: 70 },
|
||||
{ title: "标题", key: "task_title" },
|
||||
{
|
||||
title: "最高得分",
|
||||
key: "score",
|
||||
render: (row) =>
|
||||
row.score > 0
|
||||
? h("span", { style: "font-weight: bold; color: #18a058" }, row.score.toFixed(2))
|
||||
: h("span", { style: "color: #999" }, "未评分"),
|
||||
},
|
||||
{
|
||||
title: "提交时间",
|
||||
key: "created",
|
||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await Submission.myScores()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 添加路由(需要登录)**
|
||||
|
||||
在 `client/src/router.ts` 里追加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: "/my-scores",
|
||||
name: "my-scores",
|
||||
component: () => import("./pages/MyScores.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/MyScores.vue client/src/router.ts
|
||||
git commit -m "feat: add my-scores page and route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 挑战卡片显示用户最高得分
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/Challenge.vue`
|
||||
|
||||
**Step 1: 加载 my-scores 并映射**
|
||||
|
||||
打开 `client/src/components/Challenge.vue`,在 `<script setup>` 里追加:
|
||||
|
||||
```typescript
|
||||
import { Submission } from "../api"
|
||||
// ...在现有 import 之后
|
||||
|
||||
const myScoreMap = ref<Map<number, number>>(new Map())
|
||||
|
||||
async function loadMyScores() {
|
||||
try {
|
||||
const scores = await Submission.myScores()
|
||||
myScoreMap.value = new Map(scores.map((s) => [s.task_display, s.score]))
|
||||
} catch {
|
||||
// 未登录时忽略
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `onMounted(loadList)` 改为:
|
||||
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
await loadList()
|
||||
await loadMyScores()
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: 在卡片上显示**
|
||||
|
||||
在 `<template>` 中找到挑战卡片部分,在 `<template #header-extra>` 里的 `n-tag` 后追加:
|
||||
|
||||
```html
|
||||
<n-tag
|
||||
v-if="myScoreMap.get(item.display)"
|
||||
type="success"
|
||||
size="small"
|
||||
style="margin-left: 4px"
|
||||
>
|
||||
得分 {{ myScoreMap.get(item.display)!.toFixed(1) }}
|
||||
</n-tag>
|
||||
```
|
||||
|
||||
完整 `header-extra` 变为:
|
||||
|
||||
```html
|
||||
<template #header-extra>
|
||||
<n-tag type="warning" size="small">{{ item.score }}分</n-tag>
|
||||
<n-tag
|
||||
v-if="myScoreMap.get(item.display)"
|
||||
type="success"
|
||||
size="small"
|
||||
style="margin-left: 4px"
|
||||
>
|
||||
得分 {{ myScoreMap.get(item.display)!.toFixed(1) }}
|
||||
</n-tag>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/Challenge.vue
|
||||
git commit -m "feat: show user's best score on challenge cards"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Submissions 页面添加导出按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Submissions.vue`
|
||||
|
||||
**Step 1: 添加导出函数**
|
||||
|
||||
在 `<script setup>` 里(靠近其他函数处)追加:
|
||||
|
||||
```typescript
|
||||
async function exportCsv() {
|
||||
const params: { username?: string; task_id?: number } = {}
|
||||
if (query.username) params.username = query.username
|
||||
const blob = await Submission.exportCsv(params)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "submissions.csv"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加导出按钮**
|
||||
|
||||
在 `<template>` 里,找到 `<n-flex align="center">` 筛选栏区域(含 `n-select`、`n-input`、`n-pagination`)。在 `n-pagination` 之后追加(仅 admin 可见):
|
||||
|
||||
```html
|
||||
<n-button v-if="isAdmin" secondary @click="exportCsv">导出 CSV</n-button>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Submissions.vue
|
||||
git commit -m "feat: add CSV export button for admin in submissions page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 添加排行榜和我的成绩导航入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/Task.vue`
|
||||
|
||||
**Step 1: 添加按钮**
|
||||
|
||||
打开 `client/src/components/Task.vue`,在 `<n-flex>` 右侧按钮区(含 submissions 图标按钮和 edit 按钮)里,在 submissions 按钮之后追加两个按钮:
|
||||
|
||||
```html
|
||||
<n-button
|
||||
text
|
||||
@click="$router.push({ name: 'leaderboard' })"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:trophy" />
|
||||
</n-button>
|
||||
<n-button
|
||||
text
|
||||
v-if="isLoggedIn"
|
||||
@click="$router.push({ name: 'my-scores' })"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||
</n-button>
|
||||
```
|
||||
|
||||
**Step 2: 引入 isLoggedIn**
|
||||
|
||||
在 `<script setup>` 的 import 区追加:
|
||||
|
||||
```typescript
|
||||
import { STORAGE_KEY } from "../utils/const"
|
||||
import { computed } from "vue"
|
||||
const isLoggedIn = computed(() => localStorage.getItem(STORAGE_KEY.LOGIN) === "true")
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/Task.vue
|
||||
git commit -m "feat: add leaderboard and my-scores navigation buttons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成验证
|
||||
|
||||
手动测试清单:
|
||||
|
||||
1. 给某个提交打分后,检查该用户 `Profile.total_score` 是否更新(Django shell: `User.objects.get(username='xxx').profile.total_score`)
|
||||
2. 访问 `/leaderboard`,确认显示用户排名表格
|
||||
3. 登录后访问 `/my-scores`,确认显示各题最高得分
|
||||
4. 挑战列表页,确认有评分的挑战卡片上显示绿色"得分 X.X"标签
|
||||
5. 以 admin 账号进入 Submissions 页面,确认出现"导出 CSV"按钮,点击后浏览器下载 CSV 文件
|
||||
@@ -0,0 +1,55 @@
|
||||
# Submission Flag Design
|
||||
|
||||
## Overview
|
||||
|
||||
Add colored flag markers to submission records. Teachers/admins mark submissions; students can see flags but not modify them.
|
||||
|
||||
## Flag Types
|
||||
|
||||
| Color | Value | Meaning |
|
||||
|--------|----------|----------|
|
||||
| Red | `red` | Worth showcasing |
|
||||
| Blue | `blue` | Needs explanation |
|
||||
| Green | `green` | Excellent work |
|
||||
| Yellow | `yellow` | Needs improvement |
|
||||
|
||||
Each submission can have at most one flag. Setting a new flag replaces the old one. Setting `null` removes the flag.
|
||||
|
||||
## Approach: Single Field on Submission Model
|
||||
|
||||
Add a nullable `flag` CharField with choices to the Submission model. No separate table needed.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Model
|
||||
|
||||
`Submission.flag` — `CharField(max_length=10, choices=FLAG_CHOICES, null=True, blank=True, default=None)`
|
||||
|
||||
### API
|
||||
|
||||
- `PUT /api/submission/{id}/flag` — set or clear flag, restricted to ADMIN/SUPER roles
|
||||
- Input: `{ flag: "red" | "blue" | "green" | "yellow" | null }`
|
||||
- Returns updated submission
|
||||
- `GET /api/submission/` — add `flag` to SubmissionOut schema and SubmissionFilter
|
||||
|
||||
### Migration
|
||||
|
||||
One migration adding the `flag` field.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### Submissions Table (Submissions.vue)
|
||||
|
||||
- New "flag" column displaying a colored flag icon
|
||||
- Teachers/admins: click to open a popover with 4 color options + clear
|
||||
- Students: icon display only, no interaction
|
||||
|
||||
### Filtering
|
||||
|
||||
- Add flag color dropdown filter alongside existing username filter
|
||||
- Options: all / red / blue / green / yellow / flagged (any) / unflagged
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Mark/unmark**: SUPER and ADMIN roles only
|
||||
- **View flags**: all authenticated users
|
||||
@@ -0,0 +1,454 @@
|
||||
# Submission Flag Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add colored flag markers (red/blue/green/yellow) to submissions, settable by teachers/admins, visible to all users, with list filtering support.
|
||||
|
||||
**Architecture:** Add a nullable `flag` CharField to the Submission model. New PUT endpoint for setting/clearing flags with role check. Frontend shows flag icons in the table with a popover for admins to pick colors, plus a dropdown filter.
|
||||
|
||||
**Tech Stack:** Django + django-ninja (backend), Vue 3 + Naive UI + TypeScript (frontend)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `flag` field to Submission model
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/models.py:1-13` (add FLAG_CHOICES and field)
|
||||
|
||||
**Step 1: Add FLAG_CHOICES and field to model**
|
||||
|
||||
In `api/submission/models.py`, add after the imports (before the class):
|
||||
|
||||
```python
|
||||
class FlagChoices(models.TextChoices):
|
||||
RED = "red", "值得展示"
|
||||
BLUE = "blue", "需要讲解"
|
||||
GREEN = "green", "优秀作品"
|
||||
YELLOW = "yellow", "需要改进"
|
||||
```
|
||||
|
||||
In the `Submission` class, add after the `conversation` field (line 33):
|
||||
|
||||
```python
|
||||
flag = models.CharField(
|
||||
max_length=10,
|
||||
choices=FlagChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name="标记",
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Create and run migration**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd api && uv run manage.py makemigrations submission && uv run manage.py migrate
|
||||
```
|
||||
|
||||
Expected: Migration created and applied successfully.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/models.py api/submission/migrations/
|
||||
git commit -m "feat: add flag field to Submission model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add flag to API schemas and filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py`
|
||||
|
||||
**Step 1: Update SubmissionOut schema**
|
||||
|
||||
In `api/submission/schemas.py`, add to the `SubmissionOut` class fields (after line 27 `conversation_id`):
|
||||
|
||||
```python
|
||||
flag: Optional[str] = None
|
||||
```
|
||||
|
||||
**Step 2: Update SubmissionOut.list() static method**
|
||||
|
||||
Add `"flag": submission.flag,` to the return dict in `list()` (after the `conversation_id` line):
|
||||
|
||||
```python
|
||||
"flag": submission.flag,
|
||||
```
|
||||
|
||||
**Step 3: Update SubmissionOut.get() static method**
|
||||
|
||||
Add `"flag": submission.flag,` to the return dict in `get()` (after the `conversation_id` line):
|
||||
|
||||
```python
|
||||
"flag": submission.flag,
|
||||
```
|
||||
|
||||
**Step 4: Update SubmissionFilter**
|
||||
|
||||
Add flag filter field to `SubmissionFilter`:
|
||||
|
||||
```python
|
||||
class SubmissionFilter(Schema):
|
||||
task_id: Optional[int] = None
|
||||
task_type: Optional[Literal["tutorial", "challenge"]] = None
|
||||
username: Optional[str] = None
|
||||
flag: Optional[Literal["red", "blue", "green", "yellow"]] = None
|
||||
```
|
||||
|
||||
**Step 5: Add FlagIn schema**
|
||||
|
||||
Add at the end of `schemas.py`:
|
||||
|
||||
```python
|
||||
class FlagIn(Schema):
|
||||
flag: Optional[Literal["red", "blue", "green", "yellow"]] = None
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/schemas.py
|
||||
git commit -m "feat: add flag to submission schemas and filter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add flag API endpoint and filter logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/api.py`
|
||||
|
||||
**Step 1: Add flag filter to list_submissions**
|
||||
|
||||
In `api/submission/api.py`, in the `list_submissions` function, add after the username filter block (after line 65):
|
||||
|
||||
```python
|
||||
if filters.flag:
|
||||
submissions = submissions.filter(flag=filters.flag)
|
||||
```
|
||||
|
||||
**Step 2: Add flag endpoint**
|
||||
|
||||
Import `FlagIn` in the imports from `.schemas`. Add the new endpoint after `update_score`:
|
||||
|
||||
```python
|
||||
@router.put("/{submission_id}/flag")
|
||||
@login_required
|
||||
def update_flag(request, submission_id: UUID, payload: FlagIn):
|
||||
"""
|
||||
设置或清除提交的标记(仅管理员和超级管理员可操作)
|
||||
"""
|
||||
if request.user.role not in (RoleChoices.SUPER, RoleChoices.ADMIN):
|
||||
raise HttpError(403, "没有权限")
|
||||
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
submission.flag = payload.flag
|
||||
submission.save(update_fields=["flag"])
|
||||
return {"flag": submission.flag}
|
||||
```
|
||||
|
||||
Also add `RoleChoices` to the imports from `account.models`:
|
||||
|
||||
```python
|
||||
from account.models import RoleChoices
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/api.py
|
||||
git commit -m "feat: add flag endpoint and filter logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update frontend types and API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts`
|
||||
- Modify: `client/src/api.ts`
|
||||
- Modify: `client/src/store/submission.ts`
|
||||
|
||||
**Step 1: Add flag type to TypeScript interfaces**
|
||||
|
||||
In `client/src/utils/type.ts`, add the FlagType and update interfaces:
|
||||
|
||||
```typescript
|
||||
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
||||
```
|
||||
|
||||
Add `flag?: FlagType` to `SubmissionOut` (after `conversation_id`):
|
||||
|
||||
```typescript
|
||||
export interface SubmissionOut {
|
||||
id: string
|
||||
userid: number
|
||||
username: string
|
||||
task_display: number
|
||||
task_type: TASK_TYPE
|
||||
task_title: string
|
||||
score: number
|
||||
my_score: number
|
||||
conversation_id?: string
|
||||
flag?: FlagType
|
||||
created: Date
|
||||
modified: Date
|
||||
}
|
||||
```
|
||||
|
||||
Add `flag?: FlagType` to `SubmissionAll` (after `my_score`):
|
||||
|
||||
```typescript
|
||||
export interface SubmissionAll {
|
||||
id: string
|
||||
userid: number
|
||||
username: string
|
||||
task_id: number
|
||||
task_display: number
|
||||
task_type: TASK_TYPE
|
||||
task_title: string
|
||||
score: number
|
||||
my_score: number
|
||||
flag?: FlagType
|
||||
html: ""
|
||||
css: ""
|
||||
js: ""
|
||||
created: Date
|
||||
modified: Date
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add updateFlag to API client**
|
||||
|
||||
In `client/src/api.ts`, add to the `Submission` object:
|
||||
|
||||
```typescript
|
||||
async updateFlag(id: string, flag: FlagType) {
|
||||
const res = await http.put(`/submission/${id}/flag`, { flag })
|
||||
return res.data
|
||||
},
|
||||
```
|
||||
|
||||
Also add the import at the top:
|
||||
|
||||
```typescript
|
||||
import type { TutorialIn, ChallengeIn, FlagType } from "./utils/type"
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/utils/type.ts client/src/api.ts client/src/store/submission.ts
|
||||
git commit -m "feat: add flag type and API method to frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add flag column and interaction to Submissions.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Submissions.vue`
|
||||
|
||||
**Step 1: Add imports and flag config**
|
||||
|
||||
In the `<script setup>` section, add imports:
|
||||
|
||||
```typescript
|
||||
import { roleAdmin, roleSuper } from "../store/user"
|
||||
import type { FlagType } from "../utils/type"
|
||||
```
|
||||
|
||||
Add flag color config after the existing refs:
|
||||
|
||||
```typescript
|
||||
const FLAG_OPTIONS: { value: 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 isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
||||
```
|
||||
|
||||
**Step 2: Add flag update function**
|
||||
|
||||
```typescript
|
||||
async function updateFlag(row: SubmissionOut, flag: FlagType) {
|
||||
await Submission.updateFlag(row.id, flag)
|
||||
row.flag = flag
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add flag column to columns array**
|
||||
|
||||
Add as the first column in the `columns` array (before the "时间" column):
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: "标记",
|
||||
key: "flag",
|
||||
width: 50,
|
||||
render: (row) => {
|
||||
const flagOption = FLAG_OPTIONS.find((f) => f.value === row.flag)
|
||||
const flagIcon = h("span", {
|
||||
style: {
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: flagOption ? flagOption.color : "transparent",
|
||||
border: flagOption ? "none" : "1px dashed #ccc",
|
||||
cursor: isAdmin.value ? "pointer" : "default",
|
||||
},
|
||||
})
|
||||
|
||||
if (!isAdmin.value) return flagIcon
|
||||
|
||||
return h(
|
||||
NPopover,
|
||||
{ trigger: "click" },
|
||||
{
|
||||
trigger: () => flagIcon,
|
||||
default: () =>
|
||||
h(NSpace, { vertical: true, size: "small" }, () => [
|
||||
...FLAG_OPTIONS.map((opt) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => updateFlag(row, opt.value),
|
||||
},
|
||||
() =>
|
||||
h("span", { style: { display: "flex", alignItems: "center", gap: "6px" } }, [
|
||||
h("span", {
|
||||
style: {
|
||||
display: "inline-block",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: opt.color,
|
||||
},
|
||||
}),
|
||||
opt.label,
|
||||
]),
|
||||
),
|
||||
),
|
||||
row.flag
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "error",
|
||||
onClick: () => updateFlag(row, null),
|
||||
},
|
||||
() => "清除",
|
||||
)
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Step 4: Add NPopover and NSpace to imports**
|
||||
|
||||
Update the naive-ui import:
|
||||
|
||||
```typescript
|
||||
import { NButton, NPopover, NSpace, type DataTableColumn } from "naive-ui"
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Submissions.vue
|
||||
git commit -m "feat: add flag column with color picker to submissions table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add flag filter dropdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Submissions.vue`
|
||||
|
||||
**Step 1: Add flag to query reactive object**
|
||||
|
||||
Update the `query` reactive:
|
||||
|
||||
```typescript
|
||||
const query = reactive({
|
||||
page: Number(route.params.page),
|
||||
username: route.query.username ?? "",
|
||||
flag: "" as string,
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Add flag filter dropdown in template**
|
||||
|
||||
In the template, inside the `<n-flex align="center">` block (before the username input), add:
|
||||
|
||||
```html
|
||||
<n-select
|
||||
v-model:value="query.flag"
|
||||
style="width: 100px"
|
||||
clearable
|
||||
placeholder="标记"
|
||||
:options="[
|
||||
{ label: '红旗', value: 'red' },
|
||||
{ label: '蓝旗', value: 'blue' },
|
||||
{ label: '绿旗', value: 'green' },
|
||||
{ label: '黄旗', value: 'yellow' },
|
||||
]"
|
||||
/>
|
||||
```
|
||||
|
||||
**Step 3: Add watcher for flag filter**
|
||||
|
||||
Add a watch for the flag filter (alongside the existing username watcher):
|
||||
|
||||
```typescript
|
||||
watch(
|
||||
() => query.flag,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/Submissions.vue
|
||||
git commit -m "feat: add flag filter dropdown to submissions list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Manual verification
|
||||
|
||||
**Step 1: Start dev servers**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py runserver &
|
||||
cd client && npm run start &
|
||||
```
|
||||
|
||||
**Step 2: Verify the following**
|
||||
|
||||
1. Submissions list shows the flag column
|
||||
2. As admin/super: clicking the dot opens a color picker popover
|
||||
3. Selecting a color sets the flag (colored dot appears)
|
||||
4. Clicking "清除" removes the flag
|
||||
5. As normal user: flag dot is visible but not clickable
|
||||
6. Flag filter dropdown filters the list correctly
|
||||
7. Page refresh preserves flag state (persisted in DB)
|
||||
|
||||
**Step 3: Final commit if any fixes needed**
|
||||
@@ -0,0 +1,206 @@
|
||||
# 挑战 AI 对话功能分析:如何更好地通过对话写出 Web 前端页面
|
||||
|
||||
**日期:** 2026-05-04
|
||||
|
||||
## 背景
|
||||
|
||||
WebPreview 的挑战页已经具备 AI 对话生成网页的基本闭环:学生输入需求,后端通过 WebSocket 流式调用大模型,提取 `html/css/js`,前端自动应用到预览区,并在生成完成后自动创建一次提交。提交可以反查提示词链,用于回看作品迭代过程。
|
||||
|
||||
相关核心文件:
|
||||
|
||||
- `client/src/pages/ChallengeDetail.vue`:挑战页布局、AI 生成后自动提交。
|
||||
- `client/src/store/prompt.ts`:WebSocket 状态、消息列表、流式内容、代码应用。
|
||||
- `client/src/components/ai/PromptPanel.vue`:对话 UI、发送/停止、消息渲染。
|
||||
- `api/prompt/consumers.py`:WebSocket 连接、历史消息、流式生成、代码提取。
|
||||
- `api/prompt/llm.py`:系统提示词、模型调用、Markdown 代码块提取。
|
||||
- `api/submission/api.py`:提交创建、手动提交记录、提示词链查询。
|
||||
|
||||
## 当前能力
|
||||
|
||||
1. 支持平台内 AI 对话,生成 HTML、CSS、JS。
|
||||
2. 支持流式输出,用户可以看到 AI 正在生成。
|
||||
3. 生成完成后自动把代码写入预览区。
|
||||
4. 生成完成后自动静默提交,保留每轮迭代结果。
|
||||
5. 支持停止生成,停止后重连 WebSocket。
|
||||
6. 支持删除一轮对话及其关联提交。
|
||||
7. 支持手动提交外部 AI 生成的代码,并记录对应提示词。
|
||||
8. 支持从提交查看提示词链,展示每轮 prompt、来源、层级和该轮页面预览。
|
||||
|
||||
## 主要问题
|
||||
|
||||
### 1. AI 缺少挑战上下文
|
||||
|
||||
当前 `llm.build_messages()` 只拼接系统提示词和历史消息,没有注入挑战标题、挑战描述、评分要求、素材列表。模型不知道学生正在完成哪个挑战,只能根据用户输入猜测目标。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户说“帮我完善这个页面”时,AI 不知道“这个”具体指什么挑战目标。
|
||||
- 生成结果容易偏离任务要求。
|
||||
- 如果挑战提供了图片素材,AI 不知道可用素材名称和路径。
|
||||
|
||||
### 2. AI 缺少当前代码状态
|
||||
|
||||
前端编辑器中的 `html/css/js` 会被 AI 生成结果覆盖,但下一轮对话时,后端只拿数据库里的历史消息作为上下文。用户手动修改、清空、粘贴外部代码后的真实代码状态不会进入模型上下文。
|
||||
|
||||
影响:
|
||||
|
||||
- “在现在的基础上修改”不稳定。
|
||||
- 用户手改后的成果可能被下一轮 AI 覆盖。
|
||||
- AI 很难做精细迭代。
|
||||
|
||||
### 3. 输出协议依赖 Markdown 代码块
|
||||
|
||||
后端用正则从模型回复里提取 ```html / ```css / ```js 代码块。如果模型少写一个代码块、语言标签不规范、把 CSS/JS 混进 HTML,提取就可能失败。
|
||||
|
||||
影响:
|
||||
|
||||
- 预览区可能只更新部分代码。
|
||||
- 空代码块和提取失败不好区分。
|
||||
- 后端难以判断这次生成是否可安全应用。
|
||||
|
||||
### 4. 生成结果直接应用,缺少版本确认
|
||||
|
||||
AI 完成后前端立即执行 `applyCode()`,把代码应用到预览区,并自动提交。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户没有“接受 / 拒绝 / 对比差异”的机会。
|
||||
- 一次较差的生成会覆盖当前可用版本。
|
||||
- 自动提交会混合草稿版本和最终版本。
|
||||
|
||||
### 5. 缺少运行时诊断
|
||||
|
||||
预览 iframe 当前只渲染页面,没有把 `window.onerror`、`console.error`、资源加载失败等诊断信息反馈给对话系统。
|
||||
|
||||
影响:
|
||||
|
||||
- JS 报错时学生不一定知道原因。
|
||||
- AI 无法基于真实错误自动修复。
|
||||
- 教师查看作品时也缺少失败原因记录。
|
||||
|
||||
### 6. 对话输入缺少教学引导
|
||||
|
||||
输入框只有“描述你想要的网页效果...”这类开放提示。学生容易发送低质量指令,例如“帮我做一个好看的页面”。
|
||||
|
||||
影响:
|
||||
|
||||
- AI 生成质量依赖学生表达能力。
|
||||
- 难以引导学生从“命令 AI”过渡到“描述设计目标、布局、交互、约束”。
|
||||
- 已有的 L1-L6 prompt 分类结果没有反向用于提升学生提问质量。
|
||||
|
||||
## 改进目标
|
||||
|
||||
挑战 AI 对话应从“代码生成器”升级为“带上下文的前端迭代助手”:
|
||||
|
||||
1. 理解挑战要求。
|
||||
2. 理解当前页面代码。
|
||||
3. 理解可用素材。
|
||||
4. 能发现运行错误。
|
||||
5. 能以可控版本方式修改页面。
|
||||
6. 能引导学生写出更高质量的 prompt。
|
||||
|
||||
## 优先改进建议
|
||||
|
||||
### P0:注入完整上下文包
|
||||
|
||||
每次发送 prompt 时,前端或后端应构建一个上下文包:
|
||||
|
||||
- 挑战标题。
|
||||
- 挑战描述 Markdown 的纯文本摘要。
|
||||
- 分值、及格线、评分目标。
|
||||
- 当前 `html/css/js`。
|
||||
- 可用素材列表:`name`、`url`、用途说明。
|
||||
- 最近一次预览错误。
|
||||
- 最近几轮对话摘要。
|
||||
|
||||
建议后端将这些内容作为 system/developer context 注入,而不是让用户自己描述。
|
||||
|
||||
### P0:让模型基于当前代码做完整改写
|
||||
|
||||
发送消息时前端附带当前编辑器代码。后端提示模型:
|
||||
|
||||
- 如果是修改需求,必须基于当前代码返回完整修改后代码。
|
||||
- 不允许只返回片段。
|
||||
- 不允许删除已有功能,除非用户明确要求。
|
||||
|
||||
这样可以减少“AI 不知道当前页面”的问题。
|
||||
|
||||
### P1:改用结构化输出协议
|
||||
|
||||
从 Markdown 代码块改为 JSON 输出,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "本轮修改说明",
|
||||
"html": "...",
|
||||
"css": "...",
|
||||
"js": "...",
|
||||
"warnings": [],
|
||||
"questions": []
|
||||
}
|
||||
```
|
||||
|
||||
后端先解析 JSON,再校验字段完整性。解析失败时不要应用代码,而是要求模型重试一次或向前端返回可解释错误。
|
||||
|
||||
### P1:增加版本确认与回滚
|
||||
|
||||
把每轮 AI 生成结果保存为一个版本:
|
||||
|
||||
- 生成后先显示“待接受版本”。
|
||||
- 用户可以“应用”“撤销”“查看差异”“设为最终提交”。
|
||||
- 自动提交可以保留为过程记录,但最终成绩应区分 `draft` 和 `final`。
|
||||
|
||||
这样既能保留学习过程,又不会污染最终作品。
|
||||
|
||||
### P1:把预览错误反馈给 AI
|
||||
|
||||
在 iframe 中捕获:
|
||||
|
||||
- `window.onerror`
|
||||
- `unhandledrejection`
|
||||
- `console.error`
|
||||
- 图片资源加载失败
|
||||
|
||||
前端展示错误,并提供“让 AI 修复错误”按钮。发送修复请求时,把错误堆栈、当前代码和用户最近意图一起传给模型。
|
||||
|
||||
### P2:加入 prompt 质量引导
|
||||
|
||||
结合已有 L1-L6 prompt 分类能力,给学生提供轻量反馈:
|
||||
|
||||
- 当 prompt 过短时,提示补充“布局、颜色、交互、响应式、素材”。
|
||||
- 提供快捷按钮:“加交互”“优化样式”“适配手机”“修复错误”“解释代码”“按挑战要求检查”。
|
||||
- 在提交链中展示 prompt 层级趋势,鼓励学生从 L1/L2 走向 L4-L6。
|
||||
|
||||
### P2:支持多轮目标拆解
|
||||
|
||||
当用户提出复杂需求时,AI 可以先生成简短计划,而不是马上写代码:
|
||||
|
||||
1. 页面结构。
|
||||
2. 视觉风格。
|
||||
3. 交互行为。
|
||||
4. 响应式要求。
|
||||
5. 实现步骤。
|
||||
|
||||
用户确认后再生成代码。这适合复杂挑战和高质量作品创作。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 上下文包:挑战内容、素材、当前代码进入模型上下文。
|
||||
2. 预览错误捕获:让 AI 能修真实错误。
|
||||
3. 结构化输出:降低代码提取失败率。
|
||||
4. 版本确认:避免 AI 直接覆盖好版本。
|
||||
5. prompt 引导:提升学生对话质量。
|
||||
6. 最终提交标记:区分草稿过程和最终作品。
|
||||
|
||||
## 成功标准
|
||||
|
||||
1. 学生输入模糊修改需求时,AI 能稳定基于当前页面迭代,而不是重写成无关页面。
|
||||
2. AI 生成结果更符合挑战描述和素材约束。
|
||||
3. 代码提取失败率显著下降。
|
||||
4. JS 报错能被捕获并可一键交给 AI 修复。
|
||||
5. 学生可以回滚到任意历史版本。
|
||||
6. 教师查看提示词链时,可以看出学生如何从需求、反馈、修复到最终作品。
|
||||
|
||||
## 一句话结论
|
||||
|
||||
下一步重点不是让 AI “更会写代码”,而是让 AI 在每轮对话里都拥有任务、当前作品、素材、错误和版本历史。只有这样,学生才能真正通过对话持续迭代出可用、贴合挑战要求的 Web 前端页面。
|
||||
Reference in new issue
Block a user