同步设置

This commit is contained in:
tianmo committed 2026-10-17 19:01:13 +08:00
1 parent 832261739f
commit 92aaf6fbb8
28 files changed
+705 -227

No files matched your search

+24 -1
View File
@@ -7,22 +7,44 @@
.upload-card-flip-inner {
position: relative;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
transition:
transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
min-height 0.35s ease;
transform-style: preserve-3d;
}
.upload-card-flip.is-flipped .upload-card-flip-inner {
min-height: min(40rem, calc(100vh - 11rem));
transform: rotateY(180deg);
}
.upload-card-flip--file.is-flipped .upload-card-flip-inner {
min-height: min(27rem, calc(100vh - 11rem));
}
.upload-card-flip.is-flipped .upload-card-face--front {
visibility: hidden;
pointer-events: none;
}
.upload-card-flip.is-flipped .upload-settings-btn {
display: none;
}
.upload-card-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.upload-card-face--front {
position: relative;
z-index: 1;
}
.upload-card-face--back {
position: absolute;
inset: 0;
z-index: 2;
transform: rotateY(180deg);
}
@@ -33,6 +55,7 @@
.upload-card-flip.is-flipped .upload-card-flip-inner {
transform: none;
min-height: 0;
}
.upload-card-flip .upload-card-face--back {
+1 -10
View File
@@ -74,16 +74,7 @@ function fileIcon(item: FileItem): string {
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatSize(item.size) }}
</p>
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
+5 -14
View File
@@ -633,20 +633,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="text-muted"
>{{ t('tags.unlabeled') }}</span>
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
+8 -4
View File
@@ -32,24 +32,28 @@ onMounted(() => {
const text = computed(() => {
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
return props.kind === 'video'
? {
uploadTitle: t('library.video.uploadTitle'),
uploadHint: t('library.video.uploadHint'),
uploadFormats: allowedVideos.length
? t('library.video.uploadFormatsAllowed', {
formats: allowedVideos.map(item => item.toUpperCase()).join(' / ')
formats: allowedVideos.map(item => item.toUpperCase()).join(' / '),
size: videoMaxFileSizeMb
})
: t('library.video.uploadFormats')
: t('library.video.uploadFormats', { size: videoMaxFileSizeMb })
}
: {
uploadTitle: t('library.file.uploadTitle'),
uploadHint: t('library.file.uploadHint'),
uploadFormats: forbiddenFiles.length
? t('library.file.uploadFormatsBlocked', {
formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / ')
formats: forbiddenFiles.map(item => item.toUpperCase()).join(' / '),
size: fileMaxFileSizeMb
})
: t('library.file.uploadFormatsUnrestricted')
: t('library.file.uploadFormatsUnrestricted', { size: fileMaxFileSizeMb })
}
})
+1 -1
View File
@@ -17,7 +17,7 @@ const emit = defineEmits<{
'created': [tag: ImageTag]
}>()
const COMMON_LIMIT = 12
const COMMON_LIMIT = 10
const { t } = useI18n()
const toast = useToast()
const { createTag } = useTags()
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
const props = withDefaults(defineProps<{
tags?: ImageTag[]
max?: number
compact?: boolean
emptyLabel?: string
}>(), {
tags: () => [],
max: 3,
compact: false,
emptyLabel: ''
})
const emit = defineEmits<{
'tag-click': [tagId: number]
}>()
const { t } = useI18n()
const visibleTags = computed(() => props.tags.slice(0, props.max))
const hiddenTags = computed(() => props.tags.slice(props.max))
</script>
<template>
<div
v-if="tags.length"
class="flex flex-wrap items-center"
:class="compact ? 'gap-1' : 'gap-1.5'"
>
<TagBadge
v-for="tag in visibleTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
<UPopover
v-if="hiddenTags.length"
:content="{ side: 'bottom', align: 'start' }"
>
<button
type="button"
class="inline-flex rounded-md border border-default text-muted transition-colors hover:border-primary/40 hover:text-primary"
:class="compact ? 'px-1.5 py-0.5 text-[11px]' : 'px-2 py-0.5 text-xs'"
:aria-label="t('tags.moreTags', { n: hiddenTags.length })"
>
+{{ hiddenTags.length }}
</button>
<template #content>
<div
class="flex max-w-56 flex-wrap gap-1.5 p-2"
>
<TagBadge
v-for="tag in hiddenTags"
:key="tag.id"
:tag="tag"
@click="emit('tag-click', tag.id)"
/>
</div>
</template>
</UPopover>
</div>
<span
v-else-if="emptyLabel"
class="text-muted"
>
{{ emptyLabel }}
</span>
</template>
+1 -1
View File
@@ -43,7 +43,7 @@ function onBack() {
<template>
<div
class="upload-card-flip"
:class="{ 'is-flipped': flipped }"
:class="[`upload-card-flip--${kind}`, { 'is-flipped': flipped }]"
>
<div class="upload-card-flip-inner">
<div class="upload-card-face upload-card-face--front">
+116 -26
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
import {
normalizeUploadTagNames,
type UploadMediaKind
} from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
/** 每个上传页各自一份偏好 */
@@ -17,20 +20,36 @@ const {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
lastTagIds,
uploadTagNames,
transcodeToHls,
loadPreferences
} = useUploadPreferences(props.kind)
const { items: tagItems, fetchTags } = useTags()
const uploadTagDraft = ref('')
/** 视频床 / 文件床没有图片压缩相关选项 */
const isImageKind = computed(() => props.kind === 'image')
const isVideoKind = computed(() => props.kind === 'video')
const gridClass = computed(() => {
if (isImageKind.value) return 'sm:grid-cols-3 sm:gap-6'
if (isVideoKind.value) return 'sm:grid-cols-2 sm:gap-6'
return 'mx-auto w-full max-w-md'
const processingGridClass = computed(() => {
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
return ''
})
const qualityGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const convenienceGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-1'
if (isVideoKind.value) return 'md:col-start-1 md:row-start-2'
return ''
})
const tagsGridClass = computed(() => {
if (isImageKind.value) return 'md:col-start-2 md:row-start-2'
if (isVideoKind.value) return 'md:col-start-2 md:row-start-1 md:row-span-2'
return ''
})
const tagsLayoutClass = computed(() => [
tagsGridClass.value
])
const { t } = useI18n()
const {
@@ -74,11 +93,39 @@ function onClientQualityInput(event: Event) {
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
function commitUploadTags() {
const names = normalizeUploadTagNames(uploadTagDraft.value)
if (!names.length) return
uploadTagNames.value = normalizeUploadTagNames([
...uploadTagNames.value,
...names
])
uploadTagDraft.value = ''
}
function onUploadTagKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault()
commitUploadTags()
return
}
if (
event.key === 'Backspace'
&& !uploadTagDraft.value
&& uploadTagNames.value.length
) {
uploadTagNames.value = uploadTagNames.value.slice(0, -1)
}
}
function removeUploadTag(name: string) {
uploadTagNames.value = uploadTagNames.value.filter(item => item !== name)
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
void loadTranscodeSettings()
void fetchTags()
})
</script>
@@ -113,12 +160,12 @@ onMounted(() => {
<div class="flex flex-1 flex-col justify-start overflow-y-auto p-5 sm:p-6">
<div
class="grid gap-5"
:class="gridClass"
class="grid gap-5 md:grid-cols-2 md:gap-6"
>
<div
v-if="isImageKind"
class="space-y-4"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.processing') }}
@@ -179,7 +226,8 @@ onMounted(() => {
<div
v-if="isVideoKind"
class="space-y-4"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="processingGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.videoProcessing') }}
@@ -217,7 +265,8 @@ onMounted(() => {
<div
v-if="isImageKind"
class="space-y-4"
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="qualityGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.webpQuality') }}
@@ -239,7 +288,10 @@ onMounted(() => {
>
</div>
<div class="space-y-4">
<div
class="space-y-4 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="convenienceGridClass"
>
<h3 class="text-sm font-semibold">
{{ t('preferences.convenience') }}
</h3>
@@ -271,19 +323,57 @@ onMounted(() => {
/>
</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
class="space-y-3 rounded-xl border border-default/70 bg-muted/10 p-4"
:class="tagsLayoutClass"
>
<div class="space-y-3">
<h3 class="text-sm font-semibold">
{{ t('preferences.uploadTags') }}
</h3>
<p class="text-xs leading-relaxed text-muted">
{{ t('preferences.uploadTagsHint') }}
</p>
</div>
<UInput
v-model="uploadTagDraft"
size="md"
icon="i-lucide-tags"
maxlength="32"
:placeholder="t('preferences.uploadTagsPlaceholder')"
@keydown="onUploadTagKeydown"
@blur="commitUploadTags"
/>
<div
v-if="uploadTagNames.length"
class="space-y-2 pt-1"
>
<p class="text-xs font-medium text-muted">
{{ t('preferences.uploadTagsPending', { n: uploadTagNames.length }) }}
</p>
<div class="flex flex-wrap gap-2.5">
<span
v-for="name in uploadTagNames"
:key="name"
class="relative inline-flex max-w-full items-center rounded-full border border-primary/35 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"
>
<span class="truncate">{{ name }}</span>
<button
type="button"
class="absolute -top-1.5 -right-1.5 inline-flex size-4 items-center justify-center rounded-full border border-primary/40 bg-elevated text-primary shadow-sm transition-colors hover:bg-primary hover:text-inverted"
:aria-label="t('preferences.uploadTagsRemove', { name })"
@click="removeUploadTag(name)"
>
<UIcon
name="i-lucide-x"
class="size-2.5"
/>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
+1 -10
View File
@@ -109,16 +109,7 @@ const uploadedLabel = computed(() => {
<p class="truncate text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
</p>
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<TagBadgeList :tags="item.tags" />
</div>
<div class="flex items-center justify-around border-t border-default px-2 py-2">
+5 -14
View File
@@ -659,20 +659,11 @@ defineExpose({ refresh })
{{ uploadedLabel(item) }}
</td>
<td class="hidden px-3 py-2 md:table-cell">
<div
v-if="item.tags?.length"
class="flex flex-wrap gap-1"
>
<TagBadge
v-for="tag in item.tags"
:key="tag.id"
:tag="tag"
/>
</div>
<span
v-else
class="text-muted"
>{{ t('tags.unlabeled') }}</span>
<TagBadgeList
:tags="item.tags"
compact
:empty-label="t('tags.unlabeled')"
/>
</td>
<td class="px-3 py-2">
<div class="flex items-center justify-end gap-1">
@@ -5,7 +5,9 @@ type VideoExtension = 'mp4' | 'm4v' | 'webm' | 'mov' | 'mkv' | 'avi' | 'flv' | '
const props = defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadMaxFileSizeMbSource?: PolicySource
uploadImageMaxFileSizeMbSource?: PolicySource
uploadVideoMaxFileSizeMbSource?: PolicySource
uploadFileMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
allowedVideoExtensionsSource?: PolicySource
forbiddenFileExtensionsSource?: PolicySource
@@ -20,7 +22,9 @@ const props = defineProps<{
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
}>()
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
const uploadImageMaxFileSizeMbDraft = defineModel<number>('uploadImageMaxFileSizeMb')
const uploadVideoMaxFileSizeMbDraft = defineModel<number>('uploadVideoMaxFileSizeMb')
const uploadFileMaxFileSizeMbDraft = defineModel<number>('uploadFileMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
@@ -125,67 +129,109 @@ function addCustomForbiddenExtension() {
customForbiddenExtension.value = ''
}
function clampUploadMaxFileSizeMb(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
return Math.min(100, Math.max(1, Math.round(num)))
type UploadSizeKind = 'image' | 'video' | 'file'
const uploadSizeFields = computed(() => [
{
kind: 'image' as const,
title: t('settings.uploadImageMaxFileSize'),
hint: t('settings.uploadImageMaxFileSizeHint'),
source: props.uploadImageMaxFileSizeMbSource,
value: uploadImageMaxFileSizeMbDraft.value ?? 10,
max: 100
},
{
kind: 'video' as const,
title: t('settings.uploadVideoMaxFileSize'),
hint: t('settings.uploadVideoMaxFileSizeHint'),
source: props.uploadVideoMaxFileSizeMbSource,
value: uploadVideoMaxFileSizeMbDraft.value ?? 500,
max: 2048
},
{
kind: 'file' as const,
title: t('settings.uploadFileMaxFileSize'),
hint: t('settings.uploadFileMaxFileSizeHint'),
source: props.uploadFileMaxFileSizeMbSource,
value: uploadFileMaxFileSizeMbDraft.value ?? 100,
max: 1024
}
])
function uploadSizeModel(kind: UploadSizeKind) {
if (kind === 'video') return uploadVideoMaxFileSizeMbDraft
if (kind === 'file') return uploadFileMaxFileSizeMbDraft
return uploadImageMaxFileSizeMbDraft
}
function onUploadMaxFileSizeInput(event: Event) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
const num = Number(value)
const model = uploadSizeModel(kind)
const max = kind === 'video' ? 2048 : kind === 'file' ? 1024 : 100
if (!Number.isFinite(num)) return model.value ?? (kind === 'video' ? 500 : kind === 'file' ? 100 : 10)
return Math.min(max, Math.max(0, Math.round(num)))
}
function onUploadMaxFileSizeInput(kind: UploadSizeKind, event: Event) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind,
(event.target as HTMLInputElement).value
)
}
function onUploadMaxFileSizeNumberInput(value: string | number) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
}
</script>
<template>
<template v-if="part === 'upload-preference'">
<div class="py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadMaxFileSize') }}
</p>
<UBadge
v-if="uploadMaxFileSizeMbSource"
:color="sourceBadge(uploadMaxFileSizeMbSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadMaxFileSizeMbSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadMaxFileSizeHint') }}
</p>
<div class="space-y-5 py-4">
<div
class="mt-3 flex items-center gap-3"
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
v-for="field in uploadSizeFields"
:key="field.kind"
>
<input
type="range"
min="1"
max="100"
:value="uploadMaxFileSizeMbDraft"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@input="onUploadMaxFileSizeInput"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="uploadMaxFileSizeMbDraft"
type="number"
:min="1"
:max="100"
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ field.title }}
</p>
<UBadge
v-if="field.source"
:color="sourceBadge(field.source).color"
variant="subtle"
size="xs"
class="w-14 tabular-nums"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput"
/>
<span class="text-xs text-muted">MB</span>
>
{{ sourceBadge(field.source).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ field.hint }}
</p>
<div
class="mt-3 flex items-center gap-3"
:class="field.source === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<input
type="range"
min="0"
:max="field.max"
:value="field.value"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="field.source === 'env'"
@input="onUploadMaxFileSizeInput(field.kind, $event)"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="field.value"
type="number"
:min="0"
:max="field.max"
size="xs"
class="w-16 tabular-nums"
:disabled="field.source === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput(field.kind, $event)"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div>
</div>
</div>
+25 -13
View File
@@ -4,21 +4,22 @@ import type {
} from '~/types/image'
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
const { items: tagItems } = useTags()
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
const { load: loadUploadPolicy } = useUploadPolicy()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
options?: { notify?: boolean, tagIds?: number[] }
options?: { notify?: boolean }
) {
if (uploading.value) return
@@ -43,10 +44,27 @@ export function useImageUpload() {
}))
try {
const prepared = await prepareFilesForUpload(files, {
const preparedFiles = await prepareFilesForUpload(files, {
enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100
})
const currentPolicy = await loadUploadPolicy().catch(() => null)
const maxFileSizeMb = currentPolicy?.imageMaxFileSizeMb ?? 10
const prepared = preparedFiles.filter((file) => {
if (file.size <= maxFileSizeMb * 1024 * 1024) return true
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
})
if (!prepared.length) {
progressItems.value = []
return
}
progressItems.value = prepared.map((file, index) => ({
name: file.name,
@@ -59,15 +77,9 @@ export function useImageUpload() {
formData.append('files', file, file.name)
}
const tagIds = options?.tagIds ?? lastTagIds.value
if (tagIds.length) {
const names = tagIds
.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))
}
lastTagIds.value = tagIds
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
+18 -10
View File
@@ -2,6 +2,7 @@ import type { UploadProgressItem } from '~/types/image'
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences'
const MAX_FILES = 10
@@ -54,10 +55,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
autoCopyMarkdown,
copyFormat,
transcodeToHls,
lastTagIds,
uploadTagNames,
loadPreferences
} = useUploadPreferences(kind)
const { items: tagItems, fetchTags } = useTags()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
@@ -209,6 +209,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
})
return false
}
const maxFileSizeMb = kind === 'video'
? (currentPolicy?.videoMaxFileSizeMb ?? 500)
: (currentPolicy?.fileMaxFileSizeMb ?? 100)
if (file.size > maxFileSizeMb * 1024 * 1024) {
toast.add({
title: t('files.fileTooLarge', {
name: file.name,
limit: maxFileSizeMb
}),
color: 'error'
})
return false
}
return true
})
if (!acceptedFiles.length) return
@@ -234,14 +247,9 @@ 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))
}
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
if (tagNames) {
formData.append('tagNames', tagNames)
}
progressItems.value = progressItems.value.map(item => ({
+7 -1
View File
@@ -2,6 +2,9 @@ interface UploadPolicy {
allowedMimeTypes: string[]
allowedVideoExtensions: string[]
forbiddenFileExtensions: string[]
imageMaxFileSizeMb: number
videoMaxFileSizeMb: number
fileMaxFileSizeMb: number
}
const policy = ref<UploadPolicy | null>(null)
@@ -21,7 +24,10 @@ export function useUploadPolicy() {
policy.value = {
allowedMimeTypes: [...data.allowedMimeTypes],
allowedVideoExtensions: [...data.allowedVideoExtensions],
forbiddenFileExtensions: [...data.forbiddenFileExtensions]
forbiddenFileExtensions: [...data.forbiddenFileExtensions],
imageMaxFileSizeMb: data.imageMaxFileSizeMb,
videoMaxFileSizeMb: data.videoMaxFileSizeMb,
fileMaxFileSizeMb: data.fileMaxFileSizeMb
}
return policy.value
})
+27 -10
View File
@@ -13,7 +13,7 @@ export interface UploadPreferences {
autoCopyMarkdown: boolean
copyFormat: CopyFormat
clientWebpQuality: number
lastTagIds: number[]
uploadTagNames: string[]
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
transcodeToHls: boolean
}
@@ -23,10 +23,21 @@ const DEFAULTS: UploadPreferences = {
autoCopyMarkdown: false,
copyFormat: 'markdown',
clientWebpQuality: 80,
lastTagIds: [],
uploadTagNames: [],
transcodeToHls: false
}
export function normalizeUploadTagNames(value: string | readonly string[]): string[] {
const parts = typeof value === 'string'
? value.split(/[,,\n]/)
: value.flatMap(item => String(item).split(/[,,\n]/))
return [...new Set(
parts
.map(name => name.trim())
.filter(name => Boolean(name) && name.length <= 32)
)]
}
function clampQuality(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
@@ -49,9 +60,8 @@ function readStored(kind: UploadMediaKind): UploadPreferences {
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
lastTagIds: Array.isArray(parsed.lastTagIds)
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
: DEFAULTS.lastTagIds,
/** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
uploadTagNames: [...DEFAULTS.uploadTagNames],
transcodeToHls: parsed.transcodeToHls === true
}
} catch {
@@ -69,17 +79,23 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
const lastTagIds = useState<number[]>(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds)
const uploadTagNames = useState<string[]>(
`upload-pref-tag-names-${kind}`,
() => [...DEFAULTS.uploadTagNames]
)
const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
function loadPreferences() {
const firstLoad = !loaded.value
const stored = readStored(kind)
compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality
lastTagIds.value = stored.lastTagIds
if (firstLoad) {
uploadTagNames.value = [...stored.uploadTagNames]
}
transcodeToHls.value = stored.transcodeToHls
loaded.value = true
}
@@ -94,7 +110,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value),
lastTagIds: lastTagIds.value,
uploadTagNames: [...DEFAULTS.uploadTagNames],
transcodeToHls: transcodeToHls.value
})
}
@@ -104,12 +120,13 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
copyFormat.value = DEFAULTS.copyFormat
clientWebpQuality.value = DEFAULTS.clientWebpQuality
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
transcodeToHls.value = DEFAULTS.transcodeToHls
savePreferences()
}
watch(
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls],
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
() => {
if (loaded.value) {
savePreferences()
@@ -123,7 +140,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
lastTagIds,
uploadTagNames,
transcodeToHls,
loaded,
loadPreferences,
+6
View File
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
import { buildImageBbcode } from '~/utils/image-display'
const { uploading, progressItems, uploadFiles } = useImageUpload()
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
const {
autoCopyMarkdown,
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const toast = useToast()
const { t } = useI18n()
const imageFormats = computed(() =>
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
)
const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>())
@@ -39,6 +43,7 @@ const showProgress = computed(
async function loadPage() {
try {
loadPreferences()
await loadUploadPolicy().catch(() => {})
} catch (error: unknown) {
handleAuthError(error)
}
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
<AppShell v-else>
<UploadFlipCard
:disabled="uploading"
:formats="imageFormats"
@upload="handleUpload"
/>
+24 -8
View File
@@ -55,8 +55,12 @@ interface SettingsResponse {
capApiEndpoint: string
capSecret: string
appVersion: string
uploadMaxFileSizeMb: number
uploadMaxFileSizeMbSource: PolicySource
uploadImageMaxFileSizeMb: number
uploadImageMaxFileSizeMbSource: PolicySource
uploadVideoMaxFileSizeMb: number
uploadVideoMaxFileSizeMbSource: PolicySource
uploadFileMaxFileSizeMb: number
uploadFileMaxFileSizeMbSource: PolicySource
allowedMimeTypes: MimeType[]
allowedMimeTypesSource: PolicySource
allowedVideoExtensions: VideoExtension[]
@@ -185,7 +189,9 @@ const capApiEndpointDraft = ref('')
const capSecretDraft = ref('')
const domainSeparationSaveConfirmOpen = ref(false)
const uploadMaxFileSizeMbDraft = ref(10)
const uploadImageMaxFileSizeMbDraft = ref(10)
const uploadVideoMaxFileSizeMbDraft = ref(500)
const uploadFileMaxFileSizeMbDraft = ref(100)
const allowedMimeTypesDraft = ref<MimeType[]>([])
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
const forbiddenFileExtensionsDraft = ref<string[]>([])
@@ -242,7 +248,9 @@ const hasServerChanges = computed(() => {
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|| capSecretDraft.value !== settings.value.capSecret
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|| uploadImageMaxFileSizeMbDraft.value !== settings.value.uploadImageMaxFileSizeMb
|| uploadVideoMaxFileSizeMbDraft.value !== settings.value.uploadVideoMaxFileSizeMb
|| uploadFileMaxFileSizeMbDraft.value !== settings.value.uploadFileMaxFileSizeMb
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
@@ -320,7 +328,9 @@ function applySettings(data: SettingsResponse) {
turnstileSecretKeyDraft.value = data.turnstileSecretKey
capApiEndpointDraft.value = data.capApiEndpoint
capSecretDraft.value = data.capSecret
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
@@ -423,7 +433,9 @@ async function performSaveServerSettings() {
turnstileSecretKey: turnstileSecretKeyDraft.value,
capApiEndpoint: capApiEndpointDraft.value,
capSecret: capSecretDraft.value,
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
allowedMimeTypes: allowedMimeTypesDraft.value,
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
@@ -626,7 +638,9 @@ watch(isAdmin, () => {
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
v-model:upload-image-max-file-size-mb="uploadImageMaxFileSizeMbDraft"
v-model:upload-video-max-file-size-mb="uploadVideoMaxFileSizeMbDraft"
v-model:upload-file-max-file-size-mb="uploadFileMaxFileSizeMbDraft"
v-model:allowed-mime-types="allowedMimeTypesDraft"
v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
@@ -634,7 +648,9 @@ watch(isAdmin, () => {
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
part="upload-preference"
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
:upload-image-max-file-size-mb-source="settings.uploadImageMaxFileSizeMbSource"
:upload-video-max-file-size-mb-source="settings.uploadVideoMaxFileSizeMbSource"
:upload-file-max-file-size-mb-source="settings.uploadFileMaxFileSizeMbSource"
:allowed-mime-types-source="settings.allowedMimeTypesSource"
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"