diff --git a/app/components/UploadPreferencesPanel.vue b/app/components/UploadPreferencesPanel.vue
index 03ce0ff..df9eab0 100644
--- a/app/components/UploadPreferencesPanel.vue
+++ b/app/components/UploadPreferencesPanel.vue
@@ -17,9 +17,11 @@ const {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
+ lastTagIds,
transcodeToHls,
loadPreferences
} = useUploadPreferences(props.kind)
+const { items: tagItems, fetchTags } = useTags()
/** 视频床 / 文件床没有图片压缩相关选项 */
const isImageKind = computed(() => props.kind === 'image')
@@ -76,6 +78,7 @@ onMounted(() => {
loadPreferences()
void loadUserSettings()
void loadTranscodeSettings()
+ void fetchTags()
})
@@ -108,7 +111,7 @@ onMounted(() => {
/>
-
+
+
+
+ {{ t('preferences.uploadTags') }}
+
+
+ {{ t('preferences.uploadTagsHint') }}
+
+
+
diff --git a/app/composables/useApiDocs.ts b/app/composables/useApiDocs.ts
index 2f7e4a6..b0075e2 100644
--- a/app/composables/useApiDocs.ts
+++ b/app/composables/useApiDocs.ts
@@ -310,6 +310,13 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.token'
+ },
+ {
+ name: 'tagNames',
+ type: 'string',
+ location: 'form-data',
+ required: false,
+ descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/videos/upload" \\
@@ -317,7 +324,8 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
-F "video=@./demo.mp4" \\
-F "transcode=1" \\
-F "transcodePreset=compress" \\
- -F "transcodeBitratePercent=80"`,
+ -F "transcodeBitratePercent=80" \\
+ -F 'tagNames=["博客","视频"]'`,
responseExample: `{
"success": true,
"items": [
@@ -329,7 +337,11 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
"size": 10485760,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[demo.mp4](https://pic.example.com/videos/2026/10/abc123.mp4)",
- "thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp"
+ "thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
+ "tags": [
+ { "id": 3, "name": "博客", "color": "#22c55e" },
+ { "id": 8, "name": "视频", "color": "#3b82f6" }
+ ]
}
],
"errors": [],
@@ -619,11 +631,19 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.token'
+ },
+ {
+ name: 'tagNames',
+ type: 'string',
+ location: 'form-data',
+ required: false,
+ descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/files/upload" \\
${auth} \\
- -F "file=@./report.pdf"`,
+ -F "file=@./report.pdf" \\
+ -F 'tagNames=["文档","归档"]'`,
responseExample: `{
"success": true,
"items": [
@@ -634,7 +654,11 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
- "markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)"
+ "markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)",
+ "tags": [
+ { "id": 4, "name": "文档", "color": "#f59e0b" },
+ { "id": 9, "name": "归档", "color": "#64748b" }
+ ]
}
],
"errors": [],
diff --git a/app/composables/useMediaUpload.ts b/app/composables/useMediaUpload.ts
index 445eb46..45cf31c 100644
--- a/app/composables/useMediaUpload.ts
+++ b/app/composables/useMediaUpload.ts
@@ -54,8 +54,10 @@ export function useMediaUpload(kind: UploadMediaKind) {
autoCopyMarkdown,
copyFormat,
transcodeToHls,
+ lastTagIds,
loadPreferences
} = useUploadPreferences(kind)
+ const { items: tagItems, fetchTags } = useTags()
const uploading = ref(false)
const progressItems = ref([])
@@ -232,6 +234,15 @@ export function useMediaUpload(kind: UploadMediaKind) {
if (kind === 'video' && transcodeToHls.value) {
formData.append('transcode', '1')
}
+ if (lastTagIds.value.length) {
+ await fetchTags()
+ const names = lastTagIds.value
+ .map(id => tagItems.value.find(tag => tag.id === id)?.name)
+ .filter((name): name is string => Boolean(name))
+ if (names.length) {
+ formData.append('tagNames', JSON.stringify(names))
+ }
+ }
progressItems.value = progressItems.value.map(item => ({
...item,
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 2eaf925..d41623c 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -327,7 +327,9 @@
"transcodeLoadFailed": "Failed to load transcode settings",
"transcodeSaveFailed": "Failed to save transcode settings",
"autoCopy": "Copy link after upload",
- "autoCopyHint": "Copy to clipboard in the selected format"
+ "autoCopyHint": "Copy to clipboard in the selected format",
+ "uploadTags": "Upload tags",
+ "uploadTagsHint": "Tags selected here are applied to images, videos, and files; missing tags are created automatically."
},
"settings": {
"title": "System settings",
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index ea3b8e8..17841b3 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -327,7 +327,9 @@
"transcodeLoadFailed": "读取转码设置失败",
"transcodeSaveFailed": "保存转码设置失败",
"autoCopy": "上传成功自动复制链接",
- "autoCopyHint": "上传成功后,自动复制链接到剪贴板。"
+ "autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
+ "uploadTags": "上传标签",
+ "uploadTagsHint": "图片、视频和文件共用当前上传页选择的标签;不存在的标签会自动创建。"
},
"settings": {
"title": "系统设置",
diff --git a/server/api/files/upload.post.ts b/server/api/files/upload.post.ts
index f97a2da..bbc398f 100644
--- a/server/api/files/upload.post.ts
+++ b/server/api/files/upload.post.ts
@@ -11,6 +11,11 @@ import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { processSingleFileUpload } from '../../utils/file-bed'
import { getFileBedAccessMode } from '../../utils/env'
+import { readMultipartFormFieldValues } from '../../utils/multipart-form'
+import {
+ parseTagNamesParam,
+ resolveUploadTagIds
+} from '../../utils/tags'
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
@@ -28,6 +33,15 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken)
+ const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
+ const uploadTagNames = parseTagNamesParam(
+ tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
+ )
+ const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames)
+ if ('message' in resolvedTags) {
+ createApiError(event, 'INVALID_REQUEST', resolvedTags.message, 400)
+ }
+ const uploadTagIds = resolvedTags.tagIds
const fileParts = formData.filter(
part => (part.name === 'file' || part.name === 'files') && part.data?.length
@@ -54,7 +68,8 @@ export default defineEventHandler(async (event) => {
filename: part.filename,
contentType: part.type,
source,
- userId: uploadUserId
+ userId: uploadUserId,
+ tagIds: uploadTagIds
})
if ('error' in result) {
diff --git a/server/api/videos/upload.post.ts b/server/api/videos/upload.post.ts
index ee9ce43..b26f826 100644
--- a/server/api/videos/upload.post.ts
+++ b/server/api/videos/upload.post.ts
@@ -17,6 +17,11 @@ import {
parseTranscodePreset
} from '../../utils/transcode'
import { getFileBedAccessMode } from '../../utils/env'
+import { readMultipartFormFieldValues } from '../../utils/multipart-form'
+import {
+ parseTagNamesParam,
+ resolveUploadTagIds
+} from '../../utils/tags'
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
@@ -34,6 +39,15 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken)
+ const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
+ const uploadTagNames = parseTagNamesParam(
+ tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
+ )
+ const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames)
+ if ('message' in resolvedTags) {
+ createApiError(event, 'INVALID_REQUEST', resolvedTags.message, 400)
+ }
+ const uploadTagIds = resolvedTags.tagIds
const transcodeField = formData.find(part => part.name === 'transcode')
const wantTranscode = transcodeField?.data?.length
@@ -98,7 +112,8 @@ export default defineEventHandler(async (event) => {
filename: part.filename,
contentType: part.type,
source,
- userId: uploadUserId
+ userId: uploadUserId,
+ tagIds: uploadTagIds
})
if ('error' in result) {
diff --git a/server/utils/file-bed.ts b/server/utils/file-bed.ts
index 0b7b591..f3280b8 100644
--- a/server/utils/file-bed.ts
+++ b/server/utils/file-bed.ts
@@ -25,6 +25,10 @@ import {
isForbiddenFileExtension,
isVideoExtensionAllowed
} from './upload-policy'
+import {
+ attachTagsAfterUpload,
+ getTagsForImageKeys
+} from './tags'
export { isForbiddenFileExtension }
@@ -107,6 +111,7 @@ export async function processSingleFileUpload(
contentType?: string
source?: LogSource
userId?: number | null
+ tagIds?: number[]
}
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
const originalName = sanitizeDownloadName(input.filename ?? 'file')
@@ -157,7 +162,8 @@ export async function processSingleFileUpload(
contentType: normalizeFileContentType(input.contentType, extension),
source: input.source,
userId: input.userId,
- mediaType: 'file'
+ mediaType: 'file',
+ tagIds: input.tagIds
})
}
@@ -173,6 +179,7 @@ async function persistMediaUpload(
source?: LogSource
userId?: number | null
mediaType: MediaType
+ tagIds?: number[]
}
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
let backendId: string
@@ -222,16 +229,22 @@ async function persistMediaUpload(
source: input.source ?? 'web'
})
+ if (input.tagIds?.length && input.userId != null) {
+ attachTagsAfterUpload(input.key, input.tagIds, input.userId)
+ }
+
+ const item = buildFileItem({
+ event,
+ key: input.key,
+ backendId,
+ originalName: input.originalName,
+ contentType: input.contentType,
+ size: input.bytes.byteLength,
+ uploadedAt: input.uploadedAt
+ })
+ const tags = getTagsForImageKeys([input.key]).get(input.key) ?? []
return {
- item: buildFileItem({
- event,
- key: input.key,
- backendId,
- originalName: input.originalName,
- contentType: input.contentType,
- size: input.bytes.byteLength,
- uploadedAt: input.uploadedAt
- })
+ item: tags.length ? { ...item, tags } : item
}
}
@@ -243,6 +256,7 @@ export async function processSingleVideoUpload(
contentType?: string
source?: LogSource
userId?: number | null
+ tagIds?: number[]
}
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
const originalName = sanitizeDownloadName(input.filename ?? 'video')
@@ -293,7 +307,8 @@ export async function processSingleVideoUpload(
contentType: normalizeVideoContentType(input.contentType, extension),
source: input.source,
userId: input.userId,
- mediaType: 'video'
+ mediaType: 'video',
+ tagIds: input.tagIds
})
// 生成封面缩略图;失败不影响上传结果