Compare commits
4
Commits
951a6e0826
...
6f0dd33de3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f0dd33de3 | ||
|
|
81e1b233ee | ||
|
|
1ba31844a9 | ||
|
|
51ee246a6f |
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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><img src="{{ assets[0]?.name ?? "1.png" }}"></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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user