feat: 重新设计出题页的图片素材
- 出题页只放一个“图片素材(N)”按钮,素材在弹框里管理,不再挤在保存那一排 - 选图片(可多选)或拖进来就直接上传,文件名按服务端规则自动整理,不用先手填文件名 - 说明在代码里直接写文件名引用;每张图能复制文件名;删除前确认 - 同名替换后给图片地址加时间戳,避免还显示旧图 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
951a6e0826
commit
51ee246a6f
1 file changed
+167
-91
@@ -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><img src="{{ assets[0]?.name ?? "1.png" }}"></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>
|
||||||
Reference in new issue
Block a user