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:
xuyueandClaude Opus 5.5 committed 2026-10-26 16:02:12 +08:00
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
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)
+739
View File
@@ -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
+454
View File
@@ -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 前端页面。