feat: 改版阶段 2:挑战和自由创作共用工作台,学生自己选版本提交
- 新的 Workbench:左边题目(可收起)+ AI 对话,右边预览 / 代码(可编辑),底部版本条; 挑战页和自由创作页都用它,原来的“手动提交”“历史对话”标签换成“粘贴外部代码”和“全部历史” - 不再每轮 AI 回复自动提交:学生点“交这一版”,可交多次,成绩仍取最高分;已交的版本不能重复点 - 手改代码、粘贴外部 AI 代码都存成版本(POST /prompt/versions,source 为 edit / manual), 在版本条和提示词链里标“手改”“外部 AI”;它们进入 AI 上下文,AI 在手改后的代码上继续改; 以前手动提交的回复正文为空时用代码补上;edit 的占位提问不参与提示词层级分类 - 互评从全屏弹窗改成对话里的卡片,打分前不显示作者,每个分数写明含义 - 编辑器代码按任务各存一份(useTaskCode),挑战页“清空”不再清掉教程的代码; 提交列表“复制到编辑器”对挑战写入该任务自己的代码 - 抽出 utils/code.ts(整理代码、拆分整页 HTML);删除 RandomRatingModal、ExternalAIPanel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff1eb2982e
commit
9c6c01eba1
29 files changed
+1748
-1069
No files matched your search
@@ -17,7 +17,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
||||
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
|
||||
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
|
||||
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
|
||||
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
|
||||
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
|
||||
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||
|
||||
@@ -29,6 +29,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
||||
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
||||
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors. No `color-mix()` (Chrome 105)
|
||||
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
||||
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page still uses the shared `store/editors.ts`
|
||||
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
|
||||
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||
|
||||
@@ -395,6 +395,20 @@ export const Gradebook = {
|
||||
}
|
||||
|
||||
export const Prompt = {
|
||||
// 保存一个不是 AI 生成的版本:手改的代码,或者带提示词的外部 AI 代码
|
||||
async saveVersion(
|
||||
taskId: number,
|
||||
code: { html: string; css: string; js: string },
|
||||
prompt?: string,
|
||||
): Promise<{ message_id: number; user_message_id: number; source: string }> {
|
||||
const res = await http.post("/prompt/versions", {
|
||||
task_id: taskId,
|
||||
...code,
|
||||
prompt: prompt || undefined,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listConversations(taskId?: number, userId?: number) {
|
||||
const params: Record<string, number> = {}
|
||||
if (taskId) params.task_id = taskId
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<template>
|
||||
<div class="external-panel">
|
||||
<div class="content-area">
|
||||
<div class="field-label">提示词</div>
|
||||
<n-input
|
||||
v-model:value="promptText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder="粘贴你发给外部 AI 的提示词..."
|
||||
/>
|
||||
<div class="code-field">
|
||||
<div class="field-label" style="margin-top: 12px">完整的代码</div>
|
||||
<n-input
|
||||
v-model:value="rawCode"
|
||||
type="textarea"
|
||||
class="code-input"
|
||||
placeholder="粘贴完整的前端代码..."
|
||||
/>
|
||||
</div>
|
||||
<div v-if="splitResult" class="split-result">
|
||||
<n-tag size="small" type="success"
|
||||
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
||||
>
|
||||
<n-tag size="small" type="info"
|
||||
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
||||
>
|
||||
<n-tag size="small" type="warning"
|
||||
>JS · {{ splitResult.js.length }} 字符</n-tag
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
|
||||
>应用预览</n-button
|
||||
>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!splitResult || !promptText.trim()"
|
||||
:loading="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { html, css, js } from "../../store/editors"
|
||||
import { Submission } from "../../api"
|
||||
|
||||
const props = defineProps<{ taskId: number }>()
|
||||
const emit = defineEmits<{
|
||||
submitted: []
|
||||
}>()
|
||||
const message = useMessage()
|
||||
|
||||
const promptText = ref("")
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(rawCode, () => {
|
||||
splitResult.value = null
|
||||
})
|
||||
|
||||
function splitHtml(raw: string): { html: string; css: string; js: string } {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||
cssBlocks.push(content.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, content) => {
|
||||
jsBlocks.push(content.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
const result = splitHtml(rawCode.value)
|
||||
splitResult.value = result
|
||||
html.value = result.html
|
||||
css.value = result.css
|
||||
js.value = result.js
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!splitResult.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await Submission.create(props.taskId, {
|
||||
html: splitResult.value.html,
|
||||
css: splitResult.value.css,
|
||||
js: splitResult.value.js,
|
||||
prompt: promptText.value.trim(),
|
||||
})
|
||||
emit("submitted")
|
||||
message.success("提交成功")
|
||||
promptText.value = ""
|
||||
rawCode.value = ""
|
||||
splitResult.value = null
|
||||
} catch {
|
||||
message.error("提交失败,请重试")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.external-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code-input :deep(.n-input__textarea) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-input :deep(.n-input__textarea-el) {
|
||||
height: 100%;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.split-result {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -70,9 +70,9 @@
|
||||
</n-tag>
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="item.source === 'manual' ? 'info' : 'success'"
|
||||
:type="item.source === 'edit' ? 'warning' : item.source === 'manual' ? 'info' : 'success'"
|
||||
>
|
||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||
{{ sourceLabel(item.source) }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-flex align="center" :wrap="false" :size="4">
|
||||
@@ -133,6 +133,7 @@ import { onMounted, ref, watch } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { sourceLabel } from "../../utils/helper"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptHistoryItem } from "../../utils/type"
|
||||
import { parseTime } from "../../utils/helper"
|
||||
|
||||
@@ -18,8 +18,15 @@
|
||||
</button>
|
||||
|
||||
<!-- User message -->
|
||||
<div class="message user">
|
||||
<div class="message-role">我</div>
|
||||
<div v-if="pair.userMsg.source === 'edit'" class="message note">
|
||||
你手动修改了代码
|
||||
</div>
|
||||
<div v-else class="message user">
|
||||
<div class="message-role">
|
||||
我<span v-if="pair.userMsg.source === 'manual'" class="role-tag">
|
||||
用外部 AI</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.userMsg)"
|
||||
@@ -28,23 +35,16 @@
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div v-if="pair.assistantMsg" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<div
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.assistantMsg)"
|
||||
></div>
|
||||
<div
|
||||
v-if="allowSubmit && hasCode(pair.assistantMsg)"
|
||||
class="message-actions"
|
||||
>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:disabled="pair.assistantMsg.submitted"
|
||||
:loading="submittingId === pair.assistantMsg.id"
|
||||
@click="submitVersion(pair.assistantMsg)"
|
||||
>
|
||||
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
|
||||
</n-button>
|
||||
<template v-if="pair.assistantMsg.source === 'conversation' || !pair.assistantMsg.source">
|
||||
<div class="message-role">AI</div>
|
||||
<div
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.assistantMsg)"
|
||||
></div>
|
||||
</template>
|
||||
<div v-if="versionOf(pair.assistantMsg)" class="version-line">
|
||||
<span class="version-chip">v{{ versionOf(pair.assistantMsg) }}</span>
|
||||
<span v-if="pair.assistantMsg.submitted">已提交</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,6 +62,7 @@
|
||||
></div>
|
||||
<div class="streaming-hint">AI 正在生成中…</div>
|
||||
</div>
|
||||
<RatingCard v-if="rating" :active="streaming" />
|
||||
</div>
|
||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||
<div class="input-area">
|
||||
@@ -80,18 +81,22 @@
|
||||
style="flex: 1; min-width: 0"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-button
|
||||
secondary
|
||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||
style="flex-shrink: 0"
|
||||
title="调教提示词"
|
||||
aria-label="调教提示词"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
secondary
|
||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||
style="flex-shrink: 0"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
</template>
|
||||
帮我写清楚
|
||||
</n-button>
|
||||
</template>
|
||||
</n-button>
|
||||
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||
<n-select
|
||||
@@ -122,6 +127,7 @@ import { useStorage } from "@vueuse/core"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
import RatingCard from "../workbench/RatingCard.vue"
|
||||
import { openGuidance } from "../../store/guidance"
|
||||
import {
|
||||
messages,
|
||||
@@ -132,14 +138,19 @@ import {
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
type PromptMessage,
|
||||
} from "../../store/prompt"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import { Prompt } from "../../api"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
|
||||
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
|
||||
const props = defineProps<{
|
||||
// 发送前调用:工作台用它把还没存的手改先存成版本,AI 才会在手改后的代码上继续改
|
||||
beforeSend?: () => Promise<void>
|
||||
// AI 生成时在对话里显示同学作品让学生打分(挑战页才有)
|
||||
rating?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
||||
const emit = defineEmits<{ deleted: [] }>()
|
||||
|
||||
const input = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
@@ -156,18 +167,11 @@ if (!modelOptions.some((o) => o.value === selectedModel.value)) {
|
||||
}
|
||||
|
||||
// Group messages into user+assistant pairs
|
||||
const submittingId = ref<number | null>(null)
|
||||
|
||||
const pairs = computed(() => {
|
||||
const result: Array<{
|
||||
userMsg: { role: string; content: string; id?: number }
|
||||
assistantMsg: {
|
||||
role: string
|
||||
content: string
|
||||
id?: number
|
||||
code?: any
|
||||
submitted?: boolean
|
||||
} | null
|
||||
userMsg: PromptMessage
|
||||
assistantMsg: PromptMessage | null
|
||||
index: number
|
||||
}> = []
|
||||
const msgs = messages.value
|
||||
@@ -190,30 +194,17 @@ function hasCode(msg: { code?: any } | null): boolean {
|
||||
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||
}
|
||||
|
||||
async function submitVersion(msg: {
|
||||
id?: number
|
||||
code?: { html: string | null; css: string | null; js: string | null }
|
||||
}) {
|
||||
if (!msg.id || !msg.code || !currentTaskId.value) return
|
||||
submittingId.value = msg.id
|
||||
try {
|
||||
await Submission.create(
|
||||
currentTaskId.value,
|
||||
{
|
||||
html: msg.code.html ?? "",
|
||||
css: msg.code.css ?? "",
|
||||
js: msg.code.js ?? "",
|
||||
},
|
||||
msg.id,
|
||||
)
|
||||
markMessageSubmitted(msg.id)
|
||||
naiveMessage.success("提交成功")
|
||||
emit("submitted")
|
||||
} catch {
|
||||
naiveMessage.error("提交失败,请重试")
|
||||
} finally {
|
||||
submittingId.value = null
|
||||
// 带代码的 AI 回复就是一个版本,按出现顺序编号(和工作台底部的版本条一致)
|
||||
const versionNumbers = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
for (const m of messages.value) {
|
||||
if (m.role === "assistant" && m.id && hasCode(m)) map.set(m.id, map.size + 1)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function versionOf(msg: { id?: number }) {
|
||||
return msg.id ? versionNumbers.value.get(msg.id) : undefined
|
||||
}
|
||||
|
||||
async function deletePair(assistantMsgId: number) {
|
||||
@@ -227,11 +218,12 @@ async function deletePair(assistantMsgId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
sendPrompt(text, selectedModel.value)
|
||||
input.value = ""
|
||||
await props.beforeSend?.()
|
||||
sendPrompt(text, selectedModel.value)
|
||||
}
|
||||
|
||||
function startGuidance() {
|
||||
@@ -240,9 +232,10 @@ function startGuidance() {
|
||||
openGuidance(currentTaskId.value, text)
|
||||
}
|
||||
|
||||
function onGuidanceGenerate(finalPrompt: string) {
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
async function onGuidanceGenerate(finalPrompt: string) {
|
||||
input.value = ""
|
||||
await props.beforeSend?.()
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
}
|
||||
|
||||
function renderContent(msg: { role: string; content: string }): string {
|
||||
@@ -297,6 +290,36 @@ watch([() => messages.value.length, streamingContent], () => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 手改代码这类不是对话的一行说明 */
|
||||
.message.note {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 6px;
|
||||
font-weight: normal;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.version-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--wp-brand);
|
||||
border-radius: 4px;
|
||||
color: var(--wp-brand);
|
||||
font: 600 12px/18px var(--wp-font-mono);
|
||||
}
|
||||
|
||||
.message-content :deep(pre) {
|
||||
background: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<n-modal
|
||||
preset="card"
|
||||
:show="show"
|
||||
title="等待 AI 回复时,给同学的作品打个分吧"
|
||||
style="width: 90vw; max-width: 960px"
|
||||
@update:show="onUpdateShow"
|
||||
>
|
||||
<n-text
|
||||
v-if="current"
|
||||
depth="3"
|
||||
style="font-size: 12px; display: block; margin-bottom: 8px"
|
||||
>
|
||||
{{ current.task_title }} · 提交者 {{ current.username }}
|
||||
</n-text>
|
||||
<div class="preview-wrapper">
|
||||
<iframe
|
||||
class="preview-iframe"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="previewContent"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="rate-row">
|
||||
<n-rate
|
||||
v-if="current"
|
||||
:key="current.submission_id"
|
||||
:size="32"
|
||||
@update:value="onRate"
|
||||
/>
|
||||
</div>
|
||||
<n-text
|
||||
depth="3"
|
||||
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
|
||||
>
|
||||
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
|
||||
</n-text>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Submission } from "../../api"
|
||||
import { streaming } from "../../store/prompt"
|
||||
import {
|
||||
buildPreviewDocument,
|
||||
PREVIEW_SANDBOX,
|
||||
} from "../../utils/previewDocument"
|
||||
import type { RandomRatingItem } from "../../utils/type"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const show = ref(false)
|
||||
const current = ref<RandomRatingItem | null>(null)
|
||||
const previewContent = ref("")
|
||||
let reshowTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearReshowTimer() {
|
||||
if (reshowTimer !== null) {
|
||||
clearTimeout(reshowTimer)
|
||||
reshowTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndShow() {
|
||||
try {
|
||||
const item = await Submission.randomForRating(current.value?.submission_id)
|
||||
// 请求期间 AI 已经回复完了,就不要再弹出来
|
||||
if (!streaming.value) return
|
||||
if (item) {
|
||||
current.value = item
|
||||
previewContent.value = buildPreviewDocument({
|
||||
html: item.html ?? "",
|
||||
css: item.css ?? "",
|
||||
js: item.js ?? "",
|
||||
})
|
||||
show.value = true
|
||||
} else {
|
||||
show.value = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
async function onRate(score: number) {
|
||||
if (!current.value) return
|
||||
try {
|
||||
await Submission.updateScore(current.value.submission_id, score)
|
||||
message.success("感谢评分!")
|
||||
await fetchAndShow()
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateShow(value: boolean) {
|
||||
show.value = value
|
||||
if (!value) {
|
||||
clearReshowTimer()
|
||||
if (streaming.value) {
|
||||
reshowTimer = setTimeout(fetchAndShow, 30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
streaming,
|
||||
(val) => {
|
||||
clearReshowTimer()
|
||||
if (val) {
|
||||
fetchAndShow()
|
||||
} else {
|
||||
show.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearReshowTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-wrapper {
|
||||
height: 70vh;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -70,6 +70,7 @@ import Editor from "./Editor.vue"
|
||||
import Toolbar from "./Toolbar.vue"
|
||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||
import { taskId } from "../../store/task"
|
||||
import { formatCode } from "../../utils/code"
|
||||
import { Submission } from "../../api"
|
||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||
import { h, ref } from "vue"
|
||||
@@ -86,43 +87,16 @@ function changeSize(num: number) {
|
||||
size.value = num
|
||||
}
|
||||
|
||||
async function formatCode() {
|
||||
// prettier 加上各语言插件近 1MB,只在点“格式化”时才加载
|
||||
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
|
||||
await Promise.all([
|
||||
import("prettier/standalone"),
|
||||
import("prettier/parser-html"),
|
||||
import("prettier/parser-postcss"),
|
||||
import("prettier/parser-babel"),
|
||||
import("prettier/plugins/estree"),
|
||||
])
|
||||
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
|
||||
prettier.format(html.value, {
|
||||
parser: "html",
|
||||
//@ts-ignore
|
||||
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||
tabWidth: 4,
|
||||
}),
|
||||
prettier.format(css.value, {
|
||||
parser: "css",
|
||||
plugins: [cssParser],
|
||||
tabWidth: 4,
|
||||
}),
|
||||
prettier.format(js.value, {
|
||||
parser: "babel",
|
||||
//@ts-ignore
|
||||
plugins: [babelParser, estreeParser],
|
||||
tabWidth: 2,
|
||||
}),
|
||||
])
|
||||
html.value = htmlFormatted
|
||||
css.value = cssFormatted
|
||||
js.value = jsFormatted
|
||||
async function formatEditors() {
|
||||
const out = await formatCode({ html: html.value, css: css.value, js: js.value })
|
||||
html.value = out.html
|
||||
css.value = out.css
|
||||
js.value = out.js
|
||||
}
|
||||
|
||||
async function format() {
|
||||
try {
|
||||
await formatCode()
|
||||
await formatEditors()
|
||||
} catch (err: any) {
|
||||
dialog.error({
|
||||
title: "格式化失败",
|
||||
@@ -148,7 +122,7 @@ async function doSubmit() {
|
||||
async function formatAndSubmit() {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await formatCode()
|
||||
await formatEditors()
|
||||
await doSubmit()
|
||||
} catch (err: any) {
|
||||
dialog.warning({
|
||||
@@ -180,41 +154,6 @@ async function formatAndSubmit() {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
|
||||
.lang-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--wp-font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.lang-tab.lang-html::before,
|
||||
.lang-tab.lang-css::before,
|
||||
.lang-tab.lang-js::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.lang-html {
|
||||
color: var(--wp-html);
|
||||
}
|
||||
.lang-html::before {
|
||||
background: var(--wp-html);
|
||||
}
|
||||
.lang-css {
|
||||
color: var(--wp-css);
|
||||
}
|
||||
.lang-css::before {
|
||||
background: var(--wp-css);
|
||||
}
|
||||
.lang-js {
|
||||
color: var(--wp-js);
|
||||
}
|
||||
.lang-js::before {
|
||||
background: var(--wp-js);
|
||||
}
|
||||
|
||||
:deep(.n-tabs-nav .n-tabs-tab[data-name="html"]) {
|
||||
background-color: var(--wp-html-soft) !important;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<n-flex align="center" justify="space-between" class="title">
|
||||
<div>
|
||||
<n-text class="titleText">预览</n-text>
|
||||
<slot name="title"><n-text class="titleText">预览</n-text></slot>
|
||||
<n-text v-if="!!submission.id" depth="3">
|
||||
({{ submission.view_count || 0 }})
|
||||
</n-text>
|
||||
@@ -214,9 +214,11 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
||||
<style scoped>
|
||||
.title {
|
||||
height: 43px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid rgb(239, 239, 245);
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
box-sizing: border-box;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
@@ -225,7 +227,8 @@ onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
||||
|
||||
.iframe-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 减去上面标题栏的高度,否则底部 43px 会被裁掉 */
|
||||
height: calc(100% - 43px);
|
||||
margin: 0 auto;
|
||||
transition: max-width 0.3s ease;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -160,9 +160,7 @@
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
}"
|
||||
>{{
|
||||
round.source === "conversation" ? "对话" : "手动"
|
||||
}}</span
|
||||
>{{ sourceLabel(round.source) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="round.prompt_level"
|
||||
@@ -241,6 +239,7 @@ import { computed, ref, watch } from "vue"
|
||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { sourceLabel } from "../../utils/helper"
|
||||
import { storageShim } from "../../utils/previewStorage"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import type { PromptRound } from "../../utils/type"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<n-tabs
|
||||
v-model:value="tab"
|
||||
type="card"
|
||||
size="small"
|
||||
class="code-tabs"
|
||||
:class="`tab-active-${tab}`"
|
||||
pane-class="code-pane"
|
||||
>
|
||||
<template #prefix><slot name="prefix" /></template>
|
||||
<n-tab-pane name="html">
|
||||
<template #tab><span class="lang-tab lang-html">HTML</span></template>
|
||||
<Editor v-model:value="code.html" :font-size="size" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css">
|
||||
<template #tab><span class="lang-tab lang-css">CSS</span></template>
|
||||
<Editor v-model:value="code.css" :font-size="size" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js">
|
||||
<template #tab><span class="lang-tab lang-js">JS</span></template>
|
||||
<Editor v-model:value="code.js" :font-size="size" language="js" />
|
||||
</n-tab-pane>
|
||||
<template #suffix>
|
||||
<n-flex align="center" :wrap="false" :size="4" class="suffix">
|
||||
<n-dropdown :options="sizeOptions" @select="(v: number) => (size = v)">
|
||||
<n-button quaternary size="small">字号 {{ size }}</n-button>
|
||||
</n-dropdown>
|
||||
<slot name="suffix" />
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-tabs>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import Editor from "../editor/Editor.vue"
|
||||
import { size } from "../../store/editors"
|
||||
import type { Code } from "../../utils/code"
|
||||
|
||||
// 直接改传进来的响应式对象,工作台按任务保存它
|
||||
defineProps<{ code: Code }>()
|
||||
|
||||
const tab = ref<"html" | "css" | "js">("html")
|
||||
const sizeOptions = [12, 14, 16, 18, 20, 24, 28, 32].map((n) => ({
|
||||
label: `${n}`,
|
||||
key: n,
|
||||
}))
|
||||
</script>
|
||||
<style scoped>
|
||||
.code-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code-tabs :deep(.code-pane) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.code-tabs :deep(.n-tabs-nav) {
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.code-tabs :deep(.n-tabs-tab[data-name="html"]) {
|
||||
background-color: var(--wp-html-soft) !important;
|
||||
}
|
||||
.code-tabs :deep(.n-tabs-tab[data-name="css"]) {
|
||||
background-color: var(--wp-css-soft) !important;
|
||||
}
|
||||
.code-tabs :deep(.n-tabs-tab[data-name="js"]) {
|
||||
background-color: var(--wp-js-soft) !important;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
padding-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
title="粘贴外部 AI 生成的代码"
|
||||
style="width: 640px; max-width: 94vw"
|
||||
>
|
||||
<n-form-item label="你在外部 AI 里用的提示词" :show-feedback="false">
|
||||
<n-input
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
|
||||
<n-input
|
||||
v-model:value="raw"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||
placeholder="粘贴整页 HTML,里面的 <style> 和 <script> 会自动拆到 CSS 和 JS"
|
||||
class="code-input"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-text depth="3" class="hint">
|
||||
保存后会成为一个新版本,提示词会记进你的提示词记录里。
|
||||
</n-text>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="show = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!prompt.trim() || !raw.trim()"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
存为新版本
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue"
|
||||
import { splitHtml, type Code } from "../../utils/code"
|
||||
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const props = defineProps<{
|
||||
onSave: (prompt: string, code: Code) => Promise<void>
|
||||
}>()
|
||||
|
||||
const prompt = ref("")
|
||||
const raw = ref("")
|
||||
const saving = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await props.onSave(prompt.value.trim(), splitHtml(raw.value))
|
||||
prompt.value = ""
|
||||
raw.value = ""
|
||||
show.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.code-input :deep(textarea) {
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div v-if="current || revealed" class="rating-card">
|
||||
<div class="head">
|
||||
<span>等 AI 的时候,给同学的作品打个分</span>
|
||||
<n-button v-if="current" text size="tiny" @click="next(true)">跳过</n-button>
|
||||
</div>
|
||||
<div v-if="revealed" class="revealed">
|
||||
刚才那份是 <b>{{ revealed }}</b> 的作品,谢谢你的评分
|
||||
</div>
|
||||
<div v-else-if="current" class="body">
|
||||
<button class="thumb" title="点击放大看" @click="showLarge = true">
|
||||
<iframe
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="doc"
|
||||
tabindex="-1"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</button>
|
||||
<div class="info">
|
||||
<div class="task">{{ current.task_title }}</div>
|
||||
<div class="rates">
|
||||
<n-tooltip v-for="s in scores" :key="s.value">
|
||||
<template #trigger>
|
||||
<button class="rate" :disabled="rating" @click="rate(s.value)">
|
||||
{{ s.value }}
|
||||
</button>
|
||||
</template>
|
||||
{{ s.value }} 分:{{ s.label }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="legend">1 没完成 · 3 基本完成 · 5 完成且有自己的想法</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="showLarge"
|
||||
preset="card"
|
||||
:title="current ? `同学的作品 · ${current.task_title}` : '同学的作品'"
|
||||
style="width: 90vw; max-width: 1000px"
|
||||
>
|
||||
<div class="large">
|
||||
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
|
||||
</div>
|
||||
<div class="large-rates">
|
||||
<button
|
||||
v-for="s in scores"
|
||||
:key="s.value"
|
||||
class="rate wide"
|
||||
:disabled="rating"
|
||||
@click="rate(s.value)"
|
||||
>
|
||||
<b>{{ s.value }}</b> {{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Submission } from "../../api"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||
import type { RandomRatingItem } from "../../utils/type"
|
||||
|
||||
// AI 生成期间在对话里显示一份同学的作品让学生打分。打分前不显示作者,减少给熟人打高分
|
||||
const props = defineProps<{ active: boolean }>()
|
||||
const message = useMessage()
|
||||
|
||||
const scores = [
|
||||
{ value: 1, label: "没完成题目要求" },
|
||||
{ value: 2, label: "完成了一小部分" },
|
||||
{ value: 3, label: "基本完成" },
|
||||
{ value: 4, label: "完成得不错" },
|
||||
{ value: 5, label: "完成且有自己的想法" },
|
||||
]
|
||||
|
||||
const current = ref<RandomRatingItem | null>(null)
|
||||
const revealed = ref("")
|
||||
const rating = ref(false)
|
||||
const showLarge = ref(false)
|
||||
let seq = 0
|
||||
|
||||
const doc = computed(() =>
|
||||
current.value
|
||||
? buildPreviewDocument({
|
||||
html: current.value.html ?? "",
|
||||
css: current.value.css ?? "",
|
||||
js: current.value.js ?? "",
|
||||
})
|
||||
: "",
|
||||
)
|
||||
|
||||
async function next(skip = false) {
|
||||
const s = ++seq
|
||||
const exclude = skip ? current.value?.submission_id : undefined
|
||||
try {
|
||||
const item = await Submission.randomForRating(exclude)
|
||||
// 请求期间 AI 已经回复完了,就不要再显示
|
||||
if (s !== seq || !props.active) return
|
||||
current.value = item
|
||||
} catch {
|
||||
if (s === seq) current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rate(score: number) {
|
||||
if (!current.value) return
|
||||
rating.value = true
|
||||
try {
|
||||
await Submission.updateScore(current.value.submission_id, score)
|
||||
revealed.value = current.value.username
|
||||
current.value = null
|
||||
showLarge.value = false
|
||||
setTimeout(() => {
|
||||
revealed.value = ""
|
||||
if (props.active) next()
|
||||
}, 1800)
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||
} finally {
|
||||
rating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
if (active) next()
|
||||
else {
|
||||
seq++
|
||||
current.value = null
|
||||
revealed.value = ""
|
||||
showLarge.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.rating-card {
|
||||
margin: 4px 0 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 84px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 按 4 倍大小渲染再缩小,看起来像网页缩略图 */
|
||||
.thumb iframe {
|
||||
width: 528px;
|
||||
height: 336px;
|
||||
border: 0;
|
||||
transform: scale(0.25);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rates {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rate {
|
||||
min-width: 32px;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font: 600 13px var(--wp-font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rate:hover:not(:disabled) {
|
||||
border-color: var(--wp-brand);
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.rate:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.revealed {
|
||||
font-size: 13px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.large {
|
||||
height: 65vh;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.large iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.large-rates {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rate.wide {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font: 13px var(--wp-font-sans);
|
||||
}
|
||||
|
||||
.rate.wide b {
|
||||
font-family: var(--wp-font-mono);
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="version-bar">
|
||||
<span class="label">版本</span>
|
||||
<div class="chips">
|
||||
<span v-if="!versions.length" class="empty">
|
||||
还没有版本:让 AI 生成,或者在“代码”里自己写
|
||||
</span>
|
||||
<n-tooltip v-for="v in versions" :key="v.id">
|
||||
<template #trigger>
|
||||
<button
|
||||
:class="['chip', { on: v.id === selectedId && !modified }]"
|
||||
@click="emit('select', v.id)"
|
||||
>
|
||||
v{{ v.n }}
|
||||
<span v-if="v.source === 'edit'" class="src">手改</span>
|
||||
<span v-else-if="v.source === 'manual'" class="src">外部</span>
|
||||
<Icon v-if="v.submitted" icon="lucide:check" :width="12" class="ok" />
|
||||
</button>
|
||||
</template>
|
||||
{{ describe(v) }}
|
||||
</n-tooltip>
|
||||
<span v-if="modified" class="chip on draft">当前修改</span>
|
||||
</div>
|
||||
<n-button quaternary size="small" @click="emit('history')">全部历史</n-button>
|
||||
<template v-if="kind === 'challenge'">
|
||||
<span v-if="submittedLabel" class="hint">{{ submittedLabel }}</span>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSubmit || selectedSubmitted"
|
||||
:loading="busy"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ modified ? "交当前修改" : selectedSubmitted ? "已交这一版" : "交这一版" }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSubmit"
|
||||
:loading="busy"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
保存作品
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { Icon } from "../../icons"
|
||||
|
||||
export interface VersionItem {
|
||||
id: number
|
||||
n: number
|
||||
source: string
|
||||
submitted: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
kind: "challenge" | "creation"
|
||||
versions: VersionItem[]
|
||||
selectedId: number | null
|
||||
modified: boolean
|
||||
canSubmit: boolean
|
||||
busy: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ select: [id: number]; submit: []; history: [] }>()
|
||||
|
||||
const selectedSubmitted = computed(
|
||||
() =>
|
||||
!props.modified &&
|
||||
!!props.versions.find((v) => v.id === props.selectedId)?.submitted,
|
||||
)
|
||||
|
||||
const submittedLabel = computed(() => {
|
||||
const done = props.versions.filter((v) => v.submitted).map((v) => `v${v.n}`)
|
||||
if (done.length > 3) return `已交 ${done.length} 个版本`
|
||||
return done.length ? `已交 ${done.join("、")}` : ""
|
||||
})
|
||||
|
||||
function describe(v: VersionItem) {
|
||||
const from =
|
||||
v.source === "edit" ? "你手改的" : v.source === "manual" ? "外部 AI 生成的" : "AI 生成的"
|
||||
return `${from}第 ${v.n} 版${v.submitted ? ",已提交" : ""},点击查看`
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.version-bar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.chips {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font: 12px/20px var(--wp-font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
border-color: var(--wp-brand);
|
||||
color: var(--wp-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip.draft {
|
||||
cursor: default;
|
||||
border-style: dashed;
|
||||
font-family: var(--wp-font-sans);
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-family: var(--wp-font-sans);
|
||||
font-size: 11px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<n-split
|
||||
class="workbench"
|
||||
:default-size="0.38"
|
||||
min="360px"
|
||||
:max="0.62"
|
||||
>
|
||||
<template #1>
|
||||
<div class="left">
|
||||
<div class="left-head">
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="$router.push(backTo)">
|
||||
<Icon icon="lucide:arrow-left" :width="18" />
|
||||
</n-button>
|
||||
</template>
|
||||
{{ backLabel }}
|
||||
</n-tooltip>
|
||||
<span class="title">{{ title }}</span>
|
||||
<slot name="badges" />
|
||||
<span class="spacer"></span>
|
||||
<slot name="tools" />
|
||||
</div>
|
||||
<div v-if="$slots.brief" class="brief">
|
||||
<button class="brief-toggle" @click="briefOpen = !briefOpen">
|
||||
<b>{{ briefLabel }}</b>
|
||||
<span>{{ briefOpen ? "收起" : "展开" }}</span>
|
||||
</button>
|
||||
<div v-show="briefOpen" class="brief-body">
|
||||
<slot name="brief" />
|
||||
</div>
|
||||
</div>
|
||||
<PromptPanel
|
||||
class="chat"
|
||||
:before-send="beforeSend"
|
||||
:rating="kind === 'challenge'"
|
||||
@deleted="historyKey++"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<div class="right">
|
||||
<div class="right-body">
|
||||
<div v-if="mode === 'preview'" class="preview-only">
|
||||
<Preview
|
||||
:html="code.html"
|
||||
:css="code.css"
|
||||
:js="code.js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:storage-task-id="taskId"
|
||||
>
|
||||
<template #title>
|
||||
<n-radio-group v-model:value="mode" size="small">
|
||||
<n-radio-button value="preview">预览</n-radio-button>
|
||||
<n-radio-button value="code">代码</n-radio-button>
|
||||
</n-radio-group>
|
||||
</template>
|
||||
</Preview>
|
||||
</div>
|
||||
<n-split v-else direction="vertical" :default-size="0.55" min="160px" max="0.8">
|
||||
<template #1>
|
||||
<CodeTabs :code="code">
|
||||
<template #prefix>
|
||||
<n-radio-group v-model:value="mode" size="small" class="mode">
|
||||
<n-radio-button value="preview">预览</n-radio-button>
|
||||
<n-radio-button value="code">代码</n-radio-button>
|
||||
</n-radio-group>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-button quaternary size="small" @click="format">整理代码</n-button>
|
||||
<n-button quaternary size="small" @click="showPaste = true">
|
||||
粘贴外部代码
|
||||
</n-button>
|
||||
<n-button quaternary size="small" @click="clear">清空</n-button>
|
||||
</template>
|
||||
</CodeTabs>
|
||||
</template>
|
||||
<template #2>
|
||||
<div class="preview-only">
|
||||
<Preview
|
||||
:html="code.html"
|
||||
:css="code.css"
|
||||
:js="code.js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:storage-task-id="taskId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-split>
|
||||
</div>
|
||||
<VersionBar
|
||||
:kind="kind"
|
||||
:versions="versions"
|
||||
:selected-id="selectedId"
|
||||
:modified="modified"
|
||||
:can-submit="canSubmit"
|
||||
:busy="busy"
|
||||
@select="select"
|
||||
@submit="submit"
|
||||
@history="openHistory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-split>
|
||||
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
|
||||
<n-drawer v-model:show="showHistory" :width="420" placement="right">
|
||||
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
|
||||
<PromptHistoryPanel
|
||||
:task-id="taskId"
|
||||
:active="showHistory"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:refresh-key="historyKey"
|
||||
@select="adopt"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onUnmounted, ref, toRef, watch } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import type { RouteLocationRaw } from "vue-router"
|
||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
import PromptPanel from "../ai/PromptPanel.vue"
|
||||
import PromptHistoryPanel from "../ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../editor/Preview.vue"
|
||||
import CodeTabs from "./CodeTabs.vue"
|
||||
import VersionBar, { type VersionItem } from "./VersionBar.vue"
|
||||
import PasteCodeModal from "./PasteCodeModal.vue"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import { useTaskCode } from "../../composables/useTaskCode"
|
||||
import {
|
||||
addVersionPair,
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
markMessageSubmitted,
|
||||
messages,
|
||||
removeMessagePair,
|
||||
sendPrompt,
|
||||
setCodeSink,
|
||||
} from "../../store/prompt"
|
||||
import { closeGuidance } from "../../store/guidance"
|
||||
import { authed } from "../../store/user"
|
||||
import {
|
||||
emptyCode,
|
||||
formatCode,
|
||||
isEmptyCode,
|
||||
sameCode,
|
||||
type Code,
|
||||
} from "../../utils/code"
|
||||
|
||||
// 挑战和自由创作共用的工作台:左边题目 + AI 对话,右边预览 / 代码,底部是版本条。
|
||||
// 每轮 AI 回复、每次手改或粘贴外部代码都是一个版本;挑战由学生自己点“交这一版”提交
|
||||
const props = defineProps<{
|
||||
kind: "challenge" | "creation"
|
||||
// 任务还没加载完时是 0
|
||||
taskId: number
|
||||
title: string
|
||||
backLabel: string
|
||||
backTo: RouteLocationRaw
|
||||
briefLabel?: string
|
||||
assetBaseUrl?: string
|
||||
// 自由创作:保存作品(AI 生成完也会自动保存)
|
||||
saveWork?: (code: Code) => Promise<void>
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { code, restored } = useTaskCode(toRef(props, "taskId"))
|
||||
const mode = ref<"preview" | "code">("preview")
|
||||
const selectedId = ref<number | null>(null)
|
||||
const busy = ref(false)
|
||||
const briefOpen = ref(true)
|
||||
const showPaste = ref(false)
|
||||
const showHistory = ref(false)
|
||||
const historyKey = ref(0)
|
||||
|
||||
type CodeOut = { html: string | null; css: string | null; js: string | null }
|
||||
const normalize = (c: CodeOut): Code => ({
|
||||
html: c.html ?? "",
|
||||
css: c.css ?? "",
|
||||
js: c.js ?? "",
|
||||
})
|
||||
|
||||
const versionMessages = computed(() =>
|
||||
messages.value.filter(
|
||||
(m) => m.role === "assistant" && m.id && m.code && (m.code.html || m.code.css || m.code.js),
|
||||
),
|
||||
)
|
||||
|
||||
const versions = computed<VersionItem[]>(() =>
|
||||
versionMessages.value.map((m, i) => ({
|
||||
id: m.id!,
|
||||
n: i + 1,
|
||||
source: m.source ?? "conversation",
|
||||
submitted: !!m.submitted,
|
||||
})),
|
||||
)
|
||||
|
||||
function versionCode(id: number | null): Code | null {
|
||||
const m = versionMessages.value.find((v) => v.id === id)
|
||||
return m?.code ? normalize(m.code) : null
|
||||
}
|
||||
|
||||
const modified = computed(() => {
|
||||
const base = versionCode(selectedId.value)
|
||||
return base ? !sameCode(code, base) : !isEmptyCode(code)
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => !!props.taskId && !isEmptyCode(code))
|
||||
|
||||
function matchVersion(): number | null {
|
||||
const hit = [...versionMessages.value]
|
||||
.reverse()
|
||||
.find((m) => sameCode(code, normalize(m.code!)))
|
||||
return hit?.id ?? null
|
||||
}
|
||||
|
||||
// 连上时:本地存着这个任务的代码就继续用它,否则打开最新一版。AI 生成完:换成新代码
|
||||
function onCode(c: CodeOut, id: number, reason: "init" | "complete") {
|
||||
if (reason === "init" && restored.value) {
|
||||
selectedId.value = matchVersion() ?? id
|
||||
return
|
||||
}
|
||||
Object.assign(code, normalize(c))
|
||||
selectedId.value = id
|
||||
if (reason === "complete" && props.kind === "creation") {
|
||||
props.saveWork?.({ ...code }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
(id) => {
|
||||
if (!id || !authed.value) return
|
||||
setCodeSink(onCode)
|
||||
connectPrompt(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectPrompt()
|
||||
closeGuidance()
|
||||
})
|
||||
|
||||
// 打开时已经有对话的,题目默认收起,给对话留地方
|
||||
let briefDecided = false
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
(n) => {
|
||||
if (briefDecided || !n) return
|
||||
briefDecided = true
|
||||
briefOpen.value = false
|
||||
},
|
||||
)
|
||||
|
||||
async function saveAsVersion(prompt?: string): Promise<number> {
|
||||
const res = await Prompt.saveVersion(props.taskId, { ...code }, prompt)
|
||||
addVersionPair(prompt || "(手动修改了代码)", res.source, res.message_id, { ...code })
|
||||
selectedId.value = res.message_id
|
||||
historyKey.value++
|
||||
return res.message_id
|
||||
}
|
||||
|
||||
// 发给 AI 之前,先把还没存的手改存成版本,AI 才会在手改后的代码上继续改
|
||||
async function beforeSend() {
|
||||
briefOpen.value = false
|
||||
briefDecided = true
|
||||
if (!modified.value || isEmptyCode(code)) return
|
||||
try {
|
||||
await saveAsVersion()
|
||||
message.info("已把你的修改存为新版本,AI 会在它的基础上改")
|
||||
} catch {
|
||||
message.error("保存你的修改失败,AI 会在上一版的基础上改")
|
||||
}
|
||||
}
|
||||
|
||||
function select(id: number) {
|
||||
const apply = () => {
|
||||
const c = versionCode(id)
|
||||
if (!c) return
|
||||
Object.assign(code, c)
|
||||
selectedId.value = id
|
||||
}
|
||||
if (!modified.value) return apply()
|
||||
dialog.warning({
|
||||
title: "放弃当前修改?",
|
||||
content: "当前的修改还没有存成版本,切换后会丢失。想留着的话,可以先点“交当前修改”或发给 AI。",
|
||||
positiveText: "放弃并切换",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: apply,
|
||||
})
|
||||
}
|
||||
|
||||
// 从“全部历史”里选了一份代码
|
||||
function adopt(c: Code) {
|
||||
Object.assign(code, c)
|
||||
selectedId.value = matchVersion()
|
||||
showHistory.value = false
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
historyKey.value++
|
||||
showHistory.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
try {
|
||||
if (props.kind === "creation") {
|
||||
await props.saveWork?.({ ...code })
|
||||
message.success("作品已保存")
|
||||
return
|
||||
}
|
||||
const id =
|
||||
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
|
||||
await Submission.create(props.taskId, { ...code }, id)
|
||||
markMessageSubmitted(id)
|
||||
const n = versions.value.find((v) => v.id === id)?.n
|
||||
message.success(n ? `已提交 v${n}` : "已提交")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "提交失败,请重试")
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePasted(prompt: string, c: Code) {
|
||||
Object.assign(code, c)
|
||||
try {
|
||||
await saveAsVersion(prompt)
|
||||
message.success("已存为新版本")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "保存失败,请重试")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function format() {
|
||||
try {
|
||||
Object.assign(code, await formatCode({ ...code }))
|
||||
} catch (err: any) {
|
||||
dialog.error({
|
||||
title: "整理失败,代码里可能有语法错误",
|
||||
content: () => h(NCode, { code: err.message }),
|
||||
style: { width: "auto" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
dialog.warning({
|
||||
title: "清空代码?",
|
||||
content: "只清空编辑器里的代码,已经存下的版本不受影响。",
|
||||
positiveText: "清空",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => {
|
||||
Object.assign(code, emptyCode())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 和 PromptPanel 里的模型选择是同一个存储
|
||||
const model = useStorage("prompt-model", "deepseek-flash")
|
||||
|
||||
defineExpose({
|
||||
// 页面自己发提示词(自由创作的“用这个主题生成”)
|
||||
async send(text: string) {
|
||||
await beforeSend()
|
||||
sendPrompt(text, model.value)
|
||||
},
|
||||
// 页面用来放入示例代码,或自由创作从服务器读到的作品
|
||||
setCode(c: Code) {
|
||||
Object.assign(code, c)
|
||||
selectedId.value = matchVersion()
|
||||
},
|
||||
restored,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.workbench {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.left {
|
||||
border-right: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.left-head {
|
||||
height: 43px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.brief {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.brief-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
background: var(--wp-paper);
|
||||
color: var(--wp-ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brief-toggle span {
|
||||
color: var(--wp-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brief-toggle:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.brief-body {
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-only {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode {
|
||||
margin: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { reactive, ref, watch, type Ref } from "vue"
|
||||
import { user } from "../store/user"
|
||||
import { emptyCode, type Code } from "../utils/code"
|
||||
|
||||
const key = (taskId: number) => `web-code:${user.username}:${taskId}`
|
||||
|
||||
function read(taskId: number): Code | null {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key(taskId)) ?? "null")
|
||||
return saved && typeof saved.html === "string" ? { ...emptyCode(), ...saved } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 直接写入某个任务的代码(比如把别人的提交复制过去),下次打开那个任务的工作台时读到
|
||||
export function saveTaskCode(taskId: number, code: Code) {
|
||||
try {
|
||||
localStorage.setItem(key(taskId), JSON.stringify(code))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 工作台里正在编辑的代码,每个任务各存一份(按用户 + 任务存在 localStorage)。
|
||||
// 以前挑战、教程、自由创作共用一份,在挑战页点“清空”会把教程里的代码也清掉
|
||||
export function useTaskCode(taskId: Ref<number>) {
|
||||
const code = reactive(emptyCode())
|
||||
// 打开时本地有没有存着这个任务的代码;没有的话由调用方填入最新版本
|
||||
const restored = ref(false)
|
||||
|
||||
// 用户名也在 key 里,刚打开页面时可能还没取到
|
||||
watch(
|
||||
() => [taskId.value, user.username] as const,
|
||||
([id]) => {
|
||||
const saved = id && user.username ? read(id) : null
|
||||
Object.assign(code, saved ?? emptyCode())
|
||||
restored.value = !!saved
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(
|
||||
() => ({ ...code }),
|
||||
(value) => {
|
||||
if (!taskId.value || !user.username) return
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(key(taskId.value), JSON.stringify(value))
|
||||
} catch {}
|
||||
}, 300)
|
||||
},
|
||||
)
|
||||
|
||||
return { code, restored }
|
||||
}
|
||||
@@ -46,3 +46,39 @@ samp {
|
||||
iframe {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* HTML / CSS / JS 各有固定颜色,见 theme.ts */
|
||||
.lang-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--wp-font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.lang-tab.lang-html::before,
|
||||
.lang-tab.lang-css::before,
|
||||
.lang-tab.lang-js::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.lang-html {
|
||||
color: var(--wp-html);
|
||||
}
|
||||
.lang-html::before {
|
||||
background: var(--wp-html);
|
||||
}
|
||||
.lang-css {
|
||||
color: var(--wp-css);
|
||||
}
|
||||
.lang-css::before {
|
||||
background: var(--wp-css);
|
||||
}
|
||||
.lang-js {
|
||||
color: var(--wp-js);
|
||||
}
|
||||
.lang-js::before {
|
||||
background: var(--wp-js);
|
||||
}
|
||||
@@ -1,107 +1,58 @@
|
||||
<template>
|
||||
<div class="challenge-layout">
|
||||
<div class="challenge-sider">
|
||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||
<template #prefix>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="back" style="margin: 0 8px">
|
||||
<Icon :width="18" icon="lucide:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
返回挑战列表
|
||||
</n-tooltip>
|
||||
<Workbench
|
||||
ref="wb"
|
||||
kind="challenge"
|
||||
:task-id="taskId"
|
||||
:title="title"
|
||||
back-label="返回挑战列表"
|
||||
:back-to="{ name: 'home-challenge-list' }"
|
||||
brief-label="题目"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
>
|
||||
<template #badges>
|
||||
<n-tag v-if="score" size="small" :bordered="false" type="warning">
|
||||
{{ score }} 分
|
||||
</n-tag>
|
||||
</template>
|
||||
<template #tools>
|
||||
<n-button v-if="assets.length" quaternary size="small" @click="showAssets = true">
|
||||
素材
|
||||
</n-button>
|
||||
<n-tooltip v-if="roleStaff">
|
||||
<template #trigger>
|
||||
<n-button text @click="showStats = true">
|
||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||
</n-button>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-flex style="margin: 0 8px">
|
||||
<n-tooltip v-if="roleAdmin || roleSuper">
|
||||
<template #trigger>
|
||||
<n-button text @click="showStats = true">
|
||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||
</n-button>
|
||||
</template>
|
||||
本题统计
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="roleSuper">
|
||||
<template #trigger>
|
||||
<n-button text @click="edit">
|
||||
<Icon :width="16" icon="lucide:edit" />
|
||||
</n-button>
|
||||
</template>
|
||||
编辑这道题
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
本题统计
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="roleSuper">
|
||||
<template #trigger>
|
||||
<n-button text @click="edit">
|
||||
<Icon :width="16" icon="lucide:edit" />
|
||||
</n-button>
|
||||
</template>
|
||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||
<div class="desc-pane">
|
||||
<div class="challenge-meta">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-text depth="3">
|
||||
出题人:{{ challengeAuthor || "未设置" }}
|
||||
</n-text>
|
||||
<n-flex align="center" size="small">
|
||||
<n-button
|
||||
v-if="assets.length"
|
||||
size="small"
|
||||
@click="showAssets = true"
|
||||
>
|
||||
看素材
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="exampleCode"
|
||||
size="small"
|
||||
@click="previewExample"
|
||||
>
|
||||
看示例
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</div>
|
||||
<div
|
||||
class="markdown-body content no-select"
|
||||
v-html="challengeContent"
|
||||
ref="$desc"
|
||||
@copy.prevent
|
||||
/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||
<PromptPanel
|
||||
@deleted="historyRefreshKey++"
|
||||
@submitted="historyRefreshKey++"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
||||
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||
<PromptHistoryPanel
|
||||
:task-id="taskId"
|
||||
:active="activeTab === 'history'"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:refresh-key="historyRefreshKey"
|
||||
@select="previewHistoryItem"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<div class="challenge-content">
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:storage-task-id="taskId"
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
@clear="clearAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
编辑这道题
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<template #brief>
|
||||
<div class="brief">
|
||||
<n-flex align="center" justify="space-between" class="meta">
|
||||
<n-text depth="3">出题人:{{ author || "未设置" }}</n-text>
|
||||
<n-button v-if="exampleCode" size="tiny" @click="useExample">
|
||||
放入示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<div
|
||||
class="markdown-body challenge-desc no-select"
|
||||
v-html="content"
|
||||
@copy.prevent
|
||||
@click="onCopyClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Workbench>
|
||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||
<RandomRatingModal v-if="authed" />
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
@@ -119,105 +70,64 @@
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="showCode"
|
||||
preset="card"
|
||||
title="代码"
|
||||
style="width: 700px"
|
||||
>
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="html" tab="HTML">
|
||||
<n-code :code="html" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css" tab="CSS">
|
||||
<n-code :code="css" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js" tab="JS">
|
||||
<n-code :code="js" language="javascript" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { marked, type MarkedOptions } from "marked"
|
||||
import { Marked } from "marked"
|
||||
import copyFn from "copy-text-to-clipboard"
|
||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import { Icon } from "../icons"
|
||||
import Workbench from "../components/workbench/Workbench.vue"
|
||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
||||
import { Challenge, Submission, TaskAssets } from "../api"
|
||||
import { Challenge, TaskAssets } from "../api"
|
||||
import type { TaskAsset } from "../utils/type"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { authed, roleAdmin, roleSuper } from "../store/user"
|
||||
import {
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
streaming,
|
||||
setOnCodeComplete,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../store/prompt"
|
||||
import { closeGuidance } from "../store/guidance"
|
||||
import { roleStaff, roleSuper } from "../store/user"
|
||||
import { trustedExtensions } from "../utils/markdown"
|
||||
import type { Code } from "../utils/code"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const challengeRenderer = new marked.Renderer()
|
||||
challengeRenderer.code = ({ text, lang }) => {
|
||||
const language = lang?.toLowerCase() ?? "text"
|
||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||
// 题目里的代码块带“复制”按钮;题目正文本身不让复制
|
||||
const challengeMarked = new Marked(...trustedExtensions, {
|
||||
renderer: {
|
||||
code({ text, lang }) {
|
||||
const language = lang?.toLowerCase() ?? "text"
|
||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||
<div class="codeblock-action">
|
||||
<span class="lang">${language.toUpperCase()}</span>
|
||||
<button class="action-btn" data-action="copy">复制</button>
|
||||
</div>
|
||||
<pre><code class="language-${language}">${text}</code></pre>
|
||||
</div>`
|
||||
}
|
||||
challengeRenderer.link = ({ href, text }) =>
|
||||
`<a href="${href}" target="_blank">${text}</a>`
|
||||
},
|
||||
link({ href, text }) {
|
||||
return `<a href="${href}" target="_blank">${text}</a>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function setupCodeCopy() {
|
||||
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||
if (!btn) return
|
||||
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||
copyFn(code)
|
||||
btn.textContent = "已复制"
|
||||
setTimeout(() => { btn.textContent = "复制" }, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
const activeTab = ref("desc")
|
||||
const challengeContent = ref("")
|
||||
const challengeAuthor = ref("")
|
||||
const $desc = useTemplateRef<HTMLElement>("$desc")
|
||||
const showCode = ref(false)
|
||||
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||
const title = ref("")
|
||||
const score = ref(0)
|
||||
const author = ref("")
|
||||
const content = ref("")
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const exampleCode = ref<Code | null>(null)
|
||||
const showStats = ref(false)
|
||||
const showAssets = ref(false)
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const historyRefreshKey = ref(0)
|
||||
const exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
)
|
||||
|
||||
watch(streaming, (val) => {
|
||||
if (val) activeTab.value = "chat"
|
||||
})
|
||||
// 加载还没完成就离开页面时,不要再改全局状态
|
||||
let disposed = false
|
||||
|
||||
async function loadChallenge() {
|
||||
async function load() {
|
||||
const display = Number(route.params.display)
|
||||
taskTab.value = TASK_TYPE.Challenge
|
||||
challengeDisplay.value = display
|
||||
@@ -226,151 +136,57 @@ async function loadChallenge() {
|
||||
TaskAssets.listChallenge(display),
|
||||
])
|
||||
if (disposed) return
|
||||
taskId.value = data.task_ptr
|
||||
challengeAuthor.value = data.author_name ?? ""
|
||||
if (data.example_html || data.example_css || data.example_js) {
|
||||
exampleCode.value = {
|
||||
html: data.example_html ?? "",
|
||||
css: data.example_css ?? "",
|
||||
js: data.example_js ?? "",
|
||||
}
|
||||
} else {
|
||||
exampleCode.value = null
|
||||
}
|
||||
challengeContent.value = await marked.parse(data.content, {
|
||||
async: true,
|
||||
renderer: challengeRenderer,
|
||||
} as MarkedOptions)
|
||||
if (disposed) return
|
||||
title.value = `${data.display}. ${data.title}`
|
||||
score.value = data.score
|
||||
author.value = data.author_name ?? ""
|
||||
assets.value = fetchedAssets
|
||||
if (!authed.value) return
|
||||
connectPrompt(data.task_ptr)
|
||||
setOnCodeComplete(async (code, messageId) => {
|
||||
try {
|
||||
await Submission.create(
|
||||
taskId.value,
|
||||
{
|
||||
html: code.html ?? "",
|
||||
css: code.css ?? "",
|
||||
js: code.js ?? "",
|
||||
},
|
||||
messageId,
|
||||
)
|
||||
markMessageSubmitted(messageId)
|
||||
historyRefreshKey.value++
|
||||
message.success("已自动提交本次对话生成的代码")
|
||||
} catch {
|
||||
message.error("自动提交失败,请稍后重试")
|
||||
}
|
||||
})
|
||||
exampleCode.value =
|
||||
data.example_html || data.example_css || data.example_js
|
||||
? {
|
||||
html: data.example_html ?? "",
|
||||
css: data.example_css ?? "",
|
||||
js: data.example_js ?? "",
|
||||
}
|
||||
: null
|
||||
content.value = await challengeMarked.parse(data.content, { async: true })
|
||||
if (disposed) return
|
||||
taskId.value = data.task_ptr
|
||||
}
|
||||
|
||||
function useExample() {
|
||||
if (exampleCode.value) wb.value?.setCode({ ...exampleCode.value })
|
||||
}
|
||||
|
||||
function onCopyClick(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||
if (!btn) return
|
||||
const code = btn.closest<HTMLElement>("[data-lang]")?.querySelector("code")?.textContent ?? ""
|
||||
copyFn(code)
|
||||
btn.textContent = "已复制"
|
||||
setTimeout(() => {
|
||||
btn.textContent = "复制"
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function edit() {
|
||||
router.push({
|
||||
name: "challenge-editor",
|
||||
params: { display: route.params.display },
|
||||
})
|
||||
router.push({ name: "challenge-editor", params: { display: route.params.display } })
|
||||
}
|
||||
|
||||
function previewExample() {
|
||||
if (!exampleCode.value) return
|
||||
html.value = exampleCode.value.html
|
||||
css.value = exampleCode.value.css
|
||||
js.value = exampleCode.value.js
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
html.value = ""
|
||||
css.value = ""
|
||||
js.value = ""
|
||||
}
|
||||
|
||||
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||
html.value = code.html
|
||||
css.value = code.css
|
||||
js.value = code.js
|
||||
}
|
||||
|
||||
function back() {
|
||||
disconnectPrompt()
|
||||
taskId.value = 0
|
||||
router.push({ name: "home-challenge-list" })
|
||||
}
|
||||
|
||||
// 加载还没完成就离开页面时,不要再建立连接
|
||||
let disposed = false
|
||||
|
||||
onMounted(() => {
|
||||
setupCodeCopy()
|
||||
loadChallenge()
|
||||
})
|
||||
onMounted(load)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
disconnectPrompt()
|
||||
closeGuidance()
|
||||
taskId.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.challenge-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
.brief {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.challenge-sider {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.challenge-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tab-pane) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.desc-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.challenge-meta {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
.meta {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
@@ -379,15 +195,20 @@ onUnmounted(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.challenge-sider .codeblock-wrapper {
|
||||
padding: 1rem;
|
||||
background-color: #f6f8fa;
|
||||
.challenge-desc {
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.challenge-desc .codeblock-wrapper {
|
||||
padding: 12px;
|
||||
background-color: var(--wp-paper);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-wrapper pre {
|
||||
.challenge-desc .codeblock-wrapper pre {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
@@ -395,39 +216,38 @@ onUnmounted(() => {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-wrapper pre code {
|
||||
.challenge-desc .codeblock-wrapper pre code {
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-size: 13px;
|
||||
font-family: var(--wp-font-mono);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-action {
|
||||
margin-bottom: 0.5rem;
|
||||
.challenge-desc .codeblock-action {
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-action .lang {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
.challenge-desc .codeblock-action .lang {
|
||||
font: 600 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.challenge-sider .action-btn {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
.challenge-desc .action-btn {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--wp-line);
|
||||
background-color: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.challenge-sider .action-btn:hover {
|
||||
.challenge-desc .action-btn:hover {
|
||||
border-color: var(--wp-brand);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
@@ -1,167 +1,93 @@
|
||||
<template>
|
||||
<div class="creation-layout">
|
||||
<div class="creation-sider">
|
||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||
<template #prefix>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="back" style="margin: 0 8px">
|
||||
<Icon :width="18" icon="lucide:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
返回我的创作
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-flex style="margin: 0 8px">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button text :loading="savingCode" @click="saveCode(true)">
|
||||
<Icon :width="16" icon="lucide:save" />
|
||||
</n-button>
|
||||
</template>
|
||||
保存当前作品
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-tab-pane name="topic" tab="我的主题" display-directive="show">
|
||||
<div class="topic-pane">
|
||||
<n-form-item label="主题名称" :show-feedback="false">
|
||||
<n-input
|
||||
v-model:value="title"
|
||||
placeholder="给你的主题起个名字"
|
||||
maxlength="100"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="主题描述"
|
||||
:show-feedback="false"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 8 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-flex style="margin-top: 16px">
|
||||
<n-button
|
||||
:loading="savingTopic"
|
||||
:disabled="!title.trim()"
|
||||
@click="saveTopic"
|
||||
>
|
||||
保存主题
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!content.trim() || streaming"
|
||||
@click="generate"
|
||||
>
|
||||
用这个主题生成
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-text depth="3" class="topic-hint">
|
||||
自由创作只属于你自己,不会进入提交记录和评分。
|
||||
</n-text>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||
<PromptPanel :allow-submit="false" @deleted="historyRefreshKey++" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||
<PromptHistoryPanel
|
||||
:task-id="taskId"
|
||||
:active="activeTab === 'history'"
|
||||
:refresh-key="historyRefreshKey"
|
||||
@select="previewHistoryItem"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<div class="creation-content">
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:storage-task-id="taskId"
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
@clear="clearAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="showCode"
|
||||
preset="card"
|
||||
title="代码"
|
||||
style="width: 700px"
|
||||
<Workbench
|
||||
ref="wb"
|
||||
kind="creation"
|
||||
:task-id="taskId"
|
||||
:title="title || '自由创作'"
|
||||
back-label="返回我的创作"
|
||||
:back-to="{ name: 'creation-list' }"
|
||||
brief-label="我的主题"
|
||||
:save-work="saveWork"
|
||||
>
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="html" tab="HTML">
|
||||
<n-code :code="html" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css" tab="CSS">
|
||||
<n-code :code="css" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js" tab="JS">
|
||||
<n-code :code="js" language="javascript" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
<template #brief>
|
||||
<div class="topic">
|
||||
<n-form-item label="主题名称" :show-feedback="false">
|
||||
<n-input
|
||||
v-model:value="title"
|
||||
placeholder="给你的主题起个名字"
|
||||
maxlength="100"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="主题描述" :show-feedback="false" style="margin-top: 10px">
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-flex style="margin-top: 12px">
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="savingTopic"
|
||||
:disabled="!title.trim()"
|
||||
@click="saveTopic"
|
||||
>
|
||||
保存主题
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!content.trim() || streaming"
|
||||
@click="generate"
|
||||
>
|
||||
用这个主题生成
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-text depth="3" class="hint">
|
||||
自由创作只属于你自己,不会进入提交记录和评分。
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
</Workbench>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import Workbench from "../components/workbench/Workbench.vue"
|
||||
import { Creation } from "../api"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import {
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
removeMessagePair,
|
||||
sendPrompt,
|
||||
setOnCodeComplete,
|
||||
streaming,
|
||||
} from "../store/prompt"
|
||||
import { closeGuidance } from "../store/guidance"
|
||||
import { streaming } from "../store/prompt"
|
||||
import type { Code } from "../utils/code"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const wb = useTemplateRef<InstanceType<typeof Workbench>>("wb")
|
||||
|
||||
const display = Number(route.params.display)
|
||||
const activeTab = ref("topic")
|
||||
const taskId = ref(0)
|
||||
const title = ref("")
|
||||
const content = ref("")
|
||||
const showCode = ref(false)
|
||||
const savingTopic = ref(false)
|
||||
const savingCode = ref(false)
|
||||
const historyRefreshKey = ref(0)
|
||||
|
||||
watch(streaming, (val) => {
|
||||
if (val) activeTab.value = "chat"
|
||||
})
|
||||
// 加载还没完成就离开页面时,不要再改状态
|
||||
let disposed = false
|
||||
|
||||
async function load() {
|
||||
const data = await Creation.get(display)
|
||||
if (disposed) return
|
||||
taskId.value = data.task_id
|
||||
title.value = data.title
|
||||
content.value = data.content
|
||||
html.value = data.html
|
||||
css.value = data.css
|
||||
js.value = data.js
|
||||
connectPrompt(data.task_id)
|
||||
setOnCodeComplete(() => saveCode())
|
||||
taskId.value = data.task_id
|
||||
// 本地没有存着修改到一半的代码,就用服务器上保存的作品
|
||||
if (!wb.value?.restored) {
|
||||
wb.value?.setCode({ html: data.html, css: data.css, js: data.js })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWork(code: Code) {
|
||||
await Creation.saveCode(display, code)
|
||||
}
|
||||
|
||||
async function saveTopic() {
|
||||
@@ -180,110 +106,26 @@ async function saveTopic() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCode(notify = false) {
|
||||
savingCode.value = true
|
||||
try {
|
||||
await Creation.saveCode(display, {
|
||||
html: html.value,
|
||||
css: css.value,
|
||||
js: js.value,
|
||||
})
|
||||
if (notify) message.success("作品已保存")
|
||||
} catch {
|
||||
if (notify) message.error("保存失败,请重试")
|
||||
} finally {
|
||||
savingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = content.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
activeTab.value = "chat"
|
||||
sendPrompt(text)
|
||||
wb.value?.send(text)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
html.value = ""
|
||||
css.value = ""
|
||||
js.value = ""
|
||||
}
|
||||
|
||||
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||
html.value = code.html
|
||||
css.value = code.css
|
||||
js.value = code.js
|
||||
}
|
||||
|
||||
function back() {
|
||||
disconnectPrompt()
|
||||
router.push({ name: "creation-list" })
|
||||
}
|
||||
|
||||
// 加载还没完成就离开页面时,不要再建立连接
|
||||
let disposed = false
|
||||
|
||||
onMounted(load)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
disconnectPrompt()
|
||||
closeGuidance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creation-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.creation-sider {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creation-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tab-pane) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topic-pane {
|
||||
.topic {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topic-hint {
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -103,6 +103,7 @@ import { Submission } from "../api"
|
||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { goHome } from "../utils/helper"
|
||||
import { saveTaskCode } from "../composables/useTaskCode"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
@@ -368,9 +369,15 @@ function afterScore() {
|
||||
}
|
||||
|
||||
function copyToEditor() {
|
||||
eHtml.value = html.value
|
||||
eCss.value = css.value
|
||||
eJs.value = js.value
|
||||
const code = { html: html.value, css: css.value, js: js.value }
|
||||
// 挑战页按任务各存一份代码,教程页还是共用编辑器
|
||||
if (submission.value.task_type === TASK_TYPE.Challenge) {
|
||||
saveTaskCode(submission.value.task_id, code)
|
||||
} else {
|
||||
eHtml.value = code.html
|
||||
eCss.value = code.css
|
||||
eJs.value = code.js
|
||||
}
|
||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||
}
|
||||
|
||||
|
||||
+27
-27
@@ -1,6 +1,5 @@
|
||||
import { ref } from "vue"
|
||||
import { ReconnectingSocket } from "../utils/socket"
|
||||
import { html, css, js } from "./editors"
|
||||
|
||||
export interface PromptMessage {
|
||||
role: "user" | "assistant"
|
||||
@@ -9,23 +8,25 @@ export interface PromptMessage {
|
||||
code?: { html: string | null; css: string | null; js: string | null }
|
||||
submitted?: boolean // whether this assistant message's code has been submitted
|
||||
created?: string
|
||||
// conversation:AI 对话;edit:学生手改;manual:粘贴外部 AI 的代码
|
||||
source?: string
|
||||
}
|
||||
|
||||
type CodeOut = { html: string | null; css: string | null; js: string | null }
|
||||
|
||||
export const messages = ref<PromptMessage[]>([])
|
||||
export const conversationId = ref<string>("")
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
export const currentTaskId = ref(0)
|
||||
let _onCodeComplete:
|
||||
| ((
|
||||
code: { html: string | null; css: string | null; js: string | null },
|
||||
messageId: number
|
||||
) => void)
|
||||
// 新代码交给工作台处理:init 是连上时对话里最新的那一版,complete 是 AI 刚生成的
|
||||
let _codeSink:
|
||||
| ((code: CodeOut, messageId: number, reason: "init" | "complete") => void)
|
||||
| null = null
|
||||
|
||||
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||
_onCodeComplete = fn
|
||||
export function setCodeSink(fn: typeof _codeSink) {
|
||||
_codeSink = fn
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
@@ -39,8 +40,8 @@ function onMessage(data: any) {
|
||||
const lastAssistant = [...messages.value]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.code)
|
||||
if (lastAssistant?.code) {
|
||||
applyCode(lastAssistant.code)
|
||||
if (lastAssistant?.code && lastAssistant.id) {
|
||||
_codeSink?.(lastAssistant.code, lastAssistant.id, "init")
|
||||
}
|
||||
}
|
||||
} else if (data.type === "stream") {
|
||||
@@ -54,14 +55,10 @@ function onMessage(data: any) {
|
||||
id: data.message_id,
|
||||
code: data.code,
|
||||
submitted: false,
|
||||
source: "conversation",
|
||||
})
|
||||
streamingContent.value = ""
|
||||
if (data.code) {
|
||||
applyCode(data.code)
|
||||
if (_onCodeComplete) {
|
||||
_onCodeComplete(data.code, data.message_id)
|
||||
}
|
||||
}
|
||||
if (data.code) _codeSink?.(data.code, data.message_id, "complete")
|
||||
} else if (data.type === "error") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
@@ -103,7 +100,7 @@ export function disconnectPrompt() {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
currentTaskId.value = 0
|
||||
_onCodeComplete = null
|
||||
_codeSink = null
|
||||
}
|
||||
|
||||
export function sendPrompt(content: string, model: string = "") {
|
||||
@@ -130,6 +127,19 @@ export function stopPrompt() {
|
||||
}
|
||||
}
|
||||
|
||||
// 手改或粘贴外部代码存成版本后,把这一问一答加到当前对话里显示
|
||||
export function addVersionPair(
|
||||
prompt: string,
|
||||
source: string,
|
||||
messageId: number,
|
||||
code: CodeOut,
|
||||
) {
|
||||
messages.value.push(
|
||||
{ role: "user", content: prompt, source },
|
||||
{ role: "assistant", content: "", id: messageId, code, submitted: false, source },
|
||||
)
|
||||
}
|
||||
|
||||
export function markMessageSubmitted(assistantMsgId: number) {
|
||||
const msg = messages.value.find((m) => m.id === assistantMsgId)
|
||||
if (msg) msg.submitted = true
|
||||
@@ -141,13 +151,3 @@ export function removeMessagePair(assistantMsgId: number) {
|
||||
messages.value.splice(idx - 1, 2)
|
||||
}
|
||||
}
|
||||
|
||||
function applyCode(code: {
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
}) {
|
||||
if (code.html !== null) html.value = code.html
|
||||
if (code.css !== null) css.value = code.css
|
||||
if (code.js !== null) js.value = code.js
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface Code {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
}
|
||||
|
||||
export const emptyCode = (): Code => ({ html: "", css: "", js: "" })
|
||||
|
||||
export const sameCode = (a: Code, b: Code) =>
|
||||
a.html === b.html && a.css === b.css && a.js === b.js
|
||||
|
||||
export const isEmptyCode = (c: Code) => !c.html.trim() && !c.css.trim() && !c.js.trim()
|
||||
|
||||
// 把一整页 HTML(外部 AI 常给的格式)拆成 html / css / js 三段
|
||||
export function splitHtml(raw: string): Code {
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
let result = raw.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||
cssBlocks.push(content.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, content) => {
|
||||
jsBlocks.push(content.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
// prettier 加上各语言插件近 1MB,只在点“整理代码”时才加载
|
||||
export async function formatCode(code: Code): Promise<Code> {
|
||||
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
|
||||
await Promise.all([
|
||||
import("prettier/standalone"),
|
||||
import("prettier/parser-html"),
|
||||
import("prettier/parser-postcss"),
|
||||
import("prettier/parser-babel"),
|
||||
import("prettier/plugins/estree"),
|
||||
])
|
||||
const [html, css, js] = await Promise.all([
|
||||
prettier.format(code.html, {
|
||||
parser: "html",
|
||||
//@ts-ignore
|
||||
plugins: [htmlParser, babelParser, estreeParser, cssParser],
|
||||
tabWidth: 4,
|
||||
}),
|
||||
prettier.format(code.css, {
|
||||
parser: "css",
|
||||
plugins: [cssParser],
|
||||
tabWidth: 4,
|
||||
}),
|
||||
prettier.format(code.js, {
|
||||
parser: "babel",
|
||||
//@ts-ignore
|
||||
plugins: [babelParser, estreeParser],
|
||||
tabWidth: 2,
|
||||
}),
|
||||
])
|
||||
return { html, css, js }
|
||||
}
|
||||
@@ -16,3 +16,10 @@ export function goHome(router: any, type: TASK_TYPE, display: number) {
|
||||
router.push({ name: "home" })
|
||||
}
|
||||
}
|
||||
|
||||
// 一轮对话 / 一个版本是怎么来的
|
||||
export function sourceLabel(source: string | null | undefined) {
|
||||
if (source === "edit") return "手改"
|
||||
if (source === "manual") return "外部 AI"
|
||||
return "AI 对话"
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const { values } = parseArgs({
|
||||
},
|
||||
})
|
||||
|
||||
const where = ["m.role = 'user'"]
|
||||
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (values["task-id"]) {
|
||||
where.push("c.task_id = $task_id")
|
||||
|
||||
@@ -2,14 +2,22 @@ import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { all, get, tx, iso, uuidOut } from "../db"
|
||||
import { Role, isStaff, requireUser } from "../auth"
|
||||
import { HttpError, ValidationError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||
import { HttpError, ValidationError, body, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||
import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
|
||||
import {
|
||||
classifyConversationMessages,
|
||||
classifyMessage,
|
||||
classifyMessagesBatch,
|
||||
inBackground,
|
||||
} from "../services/classifier"
|
||||
import type { MessageRow } from "../services/prompt"
|
||||
import {
|
||||
EDIT_PROMPT,
|
||||
canAccessTask,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
insertMessage,
|
||||
type MessageRow,
|
||||
} from "../services/prompt"
|
||||
|
||||
const router = new Hono<Env>()
|
||||
|
||||
@@ -133,6 +141,45 @@ router.get("/history/:task_id", (c) => {
|
||||
})
|
||||
|
||||
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
||||
// 保存一个不是 AI 生成的版本:学生手改的代码(source=edit),或者粘贴外部 AI 的代码(source=manual,带提示词)。
|
||||
// 存成一问一答放进当前对话,版本条和提示词链里都能看到;之后接着和 AI 对话,AI 也是在这份代码上改
|
||||
router.post("/versions", async (c) => {
|
||||
const user = requireUser(c)
|
||||
const p = await body(
|
||||
c,
|
||||
z.object({
|
||||
task_id: z.number().int(),
|
||||
html: z.string(),
|
||||
css: z.string(),
|
||||
js: z.string(),
|
||||
prompt: z.string().trim().max(5000).optional(),
|
||||
}),
|
||||
)
|
||||
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: p.task_id }), "任务不存在")
|
||||
if (!canAccessTask(user, p.task_id)) throw new HttpError(403, "没有权限")
|
||||
const source = p.prompt ? "manual" : "edit"
|
||||
const code = { code_html: p.html, code_css: p.css, code_js: p.js }
|
||||
const ids = tx(() => {
|
||||
const convId = getOrCreateActiveConversation(user.id, p.task_id)
|
||||
const userMessageId = insertMessage({
|
||||
conversation_id: convId,
|
||||
role: "user",
|
||||
content: p.prompt || EDIT_PROMPT,
|
||||
source,
|
||||
})
|
||||
const messageId = insertMessage({
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: codeAsMarkdown(code),
|
||||
source,
|
||||
...code,
|
||||
})
|
||||
return { userMessageId, messageId }
|
||||
})
|
||||
if (p.prompt) inBackground(classifyMessage(ids.userMessageId))
|
||||
return c.json({ message_id: ids.messageId, user_message_id: ids.userMessageId, source })
|
||||
})
|
||||
|
||||
router.post("/conversations/:conversation_id/classify", (c) => {
|
||||
const user = requireUser(c)
|
||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||
@@ -151,7 +198,7 @@ router.post("/classify-batch", (c) => {
|
||||
c,
|
||||
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
||||
)
|
||||
const where = ["m.role = 'user'"]
|
||||
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (q.task_id) {
|
||||
where.push("c.task_id = $task_id")
|
||||
|
||||
@@ -39,7 +39,7 @@ async function callLlm(content: string): Promise<number | null> {
|
||||
|
||||
export async function classifyMessage(messageId: number): Promise<number | null> {
|
||||
const msg = get<{ content: string }>(
|
||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
|
||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||
{ id: messageId },
|
||||
)
|
||||
if (!msg) return null
|
||||
@@ -56,7 +56,7 @@ export async function classifyMessage(messageId: number): Promise<number | null>
|
||||
export async function classifyConversationMessages(conversationId: string, force = false) {
|
||||
const rows = all<{ id: number }>(
|
||||
`SELECT id FROM prompt_message
|
||||
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
|
||||
WHERE conversation_id = $id AND role = 'user' AND source != 'edit' ${force ? "" : "AND prompt_level IS NULL"}
|
||||
ORDER BY created, id`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
|
||||
@@ -81,6 +81,23 @@ export function insertMessage(m: {
|
||||
return Number(res.lastInsertRowid)
|
||||
}
|
||||
|
||||
// 不是 AI 生成的版本(手改 / 粘贴外部 AI 的代码),AI 回复那一条的正文写成和 AI 输出一样的代码块,
|
||||
// 后面接着和 AI 对话时,它在上下文里看到的就是这份代码
|
||||
export const EDIT_PROMPT = "(手动修改了代码)"
|
||||
|
||||
export function codeAsMarkdown(code: {
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
}) {
|
||||
const parts = [
|
||||
code.code_css ? `<style>\n${code.code_css}\n</style>` : "",
|
||||
code.code_html ?? "",
|
||||
code.code_js ? `<script>\n${code.code_js}\n</script>` : "",
|
||||
].filter(Boolean)
|
||||
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||
}
|
||||
|
||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||
return get<MessageRow>(
|
||||
`SELECT * FROM prompt_message
|
||||
|
||||
+13
-4
@@ -1,7 +1,12 @@
|
||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||
import { all, get, run, iso, uuidOut } from "./db"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||
import {
|
||||
canAccessTask,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
insertMessage,
|
||||
} from "./services/prompt"
|
||||
import {
|
||||
extractCode,
|
||||
parseGuidanceResponse,
|
||||
@@ -80,6 +85,7 @@ function conversationHistory(conversationId: string) {
|
||||
return all<{
|
||||
id: number
|
||||
role: string
|
||||
source: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
@@ -87,8 +93,9 @@ function conversationHistory(conversationId: string) {
|
||||
submission_id: string | null
|
||||
created: string
|
||||
}>(
|
||||
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
|
||||
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
|
||||
// 除了 AI 对话,还有学生手改(edit)和粘贴外部 AI 代码(manual)的版本;引导对话(guidance)不算
|
||||
`SELECT id, role, source, content, code_html, code_css, code_js, submission_id, created
|
||||
FROM prompt_message WHERE conversation_id = $id AND source IN ('conversation', 'edit', 'manual')
|
||||
ORDER BY created, id`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
@@ -117,7 +124,8 @@ async function handlePrompt(ws: WS, data: any) {
|
||||
try {
|
||||
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
||||
role: m.role as ChatMessage["role"],
|
||||
content: m.content,
|
||||
// 以前“手动提交”存的 AI 回复正文是空的,代码只在 code_* 里
|
||||
content: m.role === "assistant" && !m.content ? codeAsMarkdown(m) : m.content,
|
||||
}))
|
||||
let full: string
|
||||
try {
|
||||
@@ -211,6 +219,7 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
messages: conversationHistory(d.conversationId).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
source: m.source,
|
||||
content: m.content,
|
||||
code:
|
||||
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
||||
|
||||
@@ -226,6 +226,34 @@ describe("提交与删除", () => {
|
||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||
})
|
||||
|
||||
test("保存手改 / 外部 AI 版本,交这一版时关联到它", async () => {
|
||||
makeUser("stu")
|
||||
const task = makeTask("T")
|
||||
const stu = await client("stu")
|
||||
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
|
||||
expect(edit.json.source).toBe("edit")
|
||||
const ext = await stu("POST", "/api/prompt/versions", {
|
||||
task_id: task,
|
||||
html: "<p>2",
|
||||
css: "",
|
||||
js: "",
|
||||
prompt: "用外部 AI 做的",
|
||||
})
|
||||
expect(ext.json.source).toBe("manual")
|
||||
const history = await stu("GET", `/api/prompt/history/${task}`)
|
||||
expect(history.json.map((h: any) => [h.source, h.prompt])).toEqual([
|
||||
["edit", "(手动修改了代码)"],
|
||||
["manual", "用外部 AI 做的"],
|
||||
])
|
||||
|
||||
const sub = await stu("POST", "/api/submission/", { task_id: task, html: "<p>1", message_id: edit.json.message_id })
|
||||
const linked = get(`SELECT submission_id FROM prompt_message WHERE id = $id`, { id: edit.json.message_id })
|
||||
expect(linked.submission_id).toBe(sub.json.id.replaceAll("-", ""))
|
||||
// 同一个版本再交一次,更新原来的提交,不新建
|
||||
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id })
|
||||
expect(all(`SELECT html FROM submission_submission`)).toEqual([{ html: "<p>1!" }])
|
||||
})
|
||||
|
||||
test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => {
|
||||
makeUser("stu")
|
||||
makeUser("other")
|
||||
|
||||
+32
-1
@@ -8,9 +8,12 @@ import { client, freshDb, makeTask, makeUser } from "./helpers"
|
||||
|
||||
// 假的模型接口:每段之间停一下,方便测试中途断开
|
||||
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||
// 最近一次发给模型的请求
|
||||
let lastRequest: any = null
|
||||
const llm = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
async fetch(req) {
|
||||
lastRequest = await req.json()
|
||||
const enc = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
@@ -126,6 +129,34 @@ describe("WebSocket 代码生成", () => {
|
||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||
})
|
||||
|
||||
test("手改的版本进入对话,AI 下一轮在它上面改", async () => {
|
||||
makeUser("stu")
|
||||
const task = makeTask("挑战")
|
||||
const stu = await client("stu")
|
||||
const c = (await connect(`/ws/prompt/${task}/?token=${stu.tokens.access_token}`))!
|
||||
await c.next("init")
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||
await c.next("complete")
|
||||
|
||||
const saved = await stu("POST", "/api/prompt/versions", {
|
||||
task_id: task,
|
||||
html: "<p>我改过的</p>",
|
||||
css: "p { color: red }",
|
||||
js: "",
|
||||
})
|
||||
expect(saved.json.source).toBe("edit")
|
||||
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "再加个按钮" }))
|
||||
await c.next("complete")
|
||||
const sent = lastRequest.messages.map((m: any) => `${m.role}:${m.content}`)
|
||||
// system、第一轮、手改那一轮、这一轮的提问
|
||||
expect(sent.at(-3)).toBe("user:(手动修改了代码)")
|
||||
expect(sent.at(-2)).toContain("<p>我改过的</p>")
|
||||
expect(sent.at(-2)).toContain("p { color: red }")
|
||||
expect(sent.at(-1)).toBe("user:再加个按钮")
|
||||
c.ws.close()
|
||||
})
|
||||
|
||||
test("token 无效或任务不存在时握手失败", async () => {
|
||||
const { task, token } = await setup()
|
||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||
|
||||
Reference in new issue
Block a user