feat(设置): 能查出并删掉没人用的图片,编辑器里删掉的图和换下来的头像都算
Deploy / deploy (push) Canceled after 0s

编辑器里删图、学生换头像都只改库不删文件,盘上越积越多。设置页加「没人用的图片」,
点「查一下」才扫(要扫整个库,不在进页面时跑),按编辑器插图 / 头像分两组,
缩略图可点开,单张删或整组删(先确认)。

判定宁可多留:扫库里每张表的每个文本列,文件名在哪儿出现过都算在用(老域名、
markdown 写法都认,不分大小写);只看 png/jpg/gif/bmp,目录里的 mp4 不碰;
一天内传的不算(可能还在编辑器里没保存);库里一张图都没提到时什么也不列(多半连错了库);
删之前重新扫一遍。服务器和机房图片目录各一份,要各清各的。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-20 20:50:02 +08:00
1 parent 4000b3a9f0
commit e3c0507d41
5 files changed
+274 -1

No files matched your search

+10
View File
@@ -60,6 +60,7 @@ import type {
AdminUserList,
DashboardInfo,
JudgeServerList,
OrphanImage,
OrphanTestCase,
AdminProblem,
AdminProblemList,
@@ -475,6 +476,15 @@ export function pruneInvalidTestcases(id?: string) {
return api.delete("admin/orphan-test-cases", { params: { id } })
}
export function listOrphanImages() {
return api.get<OrphanImage[]>("admin/orphan-images")
}
/** 不带参数是全删;带 folder 只删那个目录的,再带 name 只删一张 */
export function pruneOrphanImages(target?: Pick<OrphanImage, "folder"> & { name?: string }) {
return api.delete<{ deleted: number }>("admin/orphan-images", { params: target })
}
export function getJudgeServer() {
return api.get<JudgeServerList>("admin/judge-servers")
}
+145 -1
View File
@@ -5,7 +5,7 @@ import { classLabel } from "utils/classes"
import { errorMessage } from "utils/api"
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
import { copyToClipboard, parseTime } from "utils/functions"
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
import type { OrphanImage, OrphanTestCase, Server, WebsiteConfig } from "utils/types"
import { useConfigStore } from "shared/store/config"
import {
deleteJudgeServer,
@@ -13,7 +13,9 @@ import {
getJudgeServer,
getWebsite,
listInvalidTestcases,
listOrphanImages,
pruneInvalidTestcases,
pruneOrphanImages,
} from "../api"
/**
@@ -179,6 +181,54 @@ async function deleteTestcase(id?: string) {
getTestcases()
}
// ---------------------------------------------------------------- 没人用的图片
/**
* 要扫遍整个库(包括十几万条提交),所以不在进页面时自动查,点了才查。
* null = 还没查过
*/
const orphanImages = ref<OrphanImage[] | null>(null)
const scanningImages = ref(false)
const IMAGE_GROUPS = [
{ folder: "upload", label: "编辑器里删掉的图" },
{ folder: "avatar", label: "换下来的头像" },
] as const
const imageGroups = computed(() =>
IMAGE_GROUPS.map((group) => {
const images = (orphanImages.value ?? []).filter((image) => image.folder === group.folder)
const bytes = images.reduce((sum, image) => sum + image.size, 0)
return { ...group, images, bytes }
}).filter((group) => group.images.length),
)
async function scanImages() {
scanningImages.value = true
try {
orphanImages.value = await listOrphanImages()
} catch (error) {
message.error(errorMessage(error))
} finally {
scanningImages.value = false
}
}
async function deleteImages(target: Parameters<typeof pruneOrphanImages>[0]) {
try {
const { deleted } = await pruneOrphanImages(target)
message.success(`删掉了 ${deleted} 张`)
} catch (error) {
message.error(errorMessage(error))
}
scanImages()
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
onMounted(() => {
getWebsiteConfig()
getTestcases()
@@ -295,6 +345,49 @@ onMounted(() => {
</div>
</section>
<section class="block">
<h3>
没人用的图片
<n-button size="small" style="margin-left: 8px" :loading="scanningImages" @click="scanImages">
{{ orphanImages ? "再查一遍" : "查一下" }}
</n-button>
</h3>
<n-text v-if="orphanImages && !imageGroups.length" depth="3" class="hint">
都有地方在用
</n-text>
<div v-for="group in imageGroups" :key="group.folder" class="image-group">
<div class="image-group-head">
{{ group.label }}
<span class="count">{{ group.images.length }} 张 · {{ formatBytes(group.bytes) }}</span>
<n-popconfirm @positive-click="deleteImages({ folder: group.folder })">
<template #trigger>
<n-button size="small" style="margin-left: 8px">都删掉</n-button>
</template>
删掉这 {{ group.images.length }} 张,删了找不回来,确定吗?
</n-popconfirm>
</div>
<div class="thumbs">
<div v-for="image in group.images" :key="image.name" class="thumb">
<a
:href="image.url"
target="_blank"
:title="`${image.name} · ${parseTime(new Date(image.createTime * 1000), 'YYYY-MM-DD')}`"
>
<img :src="image.url" alt="" loading="lazy" />
</a>
<button
type="button"
class="thumb-delete"
title="删掉"
@click="deleteImages({ folder: image.folder, name: image.name })"
>
×
</button>
</div>
</div>
</div>
</section>
<section v-if="testcases.length" class="block">
<h3>
没有题目用的测试数据 <span class="count">{{ testcases.length }} 份</span>
@@ -372,6 +465,57 @@ h3 {
gap: 8px;
}
.image-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.image-group-head {
display: flex;
align-items: center;
font-size: 14px;
}
.thumbs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.thumb {
position: relative;
width: 72px;
height: 72px;
border-radius: 6px;
overflow: hidden;
border: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb-delete {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 20px;
cursor: pointer;
}
.toggle {
display: inline-flex;
align-items: center;