feat: 重新设计出题页的图片素材

- 出题页只放一个“图片素材(N)”按钮,素材在弹框里管理,不再挤在保存那一排
- 选图片(可多选)或拖进来就直接上传,文件名按服务端规则自动整理,不用先手填文件名
- 说明在代码里直接写文件名引用;每张图能复制文件名;删除前确认
- 同名替换后给图片地址加时间戳,避免还显示旧图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 12:21:48 +08:00
1 parent 951a6e0826
commit 51ee246a6f
1 file changed
+167 -91
+167 -91
View File
@@ -1,78 +1,57 @@
<template> <template>
<n-flex vertical> <n-button @click="show = true">
<n-button size="small" @click="showUpload = true">图片素材</n-button> 图片素材{{ assets.length ? `(${assets.length})` : "" }}
<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-button>
</n-flex>
</n-card>
</n-flex>
<n-modal <n-modal v-model:show="show" preset="card" title="图片素材" style="width: min(720px, 92vw)">
v-model:show="showUpload" <p class="hint">
preset="card" 题目和学生的网页里直接写文件名就能用,例如
title="上传素材" <code>&lt;img src="{{ assets[0]?.name ?? "1.png" }}"&gt;</code>。
style="width: 400px" 上传同名文件会替换原来的。
</p>
<label
:class="['drop', { over: dragging }]"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop"
> >
<n-form> <input type="file" accept="image/*" multiple hidden @change="onPick" />
<n-form-item label="文件名(如 1.png)"> <Icon icon="lucide:image" :width="22" />
<n-input v-model:value="uploadName" placeholder="1.png" /> <span v-if="uploading">正在上传…</span>
</n-form-item> <span v-else>点这里选图片,或者把图片拖进来(可以一次选多张)</span>
<n-form-item label="选择文件"> </label>
<n-upload :max="1" @change="onFileChange" :default-upload="false">
<n-button>选择文件</n-button> <div v-if="assets.length" class="grid">
</n-upload> <div v-for="a in assets" :key="a.name" class="asset">
</n-form-item> <div class="img">
</n-form> <img :src="a.url" :alt="a.name" loading="lazy" />
<template #footer> </div>
<n-flex justify="end"> <div class="name" :title="a.name">{{ a.name }}</div>
<n-button @click="showUpload = false">取消</n-button> <div class="tools">
<n-button <n-button size="tiny" quaternary @click="copyName(a.name)">复制文件名</n-button>
type="primary" <n-popconfirm @positive-click="remove(a.name)">
:disabled="!uploadName || !uploadFile" <template #trigger>
:loading="uploading" <n-button size="tiny" quaternary type="error">删除</n-button>
@click="upload"
>
上传
</n-button>
</n-flex>
</template> </template>
删除 {{ a.name }}?用到它的网页里图片会显示不出来。
</n-popconfirm>
</div>
</div>
</div>
<n-empty v-else description="还没有素材" style="margin: 24px 0 8px" />
</n-modal> </n-modal>
</n-flex>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from "vue" import { ref, watch } from "vue"
import { Icon } from "../../icons"
import { useMessage } from "naive-ui" 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 { TaskAssets } from "../../api"
import type { TaskAsset } from "../../utils/type" import type { TaskAsset } from "../../utils/type"
// 出题页的图片素材:存在 /media/tasks/<类型>/<序号>/ 下,预览时那里是网页的 base 地址
const props = defineProps<{ const props = defineProps<{
taskType: "challenge" | "tutorial" taskType: "challenge" | "tutorial"
display: number display: number
@@ -80,10 +59,9 @@ const props = defineProps<{
const message = useMessage() const message = useMessage()
const assets = ref<TaskAsset[]>([]) const assets = ref<TaskAsset[]>([])
const showUpload = ref(false) const show = ref(false)
const uploadName = ref("")
const uploadFile = ref<File | null>(null)
const uploading = ref(false) const uploading = ref(false)
const dragging = ref(false)
async function load() { async function load() {
if (!props.display) return if (!props.display) return
@@ -97,40 +75,56 @@ async function load() {
} }
} }
function onFileChange({ file }: { file: UploadFileInfo }) { // 和服务端的 validFilename 一样:空格换成下划线,只留字母(含中文)、数字和 - _ .
if (file.status === "pending") { function cleanName(name: string) {
uploadFile.value = file.file ?? null return name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "").slice(-100)
}
} }
async function upload() { async function uploadAll(files: File[]) {
if (!uploadName.value || !uploadFile.value) return const images = files.filter((f) => f.type.startsWith("image/"))
if (images.length < files.length) message.warning("只能上传图片,其他文件跳过了")
if (!images.length) return
uploading.value = true uploading.value = true
let done = 0
for (const file of images) {
const name = cleanName(file.name)
if (!name || name.startsWith(".")) {
message.error(`文件名“${file.name}”不能用,请改个名字再传`)
continue
}
try { try {
const asset = const asset =
props.taskType === "challenge" props.taskType === "challenge"
? await TaskAssets.uploadChallenge( ? await TaskAssets.uploadChallenge(props.display, name, file)
props.display, : await TaskAssets.uploadTutorial(props.display, name, file)
uploadName.value, const i = assets.value.findIndex((a) => a.name === asset.name)
uploadFile.value, // 同名替换后浏览器可能还用着旧图的缓存
) const fresh = { ...asset, url: `${asset.url}?t=${Date.now()}` }
: await TaskAssets.uploadTutorial( if (i >= 0) assets.value[i] = fresh
props.display, else assets.value.push(fresh)
uploadName.value, done++
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) { } catch (err: any) {
message.error(err?.response?.data?.detail ?? "上传失败") message.error(`${name}:${err?.response?.data?.detail ?? "上传失败"}`)
} finally {
uploading.value = false
} }
}
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) { async function remove(name: string) {
@@ -139,7 +133,7 @@ async function remove(name: string) {
? await TaskAssets.deleteChallenge(props.display, name) ? await TaskAssets.deleteChallenge(props.display, name)
: await TaskAssets.deleteTutorial(props.display, name) : await TaskAssets.deleteTutorial(props.display, name)
assets.value = assets.value.filter((a) => a.name !== name) assets.value = assets.value.filter((a) => a.name !== name)
message.success("删除成功") message.success("已删除")
} catch (err: any) { } catch (err: any) {
message.error(err?.response?.data?.detail ?? "删除失败") message.error(err?.response?.data?.detail ?? "删除失败")
} }
@@ -147,3 +141,85 @@ async function remove(name: string) {
watch(() => props.display, load, { immediate: true }) watch(() => props.display, load, { immediate: true })
</script> </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>