add creation
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled

自由创作:新增 /creation 列表与详情页、Creation API 与类型定义,
TaskPanel 增加入口,PromptPanel 支持 allowSubmit 控制提交按钮。
顺带升级依赖并按 prettier 重新格式化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 09:28:05 -06:00
parent fd0fffb185
commit 1f6935e31e
10 changed files with 1557 additions and 421 deletions

View File

@@ -12,6 +12,7 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
include: include:
- name: debian - name: debian

1340
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,32 +11,32 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"axios": "^1.16.0", "axios": "^1.19.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"github-markdown-css": "^5.9.0", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.12.0",
"marked": "^18.0.0", "marked": "^18.0.9",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-code-preview": "^1.3.7", "marked-code-preview": "^1.3.7",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"md-editor-v3": "^6.5.0", "md-editor-v3": "^6.5.6",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"vue": "^3.5.33", "vue": "^3.5.41",
"vue-codemirror": "^6.1.1", "vue-codemirror": "^6.1.1",
"vue-router": "^5.0.6" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.1",
"@rsbuild/core": "^2.0.3", "@rsbuild/core": "^2.1.13",
"@rsbuild/plugin-vue": "^1.2.7", "@rsbuild/plugin-vue": "^2.0.1",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"core-js": "^3.49.0", "core-js": "^3.50.0",
"prettier": "^3.8.3", "prettier": "^3.9.6",
"typescript": "^6.0.3", "typescript": "^7.0.2",
"unplugin-vue-components": "^32.0.0" "unplugin-vue-components": "^32.1.0"
} }
} }

View File

@@ -21,6 +21,9 @@ import type {
ShowcaseDetail, ShowcaseDetail,
PromptRound, PromptRound,
RandomRatingItem, RandomRatingItem,
CreationSlim,
CreationDetail,
CreationIn,
} from "./utils/type" } from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const" import { BASE_URL, STORAGE_KEY } from "./utils/const"
@@ -164,6 +167,41 @@ export const Challenge = {
}, },
} }
export const Creation = {
async listMine(): Promise<CreationSlim[]> {
const res = await http.get("/creation/mine")
return res.data
},
async create(payload: CreationIn): Promise<CreationDetail> {
const res = await http.post("/creation/", payload)
return res.data
},
async get(display: number): Promise<CreationDetail> {
const res = await http.get(`/creation/${display}`)
return res.data
},
async update(display: number, payload: CreationIn): Promise<CreationDetail> {
const res = await http.put(`/creation/${display}`, payload)
return res.data
},
async saveCode(
display: number,
code: { html: string; css: string; js: string },
): Promise<CreationDetail> {
const res = await http.put(`/creation/${display}/code`, code)
return res.data
},
async remove(display: number) {
const res = await http.delete(`/creation/${display}`)
return res.data
},
}
export const Submission = { export const Submission = {
async create( async create(
taskId: number, taskId: number,

View File

@@ -20,14 +20,23 @@
<!-- User message --> <!-- User message -->
<div class="message user"> <div class="message user">
<div class="message-role"></div> <div class="message-role"></div>
<div class="message-content" v-html="renderContent(pair.userMsg)"></div> <div
class="message-content"
v-html="renderContent(pair.userMsg)"
></div>
</div> </div>
<!-- Assistant message --> <!-- Assistant message -->
<div v-if="pair.assistantMsg" class="message assistant"> <div v-if="pair.assistantMsg" class="message assistant">
<div class="message-role">AI</div> <div class="message-role">AI</div>
<div class="message-content" v-html="renderContent(pair.assistantMsg)"></div> <div
<div v-if="hasCode(pair.assistantMsg)" class="message-actions"> class="message-content"
v-html="renderContent(pair.assistantMsg)"
></div>
<div
v-if="allowSubmit && hasCode(pair.assistantMsg)"
class="message-actions"
>
<n-button <n-button
size="tiny" size="tiny"
:disabled="pair.assistantMsg.submitted" :disabled="pair.assistantMsg.submitted"
@@ -128,6 +137,8 @@ import {
import { Prompt, Submission } from "../../api" import { Prompt, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
const emit = defineEmits<{ deleted: []; submitted: [] }>() const emit = defineEmits<{ deleted: []; submitted: [] }>()
const input = ref("") const input = ref("")
@@ -160,7 +171,8 @@ const pairs = computed(() => {
let i = 0 let i = 0
while (i < msgs.length) { while (i < msgs.length) {
if (msgs[i].role === "user") { if (msgs[i].role === "user") {
const assistantMsg = msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null const assistantMsg =
msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
result.push({ userMsg: msgs[i], assistantMsg, index: i }) result.push({ userMsg: msgs[i], assistantMsg, index: i })
i += assistantMsg ? 2 : 1 i += assistantMsg ? 2 : 1
} else { } else {
@@ -294,7 +306,6 @@ watch([() => messages.value.length, streamingContent], () => {
margin-top: 6px; margin-top: 6px;
} }
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
@@ -374,7 +385,9 @@ watch([() => messages.value.length, streamingContent], () => {
cursor: pointer; cursor: pointer;
color: #bbb; color: #bbb;
line-height: 1; line-height: 1;
transition: color 0.15s, border-color 0.15s; transition:
color 0.15s,
border-color 0.15s;
} }
.pair-delete-btn:hover { .pair-delete-btn:hover {

View File

@@ -57,6 +57,14 @@
</template> </template>
提交记录 提交记录
</n-tooltip> </n-tooltip>
<n-tooltip v-if="authed" trigger="hover">
<template #trigger>
<n-button text @click="$router.push({ name: 'creation-list' })">
<Icon :width="16" icon="lucide:sparkles"></Icon>
</n-button>
</template>
自由创作
</n-tooltip>
<n-tooltip v-if="authed" trigger="hover"> <n-tooltip v-if="authed" trigger="hover">
<template #trigger> <template #trigger>
<n-button text @click="$router.push({ name: 'showcase' })"> <n-button text @click="$router.push({ name: 'showcase' })">

View File

@@ -0,0 +1,274 @@
<template>
<div class="creation-layout">
<div class="creation-sider">
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
<template #prefix>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
</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"
show-code-button
clearable
@showCode="showCode = true"
@clear="clearAll"
/>
</div>
</div>
<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 { onMounted, onUnmounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import PromptPanel from "../components/ai/PromptPanel.vue"
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
import Preview from "../components/editor/Preview.vue"
import { Creation } from "../api"
import { html, css, js } from "../store/editors"
import {
connectPrompt,
disconnectPrompt,
removeMessagePair,
sendPrompt,
setOnCodeComplete,
streaming,
} from "../store/prompt"
const route = useRoute()
const router = useRouter()
const message = useMessage()
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"
})
async function load() {
const data = await Creation.get(display)
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())
}
async function saveTopic() {
if (!title.value.trim()) return
savingTopic.value = true
try {
await Creation.update(display, {
title: title.value.trim(),
content: content.value,
})
message.success("主题已保存")
} catch {
message.error("保存失败,请重试")
} finally {
savingTopic.value = false
}
}
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)
}
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" })
}
onMounted(load)
onUnmounted(disconnectPrompt)
</script>
<style scoped>
.creation-layout {
display: flex;
height: 100vh;
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 {
padding: 12px;
overflow-y: auto;
flex: 1;
min-height: 0;
box-sizing: border-box;
}
.topic-hint {
display: block;
margin-top: 12px;
font-size: 12px;
}
</style>

207
src/pages/CreationHome.vue Normal file
View File

@@ -0,0 +1,207 @@
<template>
<main class="creation-home">
<n-flex
justify="space-between"
align="flex-end"
style="margin-bottom: 32px"
>
<div>
<n-flex align="center" :size="8">
<n-button text @click="$router.push({ name: 'home' })">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
<n-h2 style="margin: 0">自由创作</n-h2>
</n-flex>
<n-text depth="3">自己定一个主题 AI 一起把它做出来</n-text>
</div>
<n-button type="primary" @click="openCreate">
<template #icon>
<Icon icon="lucide:plus" :width="16" />
</template>
新建主题
</n-button>
</n-flex>
<n-spin :show="loading">
<n-empty
v-if="!loading && !creations.length"
description="还没有创作,点右上角新建一个主题吧"
style="margin-top: 72px"
/>
<div v-else class="card-grid">
<n-card
v-for="item in creations"
:key="item.display"
class="creation-card"
hoverable
@click="open(item)"
>
<template #header>
<n-ellipsis style="max-width: 100%">{{ item.title }}</n-ellipsis>
</template>
<template #header-extra>
<n-flex :size="4" align="center">
<n-tag v-if="item.has_code" type="success" size="small">
已有作品
</n-tag>
<n-button text @click.stop="remove(item)">
<Icon icon="lucide:trash-2" :width="15" />
</n-button>
</n-flex>
</template>
<n-ellipsis :line-clamp="2" class="creation-desc">
{{ item.content || "还没写主题描述" }}
</n-ellipsis>
<template #footer>
<n-text depth="3" style="font-size: 12px">
更新于 {{ formatTime(item.modified) }}
</n-text>
</template>
</n-card>
</div>
</n-spin>
<n-modal
v-model:show="showCreate"
preset="card"
title="新建主题"
style="width: 520px"
>
<n-form>
<n-form-item label="主题名称">
<n-input
v-model:value="form.title"
placeholder="例如:我的番茄钟"
maxlength="100"
show-count
/>
</n-form-item>
<n-form-item label="主题描述(可以之后再补充)">
<n-input
v-model:value="form.content"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="flex-end">
<n-button @click="showCreate = false">取消</n-button>
<n-button
type="primary"
:loading="creating"
:disabled="!form.title.trim()"
@click="submit"
>
创建并开始
</n-button>
</n-flex>
</template>
</n-modal>
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue"
import { useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Creation } from "../api"
import type { CreationSlim } from "../utils/type"
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const creating = ref(false)
const showCreate = ref(false)
const creations = ref<CreationSlim[]>([])
const form = reactive({ title: "", content: "" })
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
function open(item: CreationSlim) {
router.push({ name: "creation", params: { display: item.display } })
}
function openCreate() {
form.title = ""
form.content = ""
showCreate.value = true
}
async function submit() {
const title = form.title.trim()
if (!title) return
creating.value = true
try {
const created = await Creation.create({ title, content: form.content })
showCreate.value = false
router.push({ name: "creation", params: { display: created.display } })
} catch {
message.error("创建失败,请重试")
} finally {
creating.value = false
}
}
function remove(item: CreationSlim) {
dialog.warning({
title: "删除主题",
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
try {
await Creation.remove(item.display)
creations.value = creations.value.filter(
(c) => c.display !== item.display,
)
message.success("已删除")
} catch {
message.error("删除失败,请重试")
}
},
})
}
onMounted(async () => {
try {
creations.value = await Creation.listMine()
} finally {
loading.value = false
}
})
</script>
<style scoped>
.creation-home {
max-width: 1180px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.creation-card {
cursor: pointer;
}
.creation-desc {
font-size: 13px;
color: #666;
}
</style>

View File

@@ -7,9 +7,24 @@ import { authed } from "./store/user"
const routes = [ const routes = [
{ path: "/", name: "home", component: Workspace, meta: { auth: true } }, { path: "/", name: "home", component: Workspace, meta: { auth: true } },
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace, meta: { auth: true } }, {
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace, meta: { auth: true } }, path: "/tutorial",
{ path: "/challenge", name: "home-challenge-list", component: Workspace, meta: { auth: true } }, name: "home-tutorial-list",
component: Workspace,
meta: { auth: true },
},
{
path: "/tutorial/:display",
name: "home-tutorial",
component: Workspace,
meta: { auth: true },
},
{
path: "/challenge",
name: "home-challenge-list",
component: Workspace,
meta: { auth: true },
},
{ {
path: "/challenge/:display", path: "/challenge/:display",
name: "home-challenge", name: "home-challenge",
@@ -29,6 +44,18 @@ const routes = [
props: true, props: true,
meta: { auth: true }, meta: { auth: true },
}, },
{
path: "/creation",
name: "creation-list",
component: () => import("./pages/CreationHome.vue"),
meta: { auth: true },
},
{
path: "/creation/:display",
name: "creation",
component: () => import("./pages/CreationDetail.vue"),
meta: { auth: true },
},
{ {
path: "/showcase", path: "/showcase",
name: "showcase", name: "showcase",

View File

@@ -96,6 +96,28 @@ export interface ChallengeIn {
example_js?: string | null example_js?: string | null
} }
export interface CreationSlim {
display: number
title: string
content: string
created: string
modified: string
has_code: boolean
}
export interface CreationDetail extends CreationSlim {
task_id: number
html: string
css: string
js: string
owner_name: string
}
export interface CreationIn {
title: string
content: string
}
export interface User { export interface User {
id: number id: number
username: string username: string