编辑器里删图、学生换头像都只改库不删文件,盘上越积越多。设置页加「没人用的图片」, 点「查一下」才扫(要扫整个库,不在进页面时跑),按编辑器插图 / 头像分两组, 缩略图可点开,单张删或整组删(先确认)。 判定宁可多留:扫库里每张表的每个文本列,文件名在哪儿出现过都算在用(老域名、 markdown 写法都认,不分大小写);只看 png/jpg/gif/bmp,目录里的 mp4 不碰; 一天内传的不算(可能还在编辑器里没保存);库里一张图都没提到时什么也不列(多半连错了库); 删之前重新扫一遍。服务器和机房图片目录各一份,要各清各的。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4000b3a9f0
commit
e3c0507d41
5 files changed
+274
-1
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
type DashboardInfo,
|
||||
type JudgeServer,
|
||||
type JudgeServerList,
|
||||
type OrphanImage,
|
||||
type OrphanTestCase,
|
||||
type UploadImageResponse,
|
||||
type WebsiteConfig,
|
||||
@@ -184,6 +185,112 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
||||
return success(c, { deleted: targets.length })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 没人用的图片
|
||||
|
||||
/** 只认编辑器传上来的那几种。目录里别的东西(手工放进去的 mp4 之类)一律不碰 */
|
||||
const UPLOAD_IMAGE_RE = /^[\w-]+\.(?:png|jpe?g|gif|bmp)$/i
|
||||
const IMAGE_NAME_PATTERN = "([A-Za-z0-9_-]+\\.(?:png|jpe?g|gif|bmp))"
|
||||
/** 刚传的图可能还在编辑器里、题目还没保存,一天以内的不算 */
|
||||
const ORPHAN_IMAGE_GRACE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 库里提到过的图片文件名(小写)。不去列「哪些列放富文本」—— 题面、提示、公告、教程里都有,
|
||||
* 提交的代码里也有,以后还会有新表 —— 而是把每张表的每个文本列都扫一遍,并且只认文件名、
|
||||
* 不认路径:老域名的绝对地址、markdown 的 `` 都算用着。宁可多留,不能错删
|
||||
*/
|
||||
async function referencedImageNames() {
|
||||
const columns = await db.execute<{ table_name: string; column_name: string }>(sql`
|
||||
select c.table_name, c.column_name
|
||||
from information_schema.columns c
|
||||
join information_schema.tables t
|
||||
on t.table_schema = c.table_schema and t.table_name = c.table_name
|
||||
where c.table_schema = current_schema() and t.table_type = 'BASE TABLE'
|
||||
and c.udt_name in ('text', 'varchar', 'bpchar', 'json', 'jsonb', '_text', '_varchar')
|
||||
`)
|
||||
const tables = new Map<string, string[]>()
|
||||
for (const { table_name, column_name } of columns) {
|
||||
tables.set(table_name, [...(tables.get(table_name) ?? []), column_name])
|
||||
}
|
||||
const names = new Set<string>()
|
||||
// 一张表一条、挨个来:要扫全部提交,别和上课的请求抢那两个核
|
||||
for (const [table, columnNames] of tables) {
|
||||
const text = sql.join(
|
||||
columnNames.map((column) => sql`coalesce(${sql.identifier(column)}::text, '')`),
|
||||
sql` || ' ' || `,
|
||||
)
|
||||
const rows = await db.execute<{ name: string }>(sql`
|
||||
select distinct lower(m[1]) as name
|
||||
from (select ${text} as t from ${sql.identifier(table)}) s,
|
||||
regexp_matches(s.t, ${IMAGE_NAME_PATTERN}, 'gi') m
|
||||
`)
|
||||
for (const row of rows) names.add(row.name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 编辑器插图、头像各一个目录;头像换了、删了,旧文件同样留在盘上 */
|
||||
const IMAGE_FOLDERS = {
|
||||
upload: { directory: config.uploadDirectory, prefix: config.uploadUriPrefix },
|
||||
avatar: { directory: config.avatarDirectory, prefix: config.avatarUriPrefix },
|
||||
} as const
|
||||
|
||||
type ImageFolder = keyof typeof IMAGE_FOLDERS
|
||||
|
||||
async function orphanImages() {
|
||||
const onDisk = await Promise.all(
|
||||
(Object.keys(IMAGE_FOLDERS) as ImageFolder[]).map(async (folder) =>
|
||||
(await readdir(IMAGE_FOLDERS[folder].directory).catch(() => [] as string[]))
|
||||
.filter((name) => UPLOAD_IMAGE_RE.test(name))
|
||||
.map((name) => ({ folder, name })),
|
||||
),
|
||||
).then((lists) => lists.flat())
|
||||
if (onDisk.length === 0) return []
|
||||
const referenced = await referencedImageNames()
|
||||
// 一张都没提到,多半是连错了库(DATA_DIR 没设、起了一套空库),这时候全目录都会被当成没人用
|
||||
if (referenced.size === 0) return []
|
||||
const cutoff = Date.now() - ORPHAN_IMAGE_GRACE_MS
|
||||
const rows = await Promise.all(
|
||||
onDisk
|
||||
.filter(({ name }) => !referenced.has(name.toLowerCase()))
|
||||
.map(async ({ folder, name }) => {
|
||||
const { directory, prefix } = IMAGE_FOLDERS[folder]
|
||||
const info = await stat(resolve(directory, name)).catch(() => null)
|
||||
if (!info?.isFile() || info.mtimeMs > cutoff) return null
|
||||
return {
|
||||
folder,
|
||||
name,
|
||||
url: `${prefix}/${name}`,
|
||||
size: info.size,
|
||||
createTime: info.mtimeMs / 1000,
|
||||
} satisfies OrphanImage
|
||||
}),
|
||||
)
|
||||
return rows
|
||||
.filter((row): row is OrphanImage => row !== null)
|
||||
.sort((a, b) => b.createTime - a.createTime)
|
||||
}
|
||||
|
||||
adminConfRoutes.get("/orphan-images", requireSuperAdmin, async (c) => {
|
||||
return success(c, await orphanImages())
|
||||
})
|
||||
|
||||
adminConfRoutes.delete("/orphan-images", requireSuperAdmin, async (c) => {
|
||||
const folder = c.req.query("folder")
|
||||
const name = c.req.query("name")
|
||||
// 删之前重新扫一遍:列表是刚才看的,这期间可能有人把图插回了题面、把头像换了回去
|
||||
const orphans = await orphanImages()
|
||||
const targets = orphans.filter(
|
||||
(image) => (!folder || image.folder === folder) && (!name || image.name === name),
|
||||
)
|
||||
if (name && targets.length === 0) {
|
||||
return failure(c, 404, "not-an-orphan", "这张图片不存在或还有地方在用,未删除")
|
||||
}
|
||||
for (const image of targets) {
|
||||
await rm(resolve(IMAGE_FOLDERS[image.folder].directory, image.name), { force: true })
|
||||
}
|
||||
return success(c, { deleted: targets.length })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 概览 / 随机点名
|
||||
|
||||
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -313,6 +313,7 @@ export type {
|
||||
JudgeServerList,
|
||||
DashboardInfo,
|
||||
OrphanTestCase,
|
||||
OrphanImage,
|
||||
AdminUser,
|
||||
AdminUserList,
|
||||
AdminContestList,
|
||||
|
||||
@@ -361,6 +361,16 @@ export const orphanTestCaseSchema = z.object({
|
||||
createTime: z.number(),
|
||||
})
|
||||
|
||||
/** 上传目录里、库里哪儿都没提到的图片:编辑器插的图(upload)和换下来的头像(avatar) */
|
||||
export const orphanImageSchema = z.object({
|
||||
folder: z.enum(["upload", "avatar"]),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
/** 字节 */
|
||||
size: z.number(),
|
||||
createTime: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 后台的用户排名列表。行的形状与 oj 侧榜单相同,但**没有 100 名的上限**:
|
||||
* 这里不是榜单,是老师按班级前缀翻全班学生的管理视图,要能一直翻到底。
|
||||
@@ -1130,6 +1140,7 @@ export type JudgeServer = z.infer<typeof judgeServerSchema>
|
||||
export type DashboardInfo = z.infer<typeof dashboardInfoSchema>
|
||||
export type JudgeServerList = z.infer<typeof judgeServerListSchema>
|
||||
export type OrphanTestCase = z.infer<typeof orphanTestCaseSchema>
|
||||
export type OrphanImage = z.infer<typeof orphanImageSchema>
|
||||
export type AdminUserRank = z.infer<typeof adminUserRankSchema>
|
||||
|
||||
export type AdminAiReportListItem = z.infer<typeof adminAiReportListItemSchema>
|
||||
|
||||
Reference in new issue
Block a user