add creation
自由创作:新增 /creation 列表与详情页、Creation API 与类型定义, TaskPanel 增加入口,PromptPanel 支持 allowSubmit 控制提交按钮。 顺带升级依赖并按 prettier 重新格式化。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1
.github/workflows/deploy.yaml
vendored
1
.github/workflows/deploy.yaml
vendored
@@ -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
1340
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/api.ts
38
src/api.ts
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' })">
|
||||||
|
|||||||
274
src/pages/CreationDetail.vue
Normal file
274
src/pages/CreationDetail.vue
Normal 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
207
src/pages/CreationHome.vue
Normal 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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user