release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s

- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标
- 操作日志记录登录、删除、设置与标签操作
- API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
O96u committed 2026-09-09 14:46:52 +08:00
1 parent f17d79b236
commit 737235acff
110 files changed
+5881 -778

No files matched your search

+4 -6
View File
@@ -1,8 +1,8 @@
import type { BatchDeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { requireApiOrAdminAuth, assertImageOwnership } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db'
import { logActivity } from '../../utils/activity-log'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
@@ -67,13 +67,12 @@ export default defineEventHandler(async (event) => {
if (storageKey !== key) deleteImageIndex(storageKey)
deleted.push(key)
insertActivityLog({
logActivity(event, {
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
@@ -102,13 +101,12 @@ export default defineEventHandler(async (event) => {
await deleteImage(storageKey, key)
deleted.push(key)
insertActivityLog({
logActivity(event, {
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
+95
View File
@@ -0,0 +1,95 @@
import { requireApiOrAdminAuth } from '../../utils/access'
import { logActivity } from '../../utils/activity-log'
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants'
import { getImageIndexRow } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import {
addTagsToImage,
removeTagFromImage
} from '../../utils/tags'
import { handleTagApiError } from '../../utils/tag-api'
interface BatchTagsBody {
keys?: string[]
tagIds?: number[]
action?: 'add' | 'remove'
}
function validateKeys(keys: string[]): void {
for (const key of keys) {
if (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
) {
throw new Error(`INVALID_KEY:${key}`)
}
}
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const user = await getCurrentUser(event)
const body = await readBody<BatchTagsBody>(event)
const keys = Array.isArray(body?.keys)
? body.keys.filter(key => typeof key === 'string' && key.trim())
: []
const tagIds = Array.isArray(body?.tagIds)
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
: []
const action = body?.action === 'remove' ? 'remove' : 'add'
if (!keys.length) {
createApiError(event, 'INVALID_REQUEST', '请提供图片列表', 400)
}
if (!tagIds.length) {
createApiError(event, 'INVALID_REQUEST', '请提供标签列表', 400)
}
if (keys.length > MAX_DELETE_BATCH) {
createApiError(
event,
'INVALID_REQUEST',
`每次最多处理 ${MAX_DELETE_BATCH} 张图片`,
400
)
}
try {
validateKeys(keys)
} catch (error) {
if (error instanceof Error && error.message.startsWith('INVALID_KEY:')) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${error.message.slice(12)}`, 400)
}
throw error
}
let updated = 0
try {
for (const key of keys) {
if (action === 'add') {
addTagsToImage(key, tagIds, user!.id, user!.role === 'admin')
} else {
for (const tagId of tagIds) {
removeTagFromImage(key, tagId, user!.id, user!.role === 'admin')
}
}
updated++
}
} catch (error) {
handleTagApiError(event, error)
}
logActivity(event, {
action: 'tags',
key: 'images/batch-tags',
originalName: action === 'add'
? `批量打标(${keys.length} 张)`
: `批量移除标签(${keys.length} 张)`,
userId: user!.id
})
return { success: true, updated }
})
+6 -2
View File
@@ -1,5 +1,5 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { readBackendIdQuery } from '../../utils/image-query'
import { readBackendIdQuery, readTagFilterQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage'
@@ -12,8 +12,12 @@ export default defineEventHandler(async (event) => {
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const tagFilter = readTagFilterQuery(query)
if (tagFilter === null) {
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
}
const total = await countImages(userFilter, backendId)
const total = await countImages(userFilter, backendId, tagFilter)
return { total }
})
+4 -6
View File
@@ -1,7 +1,7 @@
import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { requireApiOrAdminAuth, assertImageOwnership } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db'
import { logActivity } from '../../utils/activity-log'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
@@ -40,13 +40,12 @@ export default defineEventHandler(async (event) => {
deleteImageIndex(key)
if (storageKey !== key) deleteImageIndex(storageKey)
insertActivityLog({
logActivity(event, {
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
@@ -69,13 +68,12 @@ export default defineEventHandler(async (event) => {
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
insertActivityLog({
logActivity(event, {
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
+12 -5
View File
@@ -7,9 +7,9 @@ import {
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { mapStoredImageToItem, attachTagsToImageItems } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { readBackendIdQuery, readContentTypeQuery, readUploadSourceQuery } from '../../utils/image-query'
import { readBackendIdQuery, readContentTypeQuery, readTagFilterQuery, readUploadSourceQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
@@ -39,6 +39,10 @@ export default defineEventHandler(async (event) => {
if (uploadSource === null) {
createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400)
}
const tagFilter = readTagFilterQuery(query)
if (tagFilter === null) {
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
}
const userFilter = await getImageUserFilter(event)
const listing = await listImages({
@@ -47,15 +51,18 @@ export default defineEventHandler(async (event) => {
userFilter,
backendId,
contentType,
uploadSource
uploadSource,
tagFilter
})
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key))
const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
const items = attachTagsToImageItems(
listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
)
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+12 -5
View File
@@ -7,9 +7,9 @@ import {
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { mapStoredImageToItem, attachTagsToImageItems } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { readBackendIdQuery, readContentTypeQuery, readUploadSourceQuery } from '../../utils/image-query'
import { readBackendIdQuery, readContentTypeQuery, readTagFilterQuery, readUploadSourceQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
@@ -48,6 +48,10 @@ export default defineEventHandler(async (event) => {
if (uploadSource === null) {
createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400)
}
const tagFilter = readTagFilterQuery(query)
if (tagFilter === null) {
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
}
const userFilter = await getImageUserFilter(event)
const result = await searchImages({
@@ -57,15 +61,18 @@ export default defineEventHandler(async (event) => {
userFilter,
backendId,
contentType,
uploadSource
uploadSource,
tagFilter
})
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const sourceMap = getUploadSourcesForKeys(result.items.map(item => item.key))
const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
const items = attachTagsToImageItems(
result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
)
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+60
View File
@@ -0,0 +1,60 @@
import { requireApiOrAdminAuth } from '../../utils/access'
import { logActivity } from '../../utils/activity-log'
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
import { getImageIndexRow } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { removeTagFromImage } from '../../utils/tags'
import { handleTagApiError } from '../../utils/tag-api'
interface ImageTagDeleteBody {
key?: string
tagId?: number
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const user = await getCurrentUser(event)
const query = getQuery(event)
const body = await readBody<ImageTagDeleteBody>(event).catch(() => ({} as ImageTagDeleteBody))
const key = (typeof query.key === 'string' ? query.key : undefined) ?? body?.key?.trim()
const tagId = Number(
typeof query.tagId === 'string' ? query.tagId : body?.tagId
)
if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
if (!Number.isInteger(tagId) || tagId <= 0) {
createApiError(event, 'INVALID_REQUEST', '无效的标签 ID', 400)
}
try {
const tags = removeTagFromImage(
key,
tagId,
user!.id,
user!.role === 'admin'
)
const indexed = getImageIndexRow(key)
logActivity(event, {
action: 'tags',
key,
originalName: indexed?.original_name ?? key,
userId: user!.id
})
return { tags }
} catch (error) {
handleTagApiError(event, error)
}
})
+54
View File
@@ -0,0 +1,54 @@
import { requireApiOrAdminAuth } from '../../utils/access'
import { logActivity } from '../../utils/activity-log'
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
import { getImageIndexRow } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { setImageTags } from '../../utils/tags'
import { handleTagApiError } from '../../utils/tag-api'
interface ImageTagsBody {
key?: string
tagIds?: number[]
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const user = await getCurrentUser(event)
const body = await readBody<ImageTagsBody>(event)
const key = body?.key?.trim()
if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
const tagIds = Array.isArray(body?.tagIds)
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
: []
try {
const tags = setImageTags(
key,
tagIds,
user!.id,
user!.role === 'admin'
)
const indexed = getImageIndexRow(key)
logActivity(event, {
action: 'tags',
key,
originalName: indexed?.original_name ?? key,
userId: user!.id
})
return { tags }
} catch (error) {
handleTagApiError(event, error)
}
})
+57
View File
@@ -0,0 +1,57 @@
import { requireApiOrAdminAuth } from '../../utils/access'
import { logActivity } from '../../utils/activity-log'
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
import { getImageIndexRow } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { addTagsToImage } from '../../utils/tags'
import { handleTagApiError } from '../../utils/tag-api'
interface ImageTagsBody {
key?: string
tagIds?: number[]
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const user = await getCurrentUser(event)
const body = await readBody<ImageTagsBody>(event)
const key = body?.key?.trim()
if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
const tagIds = Array.isArray(body?.tagIds)
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
: []
if (!tagIds.length) {
createApiError(event, 'INVALID_REQUEST', '请提供要添加的标签', 400)
}
try {
const tags = addTagsToImage(
key,
tagIds,
user!.id,
user!.role === 'admin'
)
const indexed = getImageIndexRow(key)
logActivity(event, {
action: 'tags',
key,
originalName: indexed?.original_name ?? key,
userId: user!.id
})
return { tags }
} catch (error) {
handleTagApiError(event, error)
}
})
+22 -1
View File
@@ -12,6 +12,7 @@ import {
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { parseTagIdsParam } from '../../utils/tags'
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
@@ -30,6 +31,25 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const tagIdsField = formData.find(part => part.name === 'tagIds')
let uploadTagIds: number[] = []
if (tagIdsField?.data?.length) {
const raw = new TextDecoder().decode(tagIdsField.data).trim()
try {
const parsed = JSON.parse(raw) as unknown
uploadTagIds = parseTagIdsParam(parsed)
} catch {
uploadTagIds = parseTagIdsParam(raw)
}
} else {
const repeatFields = formData.filter(part => part.name === 'tagIds' && part.data?.length)
if (repeatFields.length) {
uploadTagIds = parseTagIdsParam(
repeatFields.map(part => new TextDecoder().decode(part.data!).trim())
)
}
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
@@ -83,7 +103,8 @@ export default defineEventHandler(async (event) => {
bytes,
filename: part.filename,
source,
userId: uploadUserId
userId: uploadUserId,
tagIds: uploadTagIds
})
if ('error' in result) {