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
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:
1 parent
f17d79b236
commit
737235acff
110 files changed
+5881
-778
No files matched your search
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user