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
@@ -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>
|
||||
Reference in new issue
Block a user