Compare commits

...
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 6f0dd33de3 feat: 重新设计自由创作列表
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 和挑战列表同一种样式:一行一个主题,显示描述、“已有作品 / 还没开始”和更新时间
- 一个都没有时给 4 个主题点子,点了带着名称和描述打开新建框
- 删除改用页面内确认;新建框打开时光标在名称上

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:51:06 +08:00
xuyueandClaude Opus 5.5 81e1b233ee feat: 重新设计登录框
- 记住上次选的班级(机房一节课一个班),打开就带出这个班的名单;不记姓名
- 姓名可以打字筛选,选好后光标自动跳到密码框
- 三个报错框合成一处;“管理员登录”改成“老师”;密码可以点眼睛看;加“忘了密码找老师重置”

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:23:56 +08:00
xuyueandClaude Opus 5.5 1ba31844a9 feat: 重新设计“帮我写清楚”弹框
- 标题从“调教提示词”改成“帮我把要求写清楚”,开头说明怎么用
- 学生每次发的标成“你的要求 · 第 N 版”,AI 的回复标成“AI 的建议”
- 输入框明确是“你的要求”;按钮改成“让 AI 再看看”和“用这段生成网页”,
  没改过的要求不会重复发;AI 回复时不能直接生成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:22:53 +08:00
xuyueandClaude Opus 5.5 51ee246a6f feat: 重新设计出题页的图片素材
- 出题页只放一个“图片素材(N)”按钮,素材在弹框里管理,不再挤在保存那一排
- 选图片(可多选)或拖进来就直接上传,文件名按服务端规则自动整理,不用先手填文件名
- 说明在代码里直接写文件名引用;每张图能复制文件名;删除前确认
- 同名替换后给图片地址加时间戳,避免还显示旧图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:21:48 +08:00
4 changed files with 567 additions and 334 deletions

No files matched your search

+63 -49
View File
@@ -2,12 +2,13 @@
<n-modal
preset="card"
title="登录"
style="width: 420px"
style="width: min(400px, 92vw)"
:bordered="false"
v-model:show="loginModal"
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form>
<n-tab-pane name="student" tab="学生">
<n-form :show-feedback="false" class="form">
<n-form-item label="班级">
<n-select
v-model:value="selectedClass"
@@ -21,37 +22,25 @@
<n-select
v-model:value="selectedUsername"
:options="nameOptions"
placeholder="选择姓名"
filterable
:placeholder="selectedClass ? '选择或输入姓名' : '先选班级'"
:loading="namesLoading"
:disabled="!selectedClass || namesLoading"
@update:value="focusPassword"
/>
</n-form-item>
<n-form-item label="密码">
<n-input
ref="studentPasswordRef"
type="password"
show-password-on="click"
v-model:value="studentPassword"
name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitStudent"
/>
</n-form-item>
<n-alert
type="error"
v-if="classesError"
class="message"
title="加载班级列表失败,请刷新重试"
/>
<n-alert
type="error"
v-if="namesError"
class="message"
title="加载姓名列表失败,请重新选择班级"
/>
<n-alert
type="error"
v-if="studentError"
class="message"
:title="studentError"
/>
<div v-if="studentError" class="error">{{ studentError }}</div>
<n-button
block
type="primary"
@@ -61,28 +50,26 @@
>
登录
</n-button>
<div class="note">忘了密码找老师重置</div>
</n-form>
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录">
<n-form>
<n-tab-pane name="admin" tab="老师">
<n-form :show-feedback="false" class="form">
<n-form-item label="用户名">
<n-input v-model:value="adminName" name="username" />
<n-input v-model:value="adminName" name="username" placeholder="用户名" />
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
show-password-on="click"
v-model:value="adminPassword"
name="password"
placeholder="输入密码,按 Enter 登录"
@keyup.enter="submitAdmin"
/>
</n-form-item>
<n-alert
type="error"
v-if="adminError"
class="message"
:title="adminError"
/>
<div v-if="adminError" class="error">{{ adminError }}</div>
<n-button
block
type="primary"
@@ -99,14 +86,17 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue"
import { ref, computed, nextTick, onMounted } from "vue"
import { Account } from "../api"
import { loginModal } from "../store/modal"
import { user } from "../store/user"
import { router } from "../router"
// Tab state
// 机房的电脑一节课一个班轮流用:记住班级,不记姓名
const CLASS_KEY = "login-class"
const activeTab = ref("student")
const studentPasswordRef = ref<{ focus: () => void } | null>(null)
// Student tab state
const selectedClass = ref<string | null>(null)
@@ -115,10 +105,8 @@ const studentPassword = ref("")
const studentLoading = ref(false)
const studentError = ref("")
// Classes data
const classes = ref<string[]>([])
const classesLoading = ref(false)
const classesError = ref(false)
const classOptions = computed(() =>
classes.value.map((c) => ({ label: c, value: c })),
)
@@ -126,7 +114,6 @@ const classOptions = computed(() =>
// Names data
const names = ref<{ name: string; username: string }[]>([])
const namesLoading = ref(false)
const namesError = ref(false)
const nameOptions = computed(() =>
names.value.map((n) => ({ label: n.name, value: n.username })),
)
@@ -137,28 +124,31 @@ const adminPassword = ref("")
const adminLoading = ref(false)
const adminError = ref("")
// Load classes on mount (cached — not reloaded on tab switch)
// 班级列表只在打开页面时取一次
onMounted(async () => {
classesLoading.value = true
classesError.value = false
try {
classes.value = await Account.listClasses()
} catch {
classesError.value = true
studentError.value = "班级列表没加载出来,请刷新页面重试"
return
} finally {
classesLoading.value = false
}
let saved: string | null = null
try {
saved = localStorage.getItem(CLASS_KEY)
} catch {}
if (saved && classes.value.includes(saved)) {
selectedClass.value = saved
onClassChange(saved)
}
})
// 换标签页时清掉输入的密码和错误;选好的班级和姓名留着
function onTabChange() {
// Reset student tab state
selectedClass.value = null
selectedUsername.value = null
studentPassword.value = ""
studentError.value = ""
namesError.value = false
names.value = []
// Reset admin tab state
adminName.value = ""
adminPassword.value = ""
adminError.value = ""
@@ -167,17 +157,24 @@ function onTabChange() {
async function onClassChange(classname: string) {
selectedUsername.value = null
names.value = []
namesError.value = false
studentError.value = ""
try {
localStorage.setItem(CLASS_KEY, classname)
} catch {}
namesLoading.value = true
try {
names.value = await Account.listNamesByClass(classname)
} catch {
namesError.value = true
studentError.value = "姓名列表没加载出来,请重新选一次班级"
} finally {
namesLoading.value = false
}
}
function focusPassword() {
nextTick(() => studentPasswordRef.value?.focus())
}
// 失败次数太多被暂时禁止登录时,显示服务端给的提示
function loginError(err: any, fallback: string) {
return err.response?.status === 429 ? err.response.data.detail : fallback
@@ -228,7 +225,24 @@ async function submitAdmin() {
</script>
<style scoped>
.message {
margin-bottom: 12px;
.form {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 4px;
}
.error {
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--wp-bad);
background: var(--wp-bad-soft);
}
.note {
font-size: 12px;
text-align: center;
color: var(--wp-faint);
}
</style>
+132 -104
View File
@@ -2,82 +2,74 @@
<n-modal
:show="isOpen"
preset="card"
title="调教提示词"
style="width: min(560px, 92vw)"
title="帮我把要求写清楚"
style="width: min(640px, 92vw)"
:mask-closable="!streaming"
@update:show="onModalClose"
>
<div class="guidance-body">
<div class="guidance-messages" ref="messagesRef">
<p class="intro">
AI 会看看你的要求哪里还不够清楚,给你提建议。照着建议改好下面的要求,再让它看看;
觉得可以了就点“用这段生成网页”。
</p>
<div ref="messagesRef" class="messages">
<div v-for="(msg, i) in messages" :key="msg.id ?? i" :class="['msg', msg.role]">
<div class="who">
{{ msg.role === "user" ? `你的要求 · 第 ${versionOf(i)} 版` : "AI 的建议" }}
</div>
<div class="content markdown-body" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="msg assistant">
<div class="who">AI 的建议</div>
<div v-if="!displayStreamingContent" class="typing">
<span></span><span></span><span></span>
</div>
<div
v-for="(msg, i) in messages"
:key="msg.id ?? i"
class="guidance-msg"
:class="msg.role"
>
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="guidance-msg assistant">
<div class="msg-role">AI</div>
<div v-if="!displayStreamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="msg-content"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
v-else
class="content markdown-body"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
</div>
<div v-if="isReady" class="ready-hint">
<Icon icon="lucide:check-circle" :width="16" />
提示词已优化,可以生成代码了!
</div>
<div v-if="isReady" class="ready">
<Icon icon="lucide:check-circle" :width="16" />
AI 觉得要求已经写清楚了,可以生成网页了
</div>
<div class="guidance-input">
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="修改你的提示词后发送..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<div>
<n-button
v-if="streaming"
type="error"
size="small"
@click="stopGuidance"
>
停止
</n-button>
<n-button
v-else
size="small"
:disabled="!draftPrompt.trim() || !connected"
@click="send"
>
发送
</n-button>
</div>
<div class="draft">
<div class="draft-label">你的要求</div>
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="按 AI 的建议把要求改得更具体:要什么内容、什么颜色和布局、点了以后会怎样……"
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" class="draft-actions">
<n-flex align="center" :size="8">
<n-button v-if="streaming" size="small" @click="stopGuidance">停止</n-button>
<n-button
:type="isReady ? 'primary' : 'default'"
v-else
size="small"
:disabled="!draftPrompt.trim()"
@click="generate"
:disabled="!draftPrompt.trim() || !connected || draftPrompt.trim() === lastSent"
@click="send"
>
<template #icon>
<Icon icon="lucide:play" :width="14" />
</template>
生成代码
让 AI 再看看
</n-button>
<span class="muted">Enter 发送,Shift + Enter 换行</span>
</n-flex>
</div>
<n-button
:type="isReady ? 'primary' : 'default'"
:disabled="!draftPrompt.trim() || streaming"
@click="generate"
>
<template #icon><Icon icon="lucide:play" :width="14" /></template>
用这段生成网页
</n-button>
</n-flex>
</div>
</n-modal>
</template>
@@ -106,13 +98,23 @@ const emit = defineEmits<{
const draftPrompt = ref("")
const messagesRef = ref<HTMLElement>()
// 第几版要求:只数学生发的
function versionOf(index: number) {
return messages.value.slice(0, index + 1).filter((m) => m.role === "user").length
}
// 没改过就不用再发一遍
const lastSent = computed(
() => [...messages.value].reverse().find((m) => m.role === "user")?.content.trim() ?? "",
)
const displayStreamingContent = computed(() =>
streamingContent.value.replace(/^\[READY\]\n?/, "")
)
function send() {
const text = draftPrompt.value.trim()
if (!text || streaming.value) return
if (!text || streaming.value || text === lastSent.value) return
sendGuidance(text)
}
@@ -141,87 +143,113 @@ watch([() => messages.value.length, streamingContent], () => {
</script>
<style scoped>
.guidance-body {
display: flex;
flex-direction: column;
gap: 12px;
.intro {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--wp-muted);
}
.guidance-messages {
min-height: 160px;
max-height: 360px;
.messages {
min-height: 120px;
max-height: 45vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.guidance-msg .msg-role {
font-size: 12px;
font-weight: bold;
.who {
margin-bottom: 4px;
font-size: 12px;
font-weight: 600;
color: var(--wp-muted);
}
.guidance-msg.user .msg-role {
color: var(--wp-css);
}
.guidance-msg.assistant .msg-role {
.msg.assistant .who {
color: var(--wp-brand);
}
.guidance-msg.assistant .msg-content {
background: var(--wp-css-soft);
border-left: 3px solid var(--wp-css);
.content {
padding: 8px 12px;
border-radius: 0 6px 6px 0;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
}
.guidance-msg.user .msg-content {
font-size: 14px;
line-height: 1.6;
line-height: 1.7;
color: var(--wp-ink);
}
.msg-content :deep(p) {
margin: 0;
.msg.user .content {
border: 1px solid var(--wp-line);
background: var(--wp-paper);
}
.ready-hint {
.msg.assistant .content {
border-left: 3px solid var(--wp-brand);
border-radius: 0 8px 8px 0;
background: var(--wp-brand-soft);
}
.content :deep(p) {
margin: 0 0 6px;
}
.content :deep(:last-child) {
margin-bottom: 0;
}
.ready {
display: flex;
align-items: center;
gap: 6px;
color: var(--wp-brand);
font-size: 13px;
font-weight: 500;
margin-top: 12px;
padding: 6px 10px;
background: var(--wp-brand-soft);
border-radius: 6px;
border: 1px solid var(--wp-brand-soft);
font-size: 13px;
color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.typing-indicator {
.draft {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--wp-line);
}
.draft-label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.draft-actions {
margin-top: 8px;
}
.muted {
font-size: 12px;
color: var(--wp-faint);
}
.typing {
display: flex;
gap: 5px;
padding: 8px 0;
padding: 10px 12px;
}
.typing-indicator span {
.typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--wp-css);
background: var(--wp-brand);
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
.typing span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
.typing span:nth-child(3) {
animation-delay: 0.4s;
}
+174 -98
View File
@@ -1,78 +1,57 @@
<template>
<n-flex vertical>
<n-button size="small" @click="showUpload = true">图片素材</n-button>
<n-flex v-if="assets.length" wrap>
<n-card
v-for="asset in assets"
:key="asset.name"
size="small"
style="width: 120px"
>
<template #cover>
<n-image
:src="asset.url"
style="width: 100%; height: 100px; object-fit: contain"
/>
</template>
<n-flex align="center" justify="space-between">
<n-text style="font-size: 12px; word-break: break-all">
{{ asset.name }}
</n-text>
<n-button
size="tiny"
quaternary
type="error"
@click="remove(asset.name)"
>
<template #icon>
<Icon icon="material-symbols:close-rounded" />
</template>
</n-button>
</n-flex>
</n-card>
</n-flex>
<n-button @click="show = true">
图片素材{{ assets.length ? `(${assets.length})` : "" }}
</n-button>
<n-modal
v-model:show="showUpload"
preset="card"
title="上传素材"
style="width: 400px"
<n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
<p class="hint">
题目和学生的网页里直接写文件名就能用,例如
<code>&lt;img src="{{ assets[0]?.name ?? "1.png" }}"&gt;</code>。
上传同名文件会替换原来的。
</p>
<label
:class="['drop', { over: dragging }]"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<n-form>
<n-form-item label="文件名(如 1.png)">
<n-input v-model:value="uploadName" placeholder="1.png" />
</n-form-item>
<n-form-item label="选择文件">
<n-upload :max="1" @change="onFileChange" :default-upload="false">
<n-button>选择文件</n-button>
</n-upload>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="showUpload = false">取消</n-button>
<n-button
type="primary"
:disabled="!uploadName || !uploadFile"
:loading="uploading"
@click="upload"
>
上传
</n-button>
</n-flex>
</template>
</n-modal>
</n-flex>
<input type="file" accept="image/*" multiple hidden @change="onPick" />
<Icon icon="lucide:image" :width="22" />
<span v-if="uploading">正在上传…</span>
<span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
</label>
<div v-if="assets.length" class="grid">
<div v-for="a in assets" :key="a.name" class="asset">
<div class="img">
<img :src="a.url" :alt="a.name" loading="lazy" />
</div>
<div class="name" :title="a.name">{{ a.name }}</div>
<div class="tools">
<n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
<n-popconfirm @positive-click="remove(a.name)">
<template #trigger>
<n-button size="tiny" quaternary type="error">删除</n-button>
</template>
删除 {{ a.name }}?用到它的网页里图片会显示不出来。
</n-popconfirm>
</div>
</div>
</div>
<n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
</n-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui"
import type { UploadFileInfo } from "naive-ui"
import copy from "copy-text-to-clipboard"
import { Icon } from "../../icons"
import { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type"
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
const props = defineProps<{
taskType: "challenge" | "tutorial"
display: number
@@ -80,10 +59,9 @@ const props = defineProps<{
const message = useMessage()
const assets = ref<TaskAsset[]>([])
const showUpload = ref(false)
const uploadName = ref("")
const uploadFile = ref<File | null>(null)
const show = ref(false)
const uploading = ref(false)
const dragging = ref(false)
async function load() {
if (!props.display) return
@@ -97,40 +75,56 @@ async function load() {
}
}
function onFileChange({ file }: { file: UploadFileInfo }) {
if (file.status === "pending") {
uploadFile.value = file.file ?? null
}
// 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
function cleanName(name: string) {
return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
}
async function upload() {
if (!uploadName.value || !uploadFile.value) return
async function uploadAll(files: File[]) {
const images = files.filter((f) => f.type.startsWith("image/"))
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
if (!images.length) return
uploading.value = true
try {
const asset =
props.taskType === "challenge"
? await TaskAssets.uploadChallenge(
props.display,
uploadName.value,
uploadFile.value,
)
: await TaskAssets.uploadTutorial(
props.display,
uploadName.value,
uploadFile.value,
)
const idx = assets.value.findIndex((a) => a.name === asset.name)
if (idx >= 0) assets.value[idx] = asset
else assets.value.push(asset)
message.success(`${asset.name} 上传成功`)
showUpload.value = false
uploadName.value = ""
uploadFile.value = null
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "上传失败")
} finally {
uploading.value = false
let done = 0
for (const file of images) {
const name = cleanName(file.name)
if (!name || name.startsWith(".")) {
message.error(`文件名“${file.name}”不能用,请改个名字再传`)
continue
}
try {
const asset =
props.taskType === "challenge"
? await TaskAssets.uploadChallenge(props.display, name, file)
: await TaskAssets.uploadTutorial(props.display, name, file)
const i = assets.value.findIndex((a) => a.name === asset.name)
// 同名替换后浏览器可能还用着旧图的缓存
const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
if (i >= 0) assets.value[i] = fresh
else assets.value.push(fresh)
done++
} catch (err: any) {
message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
}
}
uploading.value = false
if (done) message.success(`上传了 ${done} 张`)
}
function onPick(e: Event) {
const input = e.target as HTMLInputElement
uploadAll([...(input.files ?? [])])
input.value = ""
}
function onDrop(e: DragEvent) {
dragging.value = false
uploadAll([...(e.dataTransfer?.files ?? [])])
}
function copyName(name: string) {
copy(name)
message.success(`已复制 ${name}`)
}
async function remove(name: string) {
@@ -139,7 +133,7 @@ async function remove(name: string) {
? await TaskAssets.deleteChallenge(props.display, name)
: await TaskAssets.deleteTutorial(props.display, name)
assets.value = assets.value.filter((a) => a.name !== name)
message.success("删除成功")
message.success("已删除")
} catch (err: any) {
message.error(err?.response?.data?.detail ?? "删除失败")
}
@@ -147,3 +141,85 @@ async function remove(name: string) {
watch(() => props.display, load, { immediate: true })
</script>
<style scoped>
.hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--wp-muted);
}
.hint code {
padding: 1px 5px;
border-radius: 4px;
font: 12px var(--wp-font-mono);
color: var(--wp-ink);
background: var(--wp-paper);
}
.drop {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px;
border: 1px dashed var(--wp-line);
border-radius: 8px;
font-size: 13px;
color: var(--wp-muted);
background: var(--wp-paper);
cursor: pointer;
}
.drop:hover,
.drop.over {
border-color: var(--wp-brand);
color: var(--wp-brand);
}
.grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
max-height: 50vh;
overflow-y: auto;
}
.asset {
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.img {
height: 100px;
display: grid;
place-items: center;
overflow: hidden;
border-bottom: 1px solid var(--wp-line);
/* 透明图片放在浅色底上才看得出边界 */
background: var(--wp-paper);
}
.img img {
display: block;
max-width: 100%;
max-height: 100px;
}
.name {
padding: 6px 8px 0;
font: 12px var(--wp-font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tools {
display: flex;
justify-content: space-between;
padding: 2px 4px 4px;
}
</style>
+198 -83
View File
@@ -1,59 +1,48 @@
<template>
<main class="creation-home">
<n-flex
justify="space-between"
align="flex-end"
style="margin-bottom: 32px"
>
<header class="head">
<div>
<n-h2 style="margin: 0">自由创作</n-h2>
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
<h2>自由创作</h2>
<p class="sub">自己定一个主题,和 AI 一起把它做出来。不用交,也不打分</p>
</div>
<n-button type="primary" @click="openCreate">
<template #icon>
<Icon icon="lucide:plus" :width="16" />
</template>
<n-button type="primary" @click="openCreate()">
<template #icon><Icon icon="lucide:plus" /></template>
新建主题
</n-button>
</n-flex>
</header>
<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 v-if="!loading && !creations.length" class="empty">
<p>还没有创作。不知道做什么?从这些开始:</p>
<div class="ideas">
<button v-for="idea in IDEAS" :key="idea.title" class="idea" @click="openCreate(idea)">
<b>{{ idea.title }}</b>
<span>{{ idea.content }}</span>
</button>
</div>
</div>
<ol v-else class="list">
<li v-for="item in creations" :key="item.display">
<router-link class="row" :to="{ name: 'creation', params: { display: item.display } }">
<span class="main">
<span class="title">{{ item.title }}</span>
<span class="desc">{{ item.content || "还没写主题描述" }}</span>
</span>
<span :class="['state', { done: item.has_code }]">
{{ item.has_code ? "已有作品" : "还没开始" }}
</span>
<span class="time">{{ formatTime(item.modified) }}</span>
<n-popconfirm @positive-click="remove(item)">
<template #trigger>
<n-button text class="del" title="删除" @click.prevent.stop>
<Icon icon="lucide:trash-2" :width="15" />
</n-button>
</template>
删除「{{ item.title }}」?和它相关的对话也会一起删掉。
</n-popconfirm>
</router-link>
</li>
</ol>
</n-spin>
<n-modal
@@ -65,6 +54,7 @@
<n-form>
<n-form-item label="主题名称">
<n-input
ref="titleRef"
v-model:value="form.title"
placeholder="例如:我的番茄钟"
maxlength="100"
@@ -98,22 +88,30 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue"
import { nextTick, onMounted, reactive, ref } from "vue"
import { useRouter } from "vue-router"
import { useDialog, useMessage } from "naive-ui"
import { useMessage } from "naive-ui"
import { Icon } from "../icons"
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: "" })
const titleRef = ref<{ focus: () => void } | null>(null)
// 一个创作都没有时给几个主题点子,点了带进新建框
const IDEAS = [
{ title: "番茄钟", content: "25 分钟倒计时,能开始、暂停、重置,时间到了提醒我休息" },
{ title: "我的个人主页", content: "头像、自我介绍、爱好,再放几张喜欢的图片" },
{ title: "点击小游戏", content: "方块随机出现,点中加分,30 秒后显示得分" },
{ title: "今日待办", content: "能添加、勾选完成、删除待办,刷新后还在" },
]
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
@@ -124,14 +122,11 @@ function formatTime(value: string) {
})
}
function open(item: CreationSlim) {
router.push({ name: "creation", params: { display: item.display } })
}
function openCreate() {
form.title = ""
form.content = ""
function openCreate(idea?: { title: string; content: string }) {
form.title = idea?.title ?? ""
form.content = idea?.content ?? ""
showCreate.value = true
nextTick(() => titleRef.value?.focus())
}
async function submit() {
@@ -149,24 +144,14 @@ async function submit() {
}
}
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("删除失败,请重试")
}
},
})
async function remove(item: CreationSlim) {
try {
await Creation.remove(item.display)
creations.value = creations.value.filter((c) => c.display !== item.display)
message.success("已删除")
} catch {
message.error("删除失败,请重试")
}
}
onMounted(async () => {
@@ -180,23 +165,153 @@ onMounted(async () => {
<style scoped>
.creation-home {
max-width: 1180px;
max-width: 880px;
margin: 0 auto;
padding: 32px 20px 48px;
padding: 28px 20px 48px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.creation-card {
h2 {
margin: 0;
font-size: 22px;
}
.sub {
margin: 4px 0 0;
color: var(--wp-muted);
font-size: 13px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--wp-line);
border-radius: 10px;
background: var(--wp-surface);
overflow: hidden;
}
.list li + li {
border-top: 1px solid var(--wp-line);
}
.row {
display: grid;
grid-template-columns: 1fr auto 96px 24px;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: var(--wp-ink);
text-decoration: none;
}
.row:hover {
background: var(--wp-paper);
}
.row:focus-visible {
outline: 2px solid var(--wp-brand);
outline-offset: -2px;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.desc {
margin-top: 2px;
font-size: 12px;
color: var(--wp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.state {
font-size: 12px;
color: var(--wp-faint);
}
.state.done {
color: var(--wp-brand);
}
.time {
font: 12px var(--wp-font-mono);
color: var(--wp-muted);
text-align: right;
}
.del {
color: var(--wp-faint);
}
.del:hover {
color: var(--wp-bad);
}
.empty {
padding: 28px 24px;
border: 1px dashed var(--wp-line);
border-radius: 10px;
background: var(--wp-surface);
}
.empty p {
margin: 0 0 14px;
font-size: 14px;
color: var(--wp-muted);
}
.ideas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 10px;
}
.idea {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-paper);
color: var(--wp-ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.creation-desc {
font-size: 13px;
.idea:hover {
border-color: var(--wp-brand);
}
.idea b {
font-size: 14px;
}
.idea span {
font-size: 12px;
line-height: 1.6;
color: var(--wp-muted);
}
</style>