上传标签
This commit is contained in:
1 parent
6494ad2918
commit
832261739f
8 files changed
+120
-20
No files matched your search
@@ -17,9 +17,11 @@ const {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
|
lastTagIds,
|
||||||
transcodeToHls,
|
transcodeToHls,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences(props.kind)
|
} = useUploadPreferences(props.kind)
|
||||||
|
const { items: tagItems, fetchTags } = useTags()
|
||||||
|
|
||||||
/** 视频床 / 文件床没有图片压缩相关选项 */
|
/** 视频床 / 文件床没有图片压缩相关选项 */
|
||||||
const isImageKind = computed(() => props.kind === 'image')
|
const isImageKind = computed(() => props.kind === 'image')
|
||||||
@@ -76,6 +78,7 @@ onMounted(() => {
|
|||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
void loadTranscodeSettings()
|
void loadTranscodeSettings()
|
||||||
|
void fetchTags()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
|
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
|
||||||
<div
|
<div
|
||||||
class="grid gap-5"
|
class="grid gap-5"
|
||||||
:class="gridClass"
|
:class="gridClass"
|
||||||
@@ -269,6 +272,19 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-3 border-t border-default pt-5">
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
{{ t('preferences.uploadTags') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
|
{{ t('preferences.uploadTagsHint') }}
|
||||||
|
</p>
|
||||||
|
<TagAssignPanel
|
||||||
|
v-model="lastTagIds"
|
||||||
|
:tags="tagItems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -310,6 +310,13 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
location: 'form-data',
|
location: 'form-data',
|
||||||
required: false,
|
required: false,
|
||||||
descriptionKey: 'api.params.videoUpload.token'
|
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" \\
|
curl: `curl -X POST "${baseUrl}/api/videos/upload" \\
|
||||||
@@ -317,7 +324,8 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
-F "video=@./demo.mp4" \\
|
-F "video=@./demo.mp4" \\
|
||||||
-F "transcode=1" \\
|
-F "transcode=1" \\
|
||||||
-F "transcodePreset=compress" \\
|
-F "transcodePreset=compress" \\
|
||||||
-F "transcodeBitratePercent=80"`,
|
-F "transcodeBitratePercent=80" \\
|
||||||
|
-F 'tagNames=["博客","视频"]'`,
|
||||||
responseExample: `{
|
responseExample: `{
|
||||||
"success": true,
|
"success": true,
|
||||||
"items": [
|
"items": [
|
||||||
@@ -329,7 +337,11 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
"size": 10485760,
|
"size": 10485760,
|
||||||
"uploadedAt": "2026-10-17T03:00:00.000Z",
|
"uploadedAt": "2026-10-17T03:00:00.000Z",
|
||||||
"markdown": "[demo.mp4](https://pic.example.com/videos/2026/10/abc123.mp4)",
|
"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": [],
|
"errors": [],
|
||||||
@@ -619,11 +631,19 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
location: 'form-data',
|
location: 'form-data',
|
||||||
required: false,
|
required: false,
|
||||||
descriptionKey: 'api.params.fileUpload.token'
|
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" \\
|
curl: `curl -X POST "${baseUrl}/api/files/upload" \\
|
||||||
${auth} \\
|
${auth} \\
|
||||||
-F "file=@./report.pdf"`,
|
-F "file=@./report.pdf" \\
|
||||||
|
-F 'tagNames=["文档","归档"]'`,
|
||||||
responseExample: `{
|
responseExample: `{
|
||||||
"success": true,
|
"success": true,
|
||||||
"items": [
|
"items": [
|
||||||
@@ -634,7 +654,11 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
|
|||||||
"contentType": "application/pdf",
|
"contentType": "application/pdf",
|
||||||
"size": 5242880,
|
"size": 5242880,
|
||||||
"uploadedAt": "2026-10-17T03:00:00.000Z",
|
"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": [],
|
"errors": [],
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
transcodeToHls,
|
transcodeToHls,
|
||||||
|
lastTagIds,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences(kind)
|
} = useUploadPreferences(kind)
|
||||||
|
const { items: tagItems, fetchTags } = useTags()
|
||||||
|
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const progressItems = ref<UploadProgressItem[]>([])
|
const progressItems = ref<UploadProgressItem[]>([])
|
||||||
@@ -232,6 +234,15 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
if (kind === 'video' && transcodeToHls.value) {
|
if (kind === 'video' && transcodeToHls.value) {
|
||||||
formData.append('transcode', '1')
|
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 => ({
|
progressItems.value = progressItems.value.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -327,7 +327,9 @@
|
|||||||
"transcodeLoadFailed": "Failed to load transcode settings",
|
"transcodeLoadFailed": "Failed to load transcode settings",
|
||||||
"transcodeSaveFailed": "Failed to save transcode settings",
|
"transcodeSaveFailed": "Failed to save transcode settings",
|
||||||
"autoCopy": "Copy link after upload",
|
"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": {
|
"settings": {
|
||||||
"title": "System settings",
|
"title": "System settings",
|
||||||
|
|||||||
@@ -327,7 +327,9 @@
|
|||||||
"transcodeLoadFailed": "读取转码设置失败",
|
"transcodeLoadFailed": "读取转码设置失败",
|
||||||
"transcodeSaveFailed": "保存转码设置失败",
|
"transcodeSaveFailed": "保存转码设置失败",
|
||||||
"autoCopy": "上传成功自动复制链接",
|
"autoCopy": "上传成功自动复制链接",
|
||||||
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。"
|
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
|
||||||
|
"uploadTags": "上传标签",
|
||||||
|
"uploadTagsHint": "图片、视频和文件共用当前上传页选择的标签;不存在的标签会自动创建。"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { createApiError } from '../../utils/api-error'
|
|||||||
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
||||||
import { processSingleFileUpload } from '../../utils/file-bed'
|
import { processSingleFileUpload } from '../../utils/file-bed'
|
||||||
import { getFileBedAccessMode } from '../../utils/env'
|
import { getFileBedAccessMode } from '../../utils/env'
|
||||||
|
import { readMultipartFormFieldValues } from '../../utils/multipart-form'
|
||||||
|
import {
|
||||||
|
parseTagNamesParam,
|
||||||
|
resolveUploadTagIds
|
||||||
|
} from '../../utils/tags'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireUploadAuth(event)
|
await requireUploadAuth(event)
|
||||||
@@ -28,6 +33,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
const uploadUserId = await getUploadUserId(event, formToken)
|
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(
|
const fileParts = formData.filter(
|
||||||
part => (part.name === 'file' || part.name === 'files') && part.data?.length
|
part => (part.name === 'file' || part.name === 'files') && part.data?.length
|
||||||
@@ -54,7 +68,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
filename: part.filename,
|
filename: part.filename,
|
||||||
contentType: part.type,
|
contentType: part.type,
|
||||||
source,
|
source,
|
||||||
userId: uploadUserId
|
userId: uploadUserId,
|
||||||
|
tagIds: uploadTagIds
|
||||||
})
|
})
|
||||||
|
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
parseTranscodePreset
|
parseTranscodePreset
|
||||||
} from '../../utils/transcode'
|
} from '../../utils/transcode'
|
||||||
import { getFileBedAccessMode } from '../../utils/env'
|
import { getFileBedAccessMode } from '../../utils/env'
|
||||||
|
import { readMultipartFormFieldValues } from '../../utils/multipart-form'
|
||||||
|
import {
|
||||||
|
parseTagNamesParam,
|
||||||
|
resolveUploadTagIds
|
||||||
|
} from '../../utils/tags'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireUploadAuth(event)
|
await requireUploadAuth(event)
|
||||||
@@ -34,6 +39,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
const uploadUserId = await getUploadUserId(event, formToken)
|
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 transcodeField = formData.find(part => part.name === 'transcode')
|
||||||
const wantTranscode = transcodeField?.data?.length
|
const wantTranscode = transcodeField?.data?.length
|
||||||
@@ -98,7 +112,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
filename: part.filename,
|
filename: part.filename,
|
||||||
contentType: part.type,
|
contentType: part.type,
|
||||||
source,
|
source,
|
||||||
userId: uploadUserId
|
userId: uploadUserId,
|
||||||
|
tagIds: uploadTagIds
|
||||||
})
|
})
|
||||||
|
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
|
|||||||
+26
-11
@@ -25,6 +25,10 @@ import {
|
|||||||
isForbiddenFileExtension,
|
isForbiddenFileExtension,
|
||||||
isVideoExtensionAllowed
|
isVideoExtensionAllowed
|
||||||
} from './upload-policy'
|
} from './upload-policy'
|
||||||
|
import {
|
||||||
|
attachTagsAfterUpload,
|
||||||
|
getTagsForImageKeys
|
||||||
|
} from './tags'
|
||||||
|
|
||||||
export { isForbiddenFileExtension }
|
export { isForbiddenFileExtension }
|
||||||
|
|
||||||
@@ -107,6 +111,7 @@ export async function processSingleFileUpload(
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
source?: LogSource
|
source?: LogSource
|
||||||
userId?: number | null
|
userId?: number | null
|
||||||
|
tagIds?: number[]
|
||||||
}
|
}
|
||||||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
const originalName = sanitizeDownloadName(input.filename ?? 'file')
|
const originalName = sanitizeDownloadName(input.filename ?? 'file')
|
||||||
@@ -157,7 +162,8 @@ export async function processSingleFileUpload(
|
|||||||
contentType: normalizeFileContentType(input.contentType, extension),
|
contentType: normalizeFileContentType(input.contentType, extension),
|
||||||
source: input.source,
|
source: input.source,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
mediaType: 'file'
|
mediaType: 'file',
|
||||||
|
tagIds: input.tagIds
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +179,7 @@ async function persistMediaUpload(
|
|||||||
source?: LogSource
|
source?: LogSource
|
||||||
userId?: number | null
|
userId?: number | null
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
|
tagIds?: number[]
|
||||||
}
|
}
|
||||||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
let backendId: string
|
let backendId: string
|
||||||
@@ -222,16 +229,22 @@ async function persistMediaUpload(
|
|||||||
source: input.source ?? 'web'
|
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 {
|
return {
|
||||||
item: buildFileItem({
|
item: tags.length ? { ...item, tags } : item
|
||||||
event,
|
|
||||||
key: input.key,
|
|
||||||
backendId,
|
|
||||||
originalName: input.originalName,
|
|
||||||
contentType: input.contentType,
|
|
||||||
size: input.bytes.byteLength,
|
|
||||||
uploadedAt: input.uploadedAt
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +256,7 @@ export async function processSingleVideoUpload(
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
source?: LogSource
|
source?: LogSource
|
||||||
userId?: number | null
|
userId?: number | null
|
||||||
|
tagIds?: number[]
|
||||||
}
|
}
|
||||||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
const originalName = sanitizeDownloadName(input.filename ?? 'video')
|
const originalName = sanitizeDownloadName(input.filename ?? 'video')
|
||||||
@@ -293,7 +307,8 @@ export async function processSingleVideoUpload(
|
|||||||
contentType: normalizeVideoContentType(input.contentType, extension),
|
contentType: normalizeVideoContentType(input.contentType, extension),
|
||||||
source: input.source,
|
source: input.source,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
mediaType: 'video'
|
mediaType: 'video',
|
||||||
|
tagIds: input.tagIds
|
||||||
})
|
})
|
||||||
|
|
||||||
// 生成封面缩略图;失败不影响上传结果
|
// 生成封面缩略图;失败不影响上传结果
|
||||||
|
|||||||
Reference in new issue
Block a user