From 832261739f3b4f5e83339bb01e4b5468aa89c084 Mon Sep 17 00:00:00 2001 From: tianmo Date: Sat, 17 Oct 2026 18:22:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/UploadPreferencesPanel.vue | 18 ++++++++++- app/composables/useApiDocs.ts | 32 +++++++++++++++++--- app/composables/useMediaUpload.ts | 11 +++++++ i18n/locales/en.json | 4 ++- i18n/locales/zh-CN.json | 4 ++- server/api/files/upload.post.ts | 17 ++++++++++- server/api/videos/upload.post.ts | 17 ++++++++++- server/utils/file-bed.ts | 37 ++++++++++++++++------- 8 files changed, 120 insertions(+), 20 deletions(-) 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 }) // 生成封面缩略图;失败不影响上传结果