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:
xuyueandClaude Opus 5.5 committed 2026-10-26 23:59:24 +08:00
1 parent ff1eb2982e
commit 9c6c01eba1
29 files changed
+1748 -1069

No files matched your search

+14
View File
@@ -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"
+93 -70
View File
@@ -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>
+8 -69
View File
@@ -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;
+7 -4
View File
@@ -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>
+56
View File
@@ -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 }
}
+36
View File
@@ -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);
}
+144 -324
View File
@@ -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);
}
+71 -229
View File
@@ -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>
+10 -3
View File
@@ -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
View File
@@ -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
}
+66
View File
@@ -0,0 +1,66 @@
export interface Code {
html: string
css: string
js: string
}
export const emptyCode = (): Code => ({ html: "", css: "", js: "" })
export const sameCode = (a: Code, b: Code) =>
a.html === b.html && a.css === b.css && a.js === b.js
export const isEmptyCode = (c: Code) => !c.html.trim() && !c.css.trim() && !c.js.trim()
// 把一整页 HTML(外部 AI 常给的格式)拆成 html / css / js 三段
export function splitHtml(raw: string): Code {
const cssBlocks: string[] = []
const jsBlocks: string[] = []
let result = raw.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
cssBlocks.push(content.trim())
return ""
})
result = result.replace(
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
(_, content) => {
jsBlocks.push(content.trim())
return ""
},
)
return {
html: result.trim(),
css: cssBlocks.join("\n\n"),
js: jsBlocks.join("\n\n"),
}
}
// prettier 加上各语言插件近 1MB,只在点“整理代码”时才加载
export async function formatCode(code: Code): Promise<Code> {
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
await Promise.all([
import("prettier/standalone"),
import("prettier/parser-html"),
import("prettier/parser-postcss"),
import("prettier/parser-babel"),
import("prettier/plugins/estree"),
])
const [html, css, js] = await Promise.all([
prettier.format(code.html, {
parser: "html",
//@ts-ignore
plugins: [htmlParser, babelParser, estreeParser, cssParser],
tabWidth: 4,
}),
prettier.format(code.css, {
parser: "css",
plugins: [cssParser],
tabWidth: 4,
}),
prettier.format(code.js, {
parser: "babel",
//@ts-ignore
plugins: [babelParser, estreeParser],
tabWidth: 2,
}),
])
return { html, css, js }
}
+7
View File
@@ -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 对话"
}