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,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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user