同步设置
This commit is contained in:
1 parent
832261739f
commit
92aaf6fbb8
28 files changed
+675
-197
No files matched your search
+3
-1
@@ -31,7 +31,9 @@
|
|||||||
# WEBP_QUALITY=80
|
# WEBP_QUALITY=80
|
||||||
|
|
||||||
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
|
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
|
||||||
# UPLOAD_MAX_FILE_SIZE_MB=10
|
# UPLOAD_IMAGE_MAX_FILE_SIZE_MB=10
|
||||||
|
# UPLOAD_VIDEO_MAX_FILE_SIZE_MB=500
|
||||||
|
# UPLOAD_FILE_MAX_FILE_SIZE_MB=100
|
||||||
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
|
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
|
||||||
# UPLOAD_RATE_IP_MAX=60
|
# UPLOAD_RATE_IP_MAX=60
|
||||||
# UPLOAD_RATE_TOKEN_MAX=120
|
# UPLOAD_RATE_TOKEN_MAX=120
|
||||||
|
|||||||
+24
-1
@@ -7,22 +7,44 @@
|
|||||||
|
|
||||||
.upload-card-flip-inner {
|
.upload-card-flip-inner {
|
||||||
position: relative;
|
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;
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||||
|
min-height: min(40rem, calc(100vh - 11rem));
|
||||||
transform: rotateY(180deg);
|
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 {
|
.upload-card-face {
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
-webkit-backface-visibility: hidden;
|
-webkit-backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-card-face--front {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-card-face--back {
|
.upload-card-face--back {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +55,7 @@
|
|||||||
|
|
||||||
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
.upload-card-flip.is-flipped .upload-card-flip-inner {
|
||||||
transform: none;
|
transform: none;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-card-flip .upload-card-face--back {
|
.upload-card-flip .upload-card-face--back {
|
||||||
|
|||||||
@@ -74,16 +74,7 @@ function fileIcon(item: FileItem): string {
|
|||||||
<p class="truncate text-xs text-muted">
|
<p class="truncate text-xs text-muted">
|
||||||
{{ uploadedLabel }} · {{ formatSize(item.size) }}
|
{{ uploadedLabel }} · {{ formatSize(item.size) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<TagBadgeList :tags="item.tags" />
|
||||||
v-if="item.tags?.length"
|
|
||||||
class="flex flex-wrap gap-1"
|
|
||||||
>
|
|
||||||
<TagBadge
|
|
||||||
v-for="tag in item.tags"
|
|
||||||
:key="tag.id"
|
|
||||||
:tag="tag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||||
|
|||||||
@@ -633,20 +633,11 @@ defineExpose({ refresh })
|
|||||||
{{ uploadedLabel(item) }}
|
{{ uploadedLabel(item) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-3 py-2 md:table-cell">
|
<td class="hidden px-3 py-2 md:table-cell">
|
||||||
<div
|
<TagBadgeList
|
||||||
v-if="item.tags?.length"
|
:tags="item.tags"
|
||||||
class="flex flex-wrap gap-1"
|
compact
|
||||||
>
|
:empty-label="t('tags.unlabeled')"
|
||||||
<TagBadge
|
|
||||||
v-for="tag in item.tags"
|
|
||||||
:key="tag.id"
|
|
||||||
:tag="tag"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-muted"
|
|
||||||
>{{ t('tags.unlabeled') }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
|||||||
@@ -32,24 +32,28 @@ onMounted(() => {
|
|||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
|
const allowedVideos = uploadPolicy.value?.allowedVideoExtensions ?? []
|
||||||
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
|
const forbiddenFiles = uploadPolicy.value?.forbiddenFileExtensions ?? []
|
||||||
|
const videoMaxFileSizeMb = uploadPolicy.value?.videoMaxFileSizeMb ?? 500
|
||||||
|
const fileMaxFileSizeMb = uploadPolicy.value?.fileMaxFileSizeMb ?? 100
|
||||||
return props.kind === 'video'
|
return props.kind === 'video'
|
||||||
? {
|
? {
|
||||||
uploadTitle: t('library.video.uploadTitle'),
|
uploadTitle: t('library.video.uploadTitle'),
|
||||||
uploadHint: t('library.video.uploadHint'),
|
uploadHint: t('library.video.uploadHint'),
|
||||||
uploadFormats: allowedVideos.length
|
uploadFormats: allowedVideos.length
|
||||||
? t('library.video.uploadFormatsAllowed', {
|
? 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'),
|
uploadTitle: t('library.file.uploadTitle'),
|
||||||
uploadHint: t('library.file.uploadHint'),
|
uploadHint: t('library.file.uploadHint'),
|
||||||
uploadFormats: forbiddenFiles.length
|
uploadFormats: forbiddenFiles.length
|
||||||
? t('library.file.uploadFormatsBlocked', {
|
? 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 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const emit = defineEmits<{
|
|||||||
'created': [tag: ImageTag]
|
'created': [tag: ImageTag]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const COMMON_LIMIT = 12
|
const COMMON_LIMIT = 10
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { createTag } = useTags()
|
const { createTag } = useTags()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -43,7 +43,7 @@ function onBack() {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="upload-card-flip"
|
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-flip-inner">
|
||||||
<div class="upload-card-face upload-card-face--front">
|
<div class="upload-card-face upload-card-face--front">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
import {
|
||||||
|
normalizeUploadTagNames,
|
||||||
|
type UploadMediaKind
|
||||||
|
} from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
/** 每个上传页各自一份偏好 */
|
/** 每个上传页各自一份偏好 */
|
||||||
@@ -17,20 +20,36 @@ const {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
lastTagIds,
|
uploadTagNames,
|
||||||
transcodeToHls,
|
transcodeToHls,
|
||||||
loadPreferences
|
loadPreferences
|
||||||
} = useUploadPreferences(props.kind)
|
} = useUploadPreferences(props.kind)
|
||||||
const { items: tagItems, fetchTags } = useTags()
|
const uploadTagDraft = ref('')
|
||||||
|
|
||||||
/** 视频床 / 文件床没有图片压缩相关选项 */
|
/** 视频床 / 文件床没有图片压缩相关选项 */
|
||||||
const isImageKind = computed(() => props.kind === 'image')
|
const isImageKind = computed(() => props.kind === 'image')
|
||||||
const isVideoKind = computed(() => props.kind === 'video')
|
const isVideoKind = computed(() => props.kind === 'video')
|
||||||
const gridClass = computed(() => {
|
const processingGridClass = computed(() => {
|
||||||
if (isImageKind.value) return 'sm:grid-cols-3 sm:gap-6'
|
if (isImageKind.value || isVideoKind.value) return 'md:col-start-1 md:row-start-1'
|
||||||
if (isVideoKind.value) return 'sm:grid-cols-2 sm:gap-6'
|
return ''
|
||||||
return 'mx-auto w-full max-w-md'
|
|
||||||
})
|
})
|
||||||
|
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 { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
@@ -74,11 +93,39 @@ function onClientQualityInput(event: Event) {
|
|||||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
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(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
void loadTranscodeSettings()
|
void loadTranscodeSettings()
|
||||||
void fetchTags()
|
|
||||||
})
|
})
|
||||||
</script>
|
</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="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 md:grid-cols-2 md:gap-6"
|
||||||
:class="gridClass"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isImageKind"
|
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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.processing') }}
|
{{ t('preferences.processing') }}
|
||||||
@@ -179,7 +226,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isVideoKind"
|
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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.videoProcessing') }}
|
{{ t('preferences.videoProcessing') }}
|
||||||
@@ -217,7 +265,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isImageKind"
|
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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.webpQuality') }}
|
{{ t('preferences.webpQuality') }}
|
||||||
@@ -239,7 +288,10 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
</div>
|
</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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.convenience') }}
|
{{ t('preferences.convenience') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -271,19 +323,57 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 space-y-3 border-t border-default pt-5">
|
<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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.uploadTags') }}
|
{{ t('preferences.uploadTags') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
<p class="text-xs leading-relaxed text-muted">
|
||||||
{{ t('preferences.uploadTagsHint') }}
|
{{ t('preferences.uploadTagsHint') }}
|
||||||
</p>
|
</p>
|
||||||
<TagAssignPanel
|
</div>
|
||||||
v-model="lastTagIds"
|
<UInput
|
||||||
:tags="tagItems"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,16 +109,7 @@ const uploadedLabel = computed(() => {
|
|||||||
<p class="truncate text-xs text-muted">
|
<p class="truncate text-xs text-muted">
|
||||||
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
|
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<TagBadgeList :tags="item.tags" />
|
||||||
v-if="item.tags?.length"
|
|
||||||
class="flex flex-wrap gap-1"
|
|
||||||
>
|
|
||||||
<TagBadge
|
|
||||||
v-for="tag in item.tags"
|
|
||||||
:key="tag.id"
|
|
||||||
:tag="tag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||||
|
|||||||
@@ -659,20 +659,11 @@ defineExpose({ refresh })
|
|||||||
{{ uploadedLabel(item) }}
|
{{ uploadedLabel(item) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-3 py-2 md:table-cell">
|
<td class="hidden px-3 py-2 md:table-cell">
|
||||||
<div
|
<TagBadgeList
|
||||||
v-if="item.tags?.length"
|
:tags="item.tags"
|
||||||
class="flex flex-wrap gap-1"
|
compact
|
||||||
>
|
:empty-label="t('tags.unlabeled')"
|
||||||
<TagBadge
|
|
||||||
v-for="tag in item.tags"
|
|
||||||
:key="tag.id"
|
|
||||||
:tag="tag"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-muted"
|
|
||||||
>{{ t('tags.unlabeled') }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<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<{
|
const props = defineProps<{
|
||||||
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
part: 'upload-preference' | 'image-processing' | 'login-rate'
|
||||||
uploadMaxFileSizeMbSource?: PolicySource
|
uploadImageMaxFileSizeMbSource?: PolicySource
|
||||||
|
uploadVideoMaxFileSizeMbSource?: PolicySource
|
||||||
|
uploadFileMaxFileSizeMbSource?: PolicySource
|
||||||
allowedMimeTypesSource?: PolicySource
|
allowedMimeTypesSource?: PolicySource
|
||||||
allowedVideoExtensionsSource?: PolicySource
|
allowedVideoExtensionsSource?: PolicySource
|
||||||
forbiddenFileExtensionsSource?: PolicySource
|
forbiddenFileExtensionsSource?: PolicySource
|
||||||
@@ -20,7 +22,9 @@ const props = defineProps<{
|
|||||||
forbiddenFileExtensionOptions?: ReadonlyArray<{ extension: string, label: string }>
|
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 allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
|
||||||
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
const allowedVideoExtensionsDraft = defineModel<VideoExtension[]>('allowedVideoExtensions')
|
||||||
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
const forbiddenFileExtensionsDraft = defineModel<string[]>('forbiddenFileExtensions')
|
||||||
@@ -125,70 +129,112 @@ function addCustomForbiddenExtension() {
|
|||||||
customForbiddenExtension.value = ''
|
customForbiddenExtension.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampUploadMaxFileSizeMb(value: unknown): number {
|
type UploadSizeKind = 'image' | 'video' | 'file'
|
||||||
const num = Number(value)
|
|
||||||
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
|
const uploadSizeFields = computed(() => [
|
||||||
return Math.min(100, Math.max(1, Math.round(num)))
|
{
|
||||||
|
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) {
|
function clampUploadMaxFileSizeMb(kind: UploadSizeKind, value: unknown): number {
|
||||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
|
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
|
(event.target as HTMLInputElement).value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUploadMaxFileSizeNumberInput(value: string | number) {
|
function onUploadMaxFileSizeNumberInput(kind: UploadSizeKind, value: string | number) {
|
||||||
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
|
uploadSizeModel(kind).value = clampUploadMaxFileSizeMb(kind, value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="part === 'upload-preference'">
|
<template v-if="part === 'upload-preference'">
|
||||||
<div class="py-4">
|
<div class="space-y-5 py-4">
|
||||||
|
<div
|
||||||
|
v-for="field in uploadSizeFields"
|
||||||
|
:key="field.kind"
|
||||||
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm font-medium text-highlighted">
|
<p class="text-sm font-medium text-highlighted">
|
||||||
{{ t('settings.uploadMaxFileSize') }}
|
{{ field.title }}
|
||||||
</p>
|
</p>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="uploadMaxFileSizeMbSource"
|
v-if="field.source"
|
||||||
:color="sourceBadge(uploadMaxFileSizeMbSource).color"
|
:color="sourceBadge(field.source).color"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
{{ sourceBadge(uploadMaxFileSizeMbSource).label }}
|
{{ sourceBadge(field.source).label }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.uploadMaxFileSizeHint') }}
|
{{ field.hint }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="mt-3 flex items-center gap-3"
|
class="mt-3 flex items-center gap-3"
|
||||||
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
|
:class="field.source === 'env' ? 'pointer-events-none opacity-60' : ''"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="0"
|
||||||
max="100"
|
:max="field.max"
|
||||||
:value="uploadMaxFileSizeMbDraft"
|
:value="field.value"
|
||||||
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
:disabled="field.source === 'env'"
|
||||||
@input="onUploadMaxFileSizeInput"
|
@input="onUploadMaxFileSizeInput(field.kind, $event)"
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
<UInput
|
<UInput
|
||||||
:model-value="uploadMaxFileSizeMbDraft"
|
:model-value="field.value"
|
||||||
type="number"
|
type="number"
|
||||||
:min="1"
|
:min="0"
|
||||||
:max="100"
|
:max="field.max"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="w-14 tabular-nums"
|
class="w-16 tabular-nums"
|
||||||
:disabled="uploadMaxFileSizeMbSource === 'env'"
|
:disabled="field.source === 'env'"
|
||||||
@update:model-value="onUploadMaxFileSizeNumberInput"
|
@update:model-value="onUploadMaxFileSizeNumberInput(field.kind, $event)"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted">MB</span>
|
<span class="text-xs text-muted">MB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-default/50 py-4">
|
<div class="border-t border-default/50 py-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import type {
|
|||||||
} from '~/types/image'
|
} from '~/types/image'
|
||||||
import { isUnauthorizedError } from './useAuth'
|
import { isUnauthorizedError } from './useAuth'
|
||||||
import { prepareFilesForUpload } from './useClipboardImage'
|
import { prepareFilesForUpload } from './useClipboardImage'
|
||||||
|
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||||
|
|
||||||
const MAX_FILES = 10
|
const MAX_FILES = 10
|
||||||
|
|
||||||
export function useImageUpload() {
|
export function useImageUpload() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
|
const { compressEnabled, clientWebpQuality, uploadTagNames } = useUploadPreferences()
|
||||||
const { items: tagItems } = useTags()
|
const { load: loadUploadPolicy } = useUploadPolicy()
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const progressItems = ref<UploadProgressItem[]>([])
|
const progressItems = ref<UploadProgressItem[]>([])
|
||||||
const lastUploadResult = ref<UploadResponse | null>(null)
|
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||||
|
|
||||||
async function uploadFiles(
|
async function uploadFiles(
|
||||||
files: File[],
|
files: File[],
|
||||||
options?: { notify?: boolean, tagIds?: number[] }
|
options?: { notify?: boolean }
|
||||||
) {
|
) {
|
||||||
if (uploading.value) return
|
if (uploading.value) return
|
||||||
|
|
||||||
@@ -43,10 +44,27 @@ export function useImageUpload() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = await prepareFilesForUpload(files, {
|
const preparedFiles = await prepareFilesForUpload(files, {
|
||||||
enabled: compressEnabled.value,
|
enabled: compressEnabled.value,
|
||||||
quality: clientWebpQuality.value / 100
|
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) => ({
|
progressItems.value = prepared.map((file, index) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
@@ -59,15 +77,9 @@ export function useImageUpload() {
|
|||||||
formData.append('files', file, file.name)
|
formData.append('files', file, file.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagIds = options?.tagIds ?? lastTagIds.value
|
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||||
if (tagIds.length) {
|
if (tagNames) {
|
||||||
const names = tagIds
|
formData.append('tagNames', tagNames)
|
||||||
.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 response = await $fetch<UploadResponse>('/api/images/upload', {
|
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UploadProgressItem } from '~/types/image'
|
|||||||
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
|
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
|
||||||
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
|
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
|
||||||
import { isUnauthorizedError } from './useAuth'
|
import { isUnauthorizedError } from './useAuth'
|
||||||
|
import { normalizeUploadTagNames } from './useUploadPreferences'
|
||||||
|
|
||||||
const MAX_FILES = 10
|
const MAX_FILES = 10
|
||||||
|
|
||||||
@@ -54,10 +55,9 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
transcodeToHls,
|
transcodeToHls,
|
||||||
lastTagIds,
|
uploadTagNames,
|
||||||
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[]>([])
|
||||||
@@ -209,6 +209,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
|
|||||||
})
|
})
|
||||||
return false
|
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
|
return true
|
||||||
})
|
})
|
||||||
if (!acceptedFiles.length) return
|
if (!acceptedFiles.length) return
|
||||||
@@ -234,14 +247,9 @@ 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) {
|
const tagNames = normalizeUploadTagNames(uploadTagNames.value).join(',')
|
||||||
await fetchTags()
|
if (tagNames) {
|
||||||
const names = lastTagIds.value
|
formData.append('tagNames', tagNames)
|
||||||
.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 => ({
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ interface UploadPolicy {
|
|||||||
allowedMimeTypes: string[]
|
allowedMimeTypes: string[]
|
||||||
allowedVideoExtensions: string[]
|
allowedVideoExtensions: string[]
|
||||||
forbiddenFileExtensions: string[]
|
forbiddenFileExtensions: string[]
|
||||||
|
imageMaxFileSizeMb: number
|
||||||
|
videoMaxFileSizeMb: number
|
||||||
|
fileMaxFileSizeMb: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const policy = ref<UploadPolicy | null>(null)
|
const policy = ref<UploadPolicy | null>(null)
|
||||||
@@ -21,7 +24,10 @@ export function useUploadPolicy() {
|
|||||||
policy.value = {
|
policy.value = {
|
||||||
allowedMimeTypes: [...data.allowedMimeTypes],
|
allowedMimeTypes: [...data.allowedMimeTypes],
|
||||||
allowedVideoExtensions: [...data.allowedVideoExtensions],
|
allowedVideoExtensions: [...data.allowedVideoExtensions],
|
||||||
forbiddenFileExtensions: [...data.forbiddenFileExtensions]
|
forbiddenFileExtensions: [...data.forbiddenFileExtensions],
|
||||||
|
imageMaxFileSizeMb: data.imageMaxFileSizeMb,
|
||||||
|
videoMaxFileSizeMb: data.videoMaxFileSizeMb,
|
||||||
|
fileMaxFileSizeMb: data.fileMaxFileSizeMb
|
||||||
}
|
}
|
||||||
return policy.value
|
return policy.value
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface UploadPreferences {
|
|||||||
autoCopyMarkdown: boolean
|
autoCopyMarkdown: boolean
|
||||||
copyFormat: CopyFormat
|
copyFormat: CopyFormat
|
||||||
clientWebpQuality: number
|
clientWebpQuality: number
|
||||||
lastTagIds: number[]
|
uploadTagNames: string[]
|
||||||
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
||||||
transcodeToHls: boolean
|
transcodeToHls: boolean
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,21 @@ const DEFAULTS: UploadPreferences = {
|
|||||||
autoCopyMarkdown: false,
|
autoCopyMarkdown: false,
|
||||||
copyFormat: 'markdown',
|
copyFormat: 'markdown',
|
||||||
clientWebpQuality: 80,
|
clientWebpQuality: 80,
|
||||||
lastTagIds: [],
|
uploadTagNames: [],
|
||||||
transcodeToHls: false
|
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 {
|
function clampQuality(value: unknown): number {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
||||||
@@ -49,9 +60,8 @@ function readStored(kind: UploadMediaKind): UploadPreferences {
|
|||||||
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||||
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
||||||
lastTagIds: Array.isArray(parsed.lastTagIds)
|
/** 标签输入只在当前页面会话内生效,刷新后默认为空。 */
|
||||||
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
uploadTagNames: [...DEFAULTS.uploadTagNames],
|
||||||
: DEFAULTS.lastTagIds,
|
|
||||||
transcodeToHls: parsed.transcodeToHls === true
|
transcodeToHls: parsed.transcodeToHls === true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -69,17 +79,23 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
|||||||
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
||||||
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
||||||
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
|
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 transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
|
||||||
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
|
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
|
||||||
|
|
||||||
function loadPreferences() {
|
function loadPreferences() {
|
||||||
|
const firstLoad = !loaded.value
|
||||||
const stored = readStored(kind)
|
const stored = readStored(kind)
|
||||||
compressEnabled.value = stored.compressEnabled
|
compressEnabled.value = stored.compressEnabled
|
||||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||||
copyFormat.value = stored.copyFormat
|
copyFormat.value = stored.copyFormat
|
||||||
clientWebpQuality.value = stored.clientWebpQuality
|
clientWebpQuality.value = stored.clientWebpQuality
|
||||||
lastTagIds.value = stored.lastTagIds
|
if (firstLoad) {
|
||||||
|
uploadTagNames.value = [...stored.uploadTagNames]
|
||||||
|
}
|
||||||
transcodeToHls.value = stored.transcodeToHls
|
transcodeToHls.value = stored.transcodeToHls
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
@@ -94,7 +110,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
|||||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||||
copyFormat: copyFormat.value,
|
copyFormat: copyFormat.value,
|
||||||
clientWebpQuality: clampQuality(clientWebpQuality.value),
|
clientWebpQuality: clampQuality(clientWebpQuality.value),
|
||||||
lastTagIds: lastTagIds.value,
|
uploadTagNames: [...DEFAULTS.uploadTagNames],
|
||||||
transcodeToHls: transcodeToHls.value
|
transcodeToHls: transcodeToHls.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,12 +120,13 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
|||||||
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
||||||
copyFormat.value = DEFAULTS.copyFormat
|
copyFormat.value = DEFAULTS.copyFormat
|
||||||
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
||||||
|
uploadTagNames.value = [...DEFAULTS.uploadTagNames]
|
||||||
transcodeToHls.value = DEFAULTS.transcodeToHls
|
transcodeToHls.value = DEFAULTS.transcodeToHls
|
||||||
savePreferences()
|
savePreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls],
|
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, transcodeToHls],
|
||||||
() => {
|
() => {
|
||||||
if (loaded.value) {
|
if (loaded.value) {
|
||||||
savePreferences()
|
savePreferences()
|
||||||
@@ -123,7 +140,7 @@ export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
lastTagIds,
|
uploadTagNames,
|
||||||
transcodeToHls,
|
transcodeToHls,
|
||||||
loaded,
|
loaded,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ImageItem, ImageTag } from '~/types/image'
|
|||||||
import { buildImageBbcode } from '~/utils/image-display'
|
import { buildImageBbcode } from '~/utils/image-display'
|
||||||
|
|
||||||
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
||||||
|
const { policy: uploadPolicy, load: loadUploadPolicy } = useUploadPolicy()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
@@ -16,6 +17,9 @@ const { recordRecent } = useRecentTags()
|
|||||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const imageFormats = computed(() =>
|
||||||
|
t('upload.formats', { size: uploadPolicy.value?.imageMaxFileSizeMb ?? 10 })
|
||||||
|
)
|
||||||
const sessionItems = ref<ImageItem[]>([])
|
const sessionItems = ref<ImageItem[]>([])
|
||||||
const deletingKeys = ref<Set<string>>(new Set())
|
const deletingKeys = ref<Set<string>>(new Set())
|
||||||
const emptySelection = ref(new Set<string>())
|
const emptySelection = ref(new Set<string>())
|
||||||
@@ -39,6 +43,7 @@ const showProgress = computed(
|
|||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
try {
|
try {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
|
await loadUploadPolicy().catch(() => {})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleAuthError(error)
|
handleAuthError(error)
|
||||||
}
|
}
|
||||||
@@ -227,6 +232,7 @@ async function confirmBatchTags(tagIds: number[]) {
|
|||||||
<AppShell v-else>
|
<AppShell v-else>
|
||||||
<UploadFlipCard
|
<UploadFlipCard
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
|
:formats="imageFormats"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ interface SettingsResponse {
|
|||||||
capApiEndpoint: string
|
capApiEndpoint: string
|
||||||
capSecret: string
|
capSecret: string
|
||||||
appVersion: string
|
appVersion: string
|
||||||
uploadMaxFileSizeMb: number
|
uploadImageMaxFileSizeMb: number
|
||||||
uploadMaxFileSizeMbSource: PolicySource
|
uploadImageMaxFileSizeMbSource: PolicySource
|
||||||
|
uploadVideoMaxFileSizeMb: number
|
||||||
|
uploadVideoMaxFileSizeMbSource: PolicySource
|
||||||
|
uploadFileMaxFileSizeMb: number
|
||||||
|
uploadFileMaxFileSizeMbSource: PolicySource
|
||||||
allowedMimeTypes: MimeType[]
|
allowedMimeTypes: MimeType[]
|
||||||
allowedMimeTypesSource: PolicySource
|
allowedMimeTypesSource: PolicySource
|
||||||
allowedVideoExtensions: VideoExtension[]
|
allowedVideoExtensions: VideoExtension[]
|
||||||
@@ -185,7 +189,9 @@ const capApiEndpointDraft = ref('')
|
|||||||
const capSecretDraft = ref('')
|
const capSecretDraft = ref('')
|
||||||
const domainSeparationSaveConfirmOpen = ref(false)
|
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 allowedMimeTypesDraft = ref<MimeType[]>([])
|
||||||
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
const allowedVideoExtensionsDraft = ref<VideoExtension[]>([])
|
||||||
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
const forbiddenFileExtensionsDraft = ref<string[]>([])
|
||||||
@@ -242,7 +248,9 @@ const hasServerChanges = computed(() => {
|
|||||||
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
||||||
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
||||||
|| capSecretDraft.value !== settings.value.capSecret
|
|| 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(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|
||||||
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
|| !mimeListsEqual(allowedVideoExtensionsDraft.value, settings.value.allowedVideoExtensions)
|
||||||
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
|| !mimeListsEqual(forbiddenFileExtensionsDraft.value, settings.value.forbiddenFileExtensions)
|
||||||
@@ -320,7 +328,9 @@ function applySettings(data: SettingsResponse) {
|
|||||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||||
capApiEndpointDraft.value = data.capApiEndpoint
|
capApiEndpointDraft.value = data.capApiEndpoint
|
||||||
capSecretDraft.value = data.capSecret
|
capSecretDraft.value = data.capSecret
|
||||||
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
|
uploadImageMaxFileSizeMbDraft.value = data.uploadImageMaxFileSizeMb
|
||||||
|
uploadVideoMaxFileSizeMbDraft.value = data.uploadVideoMaxFileSizeMb
|
||||||
|
uploadFileMaxFileSizeMbDraft.value = data.uploadFileMaxFileSizeMb
|
||||||
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
|
||||||
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
allowedVideoExtensionsDraft.value = [...data.allowedVideoExtensions]
|
||||||
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
forbiddenFileExtensionsDraft.value = [...data.forbiddenFileExtensions]
|
||||||
@@ -423,7 +433,9 @@ async function performSaveServerSettings() {
|
|||||||
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
||||||
capApiEndpoint: capApiEndpointDraft.value,
|
capApiEndpoint: capApiEndpointDraft.value,
|
||||||
capSecret: capSecretDraft.value,
|
capSecret: capSecretDraft.value,
|
||||||
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
|
uploadImageMaxFileSizeMb: uploadImageMaxFileSizeMbDraft.value,
|
||||||
|
uploadVideoMaxFileSizeMb: uploadVideoMaxFileSizeMbDraft.value,
|
||||||
|
uploadFileMaxFileSizeMb: uploadFileMaxFileSizeMbDraft.value,
|
||||||
allowedMimeTypes: allowedMimeTypesDraft.value,
|
allowedMimeTypes: allowedMimeTypesDraft.value,
|
||||||
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
allowedVideoExtensions: allowedVideoExtensionsDraft.value,
|
||||||
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
forbiddenFileExtensions: forbiddenFileExtensionsDraft.value,
|
||||||
@@ -626,7 +638,9 @@ watch(isAdmin, () => {
|
|||||||
>
|
>
|
||||||
<SettingsGroup>
|
<SettingsGroup>
|
||||||
<SettingsUploadPolicyFields
|
<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-mime-types="allowedMimeTypesDraft"
|
||||||
v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
|
v-model:allowed-video-extensions="allowedVideoExtensionsDraft"
|
||||||
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
|
v-model:forbidden-file-extensions="forbiddenFileExtensionsDraft"
|
||||||
@@ -634,7 +648,9 @@ watch(isAdmin, () => {
|
|||||||
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
|
||||||
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
|
||||||
part="upload-preference"
|
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-mime-types-source="settings.allowedMimeTypesSource"
|
||||||
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
|
:allowed-video-extensions-source="settings.allowedVideoExtensionsSource"
|
||||||
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
|
:forbidden-file-extensions-source="settings.forbiddenFileExtensionsSource"
|
||||||
|
|||||||
+17
-7
@@ -158,7 +158,7 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"title": "Drop images here to upload",
|
"title": "Drop images here to upload",
|
||||||
"hint": "Or click to select files, press {ctrl} + {v} to paste",
|
"hint": "Or click to select files, press {ctrl} + {v} to paste",
|
||||||
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max 10 MB each, up to 10 files per batch",
|
"formats": "JPEG / PNG / WebP / GIF / SVG / ICO, max {size} MB each, up to 10 files per batch",
|
||||||
"selectFiles": "Select images",
|
"selectFiles": "Select images",
|
||||||
"settings": "Upload settings",
|
"settings": "Upload settings",
|
||||||
"progress": "Upload progress",
|
"progress": "Upload progress",
|
||||||
@@ -329,7 +329,10 @@
|
|||||||
"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",
|
"uploadTags": "Upload tags",
|
||||||
"uploadTagsHint": "Tags selected here are applied to images, videos, and files; missing tags are created automatically."
|
"uploadTagsHint": "Press Enter, type a comma, or leave the field to add a tag. Leave empty to upload without tags.",
|
||||||
|
"uploadTagsPlaceholder": "Enter tag",
|
||||||
|
"uploadTagsPending": "Pending tags ({n})",
|
||||||
|
"uploadTagsRemove": "Remove tag {name}"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "System settings",
|
"title": "System settings",
|
||||||
@@ -475,6 +478,12 @@
|
|||||||
"minutesUnit": "min",
|
"minutesUnit": "min",
|
||||||
"uploadMaxFileSize": "Max file size",
|
"uploadMaxFileSize": "Max file size",
|
||||||
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
|
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
|
||||||
|
"uploadImageMaxFileSize": "Image size limit",
|
||||||
|
"uploadImageMaxFileSizeHint": "Images larger than this are rejected, up to 100 MB. 0 MB blocks uploads.",
|
||||||
|
"uploadVideoMaxFileSize": "Video size limit",
|
||||||
|
"uploadVideoMaxFileSizeHint": "Videos larger than this are rejected, up to 2048 MB. 0 MB blocks uploads.",
|
||||||
|
"uploadFileMaxFileSize": "File size limit",
|
||||||
|
"uploadFileMaxFileSizeHint": "Regular files larger than this are rejected, up to 1024 MB. 0 MB blocks uploads.",
|
||||||
"allowedMimeTypes": "Allowed image formats",
|
"allowedMimeTypes": "Allowed image formats",
|
||||||
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
|
||||||
"allowedVideoExtensions": "Allowed video formats",
|
"allowedVideoExtensions": "Allowed video formats",
|
||||||
@@ -1169,6 +1178,7 @@
|
|||||||
"maxFiles": "Up to {n} files per upload",
|
"maxFiles": "Up to {n} files per upload",
|
||||||
"uploadSuccess": "Uploaded {n} file(s)",
|
"uploadSuccess": "Uploaded {n} file(s)",
|
||||||
"uploadFailed": "Upload failed, please try again",
|
"uploadFailed": "Upload failed, please try again",
|
||||||
|
"fileTooLarge": "{name}: exceeds the {limit} MB per-file limit",
|
||||||
"loadFailed": "Failed to load files",
|
"loadFailed": "Failed to load files",
|
||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
@@ -1218,9 +1228,9 @@
|
|||||||
"subtitle": "Manage uploaded files, copy links or download",
|
"subtitle": "Manage uploaded files, copy links or download",
|
||||||
"uploadTitle": "Drop files here to upload",
|
"uploadTitle": "Drop files here to upload",
|
||||||
"uploadHint": "or click to choose files, up to 10 at a time",
|
"uploadHint": "or click to choose files, up to 10 at a time",
|
||||||
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file",
|
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to {size} MB per file",
|
||||||
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to 100MB per file",
|
"uploadFormatsBlocked": "{formats} blocked; other file types allowed, up to {size} MB per file",
|
||||||
"uploadFormatsUnrestricted": "Any file type allowed, up to 100MB per file",
|
"uploadFormatsUnrestricted": "Any file type allowed, up to {size} MB per file",
|
||||||
"empty": "No files yet — upload one to get started"
|
"empty": "No files yet — upload one to get started"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
@@ -1243,8 +1253,8 @@
|
|||||||
"downloadMp4Failed": "Video merge failed. Please try again later.",
|
"downloadMp4Failed": "Video merge failed. Please try again later.",
|
||||||
"uploadTitle": "Drop videos here to upload",
|
"uploadTitle": "Drop videos here to upload",
|
||||||
"uploadHint": "or click to choose video files, up to 10 at a time",
|
"uploadHint": "or click to choose video files, up to 10 at a time",
|
||||||
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
|
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to {size} MB per file",
|
||||||
"uploadFormatsAllowed": "{formats} supported, up to 100MB per file",
|
"uploadFormatsAllowed": "{formats} supported, up to {size} MB per file",
|
||||||
"empty": "No videos yet — upload one to get started"
|
"empty": "No videos yet — upload one to get started"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-7
@@ -158,7 +158,7 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"title": "拖拽图片到此处上传",
|
"title": "拖拽图片到此处上传",
|
||||||
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
|
"hint": "或点击选择文件,按 {ctrl} + {v} 粘贴图片",
|
||||||
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张",
|
"formats": "支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 {size} MB,每次最多 10 张",
|
||||||
"selectFiles": "选择图片",
|
"selectFiles": "选择图片",
|
||||||
"settings": "上传设置",
|
"settings": "上传设置",
|
||||||
"progress": "上传进度",
|
"progress": "上传进度",
|
||||||
@@ -329,7 +329,10 @@
|
|||||||
"autoCopy": "上传成功自动复制链接",
|
"autoCopy": "上传成功自动复制链接",
|
||||||
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
|
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。",
|
||||||
"uploadTags": "上传标签",
|
"uploadTags": "上传标签",
|
||||||
"uploadTagsHint": "图片、视频和文件共用当前上传页选择的标签;不存在的标签会自动创建。"
|
"uploadTagsHint": "输入后按 Enter、逗号或移开焦点加入待添加列表;留空则不添加标签。",
|
||||||
|
"uploadTagsPlaceholder": "输入标签",
|
||||||
|
"uploadTagsPending": "待添加标签({n})",
|
||||||
|
"uploadTagsRemove": "删除标签 {name}"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
@@ -475,6 +478,12 @@
|
|||||||
"minutesUnit": "分钟",
|
"minutesUnit": "分钟",
|
||||||
"uploadMaxFileSize": "单文件大小上限",
|
"uploadMaxFileSize": "单文件大小上限",
|
||||||
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
|
||||||
|
"uploadImageMaxFileSize": "图片大小上限",
|
||||||
|
"uploadImageMaxFileSizeHint": "超过此大小的图片将被拒绝上传,最大 100 MB。0 MB 表示禁止上传。",
|
||||||
|
"uploadVideoMaxFileSize": "视频大小上限",
|
||||||
|
"uploadVideoMaxFileSizeHint": "超过此大小的视频将被拒绝上传,最大 2048 MB。0 MB 表示禁止上传。",
|
||||||
|
"uploadFileMaxFileSize": "文件大小上限",
|
||||||
|
"uploadFileMaxFileSizeHint": "超过此大小的普通文件将被拒绝上传,最大 1024 MB。0 MB 表示禁止上传。",
|
||||||
"allowedMimeTypes": "允许的图片格式",
|
"allowedMimeTypes": "允许的图片格式",
|
||||||
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
|
||||||
"allowedVideoExtensions": "允许的视频格式",
|
"allowedVideoExtensions": "允许的视频格式",
|
||||||
@@ -1172,6 +1181,7 @@
|
|||||||
"maxFiles": "每次最多上传 {n} 个文件",
|
"maxFiles": "每次最多上传 {n} 个文件",
|
||||||
"uploadSuccess": "已上传 {n} 个文件",
|
"uploadSuccess": "已上传 {n} 个文件",
|
||||||
"uploadFailed": "上传失败,请稍后重试",
|
"uploadFailed": "上传失败,请稍后重试",
|
||||||
|
"fileTooLarge": "{name}:超过单文件上限 {limit} MB",
|
||||||
"loadFailed": "读取文件列表失败",
|
"loadFailed": "读取文件列表失败",
|
||||||
"copyLink": "复制直链",
|
"copyLink": "复制直链",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
@@ -1221,9 +1231,9 @@
|
|||||||
"subtitle": "管理已上传的文件,复制直链或下载",
|
"subtitle": "管理已上传的文件,复制直链或下载",
|
||||||
"uploadTitle": "拖拽文件到此处上传",
|
"uploadTitle": "拖拽文件到此处上传",
|
||||||
"uploadHint": "或点击选择文件,单次最多 10 个",
|
"uploadHint": "或点击选择文件,单次最多 10 个",
|
||||||
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB",
|
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 {size} MB",
|
||||||
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 100MB",
|
"uploadFormatsBlocked": "禁止 {formats};其他文件类型可上传,单个文件最大 {size} MB",
|
||||||
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 100MB",
|
"uploadFormatsUnrestricted": "支持任意类型,单个文件最大 {size} MB",
|
||||||
"empty": "还没有文件,上传一个试试"
|
"empty": "还没有文件,上传一个试试"
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
@@ -1246,8 +1256,8 @@
|
|||||||
"downloadMp4Failed": "视频合并失败,请稍后重试",
|
"downloadMp4Failed": "视频合并失败,请稍后重试",
|
||||||
"uploadTitle": "拖拽视频到此处上传",
|
"uploadTitle": "拖拽视频到此处上传",
|
||||||
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
||||||
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
|
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 {size} MB",
|
||||||
"uploadFormatsAllowed": "支持 {formats},单个文件最大 100MB",
|
"uploadFormatsAllowed": "支持 {formats},单个文件最大 {size} MB",
|
||||||
"empty": "还没有视频,上传一个试试"
|
"empty": "还没有视频,上传一个试试"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
parseLoginRateMax,
|
parseLoginRateMax,
|
||||||
parseLoginRateWindowMinutes,
|
parseLoginRateWindowMinutes,
|
||||||
parseUploadMaxFileSizeMb,
|
parseUploadMaxFileSizeMb,
|
||||||
|
parseUploadMaxFileSizeMbForKind,
|
||||||
parseUploadRateIpMax,
|
parseUploadRateIpMax,
|
||||||
parseUploadRateTokenMax,
|
parseUploadRateTokenMax,
|
||||||
parseUploadRateWindowMinutes,
|
parseUploadRateWindowMinutes,
|
||||||
@@ -78,6 +79,9 @@ interface SettingsPatchBody {
|
|||||||
capApiEndpoint?: unknown
|
capApiEndpoint?: unknown
|
||||||
capSecret?: unknown
|
capSecret?: unknown
|
||||||
uploadMaxFileSizeMb?: unknown
|
uploadMaxFileSizeMb?: unknown
|
||||||
|
uploadImageMaxFileSizeMb?: unknown
|
||||||
|
uploadVideoMaxFileSizeMb?: unknown
|
||||||
|
uploadFileMaxFileSizeMb?: unknown
|
||||||
allowedMimeTypes?: unknown
|
allowedMimeTypes?: unknown
|
||||||
allowedVideoExtensions?: unknown
|
allowedVideoExtensions?: unknown
|
||||||
forbiddenFileExtensions?: unknown
|
forbiddenFileExtensions?: unknown
|
||||||
@@ -360,11 +364,44 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body.uploadMaxFileSizeMb !== undefined) {
|
if (body.uploadMaxFileSizeMb !== undefined) {
|
||||||
const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb))
|
const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb))
|
||||||
if (mb === null) {
|
if (mb === null) {
|
||||||
createApiError(event, 'INVALID_REQUEST', '单文件大小需为 1–100 MB 的整数', 400)
|
createApiError(event, 'INVALID_REQUEST', '图片大小需为 0–100 MB 的整数', 400)
|
||||||
}
|
}
|
||||||
setUploadPolicySettings({ uploadMaxFileSizeMb: mb })
|
setUploadPolicySettings({ uploadMaxFileSizeMb: mb })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.uploadImageMaxFileSizeMb !== undefined) {
|
||||||
|
const mb = parseUploadMaxFileSizeMbForKind(
|
||||||
|
String(body.uploadImageMaxFileSizeMb),
|
||||||
|
'image'
|
||||||
|
)
|
||||||
|
if (mb === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '图片大小需为 0–100 MB 的整数', 400)
|
||||||
|
}
|
||||||
|
setUploadPolicySettings({ uploadImageMaxFileSizeMb: mb })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.uploadVideoMaxFileSizeMb !== undefined) {
|
||||||
|
const mb = parseUploadMaxFileSizeMbForKind(
|
||||||
|
String(body.uploadVideoMaxFileSizeMb),
|
||||||
|
'video'
|
||||||
|
)
|
||||||
|
if (mb === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '视频大小需为 0–2048 MB 的整数', 400)
|
||||||
|
}
|
||||||
|
setUploadPolicySettings({ uploadVideoMaxFileSizeMb: mb })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.uploadFileMaxFileSizeMb !== undefined) {
|
||||||
|
const mb = parseUploadMaxFileSizeMbForKind(
|
||||||
|
String(body.uploadFileMaxFileSizeMb),
|
||||||
|
'file'
|
||||||
|
)
|
||||||
|
if (mb === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '文件大小需为 0–1024 MB 的整数', 400)
|
||||||
|
}
|
||||||
|
setUploadPolicySettings({ uploadFileMaxFileSizeMb: mb })
|
||||||
|
}
|
||||||
|
|
||||||
if (body.allowedMimeTypes !== undefined) {
|
if (body.allowedMimeTypes !== undefined) {
|
||||||
const raw = Array.isArray(body.allowedMimeTypes)
|
const raw = Array.isArray(body.allowedMimeTypes)
|
||||||
? body.allowedMimeTypes.map(item => String(item))
|
? body.allowedMimeTypes.map(item => String(item))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
getAllowedMimeTypesList,
|
getAllowedMimeTypesList,
|
||||||
getAllowedVideoExtensions,
|
getAllowedVideoExtensions,
|
||||||
|
getEffectiveUploadMaxFileSizeMb,
|
||||||
getForbiddenFileExtensions
|
getForbiddenFileExtensions
|
||||||
} from '../utils/upload-policy'
|
} from '../utils/upload-policy'
|
||||||
|
|
||||||
@@ -9,6 +10,9 @@ export default defineEventHandler((event) => {
|
|||||||
return {
|
return {
|
||||||
allowedMimeTypes: getAllowedMimeTypesList(),
|
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||||
allowedVideoExtensions: getAllowedVideoExtensions(),
|
allowedVideoExtensions: getAllowedVideoExtensions(),
|
||||||
forbiddenFileExtensions: getForbiddenFileExtensions()
|
forbiddenFileExtensions: getForbiddenFileExtensions(),
|
||||||
|
imageMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('image'),
|
||||||
|
videoMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('video'),
|
||||||
|
fileMaxFileSizeMb: getEffectiveUploadMaxFileSizeMb('file')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import type { UploadErrorItem } from '~/types/image'
|
import type { UploadErrorItem } from '~/types/image'
|
||||||
import type { FileItem } from '~/types/file'
|
import type { FileItem } from '~/types/file'
|
||||||
import {
|
|
||||||
MAX_FILE_BED_FILE_SIZE,
|
|
||||||
MAX_VIDEO_FILE_SIZE
|
|
||||||
} from './constants'
|
|
||||||
import { contentTypeFromKey } from './content-type'
|
import { contentTypeFromKey } from './content-type'
|
||||||
import {
|
import {
|
||||||
extensionFromFilename,
|
extensionFromFilename,
|
||||||
@@ -21,7 +17,9 @@ import { putImage } from './storage'
|
|||||||
import { generateVideoThumbnail } from './transcode'
|
import { generateVideoThumbnail } from './transcode'
|
||||||
import { updateImageThumb } from './image-index'
|
import { updateImageThumb } from './image-index'
|
||||||
import {
|
import {
|
||||||
|
formatEffectiveMaxFileSizeMessage,
|
||||||
getAllowedVideoExtensions,
|
getAllowedVideoExtensions,
|
||||||
|
getEffectiveMaxFileSizeBytes,
|
||||||
isForbiddenFileExtension,
|
isForbiddenFileExtension,
|
||||||
isVideoExtensionAllowed
|
isVideoExtensionAllowed
|
||||||
} from './upload-policy'
|
} from './upload-policy'
|
||||||
@@ -126,13 +124,13 @@ export async function processSingleFileUpload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes.byteLength > MAX_FILE_BED_FILE_SIZE) {
|
if (bytes.byteLength > getEffectiveMaxFileSizeBytes('file')) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
name: originalName,
|
name: originalName,
|
||||||
error: {
|
error: {
|
||||||
code: 'FILE_TOO_LARGE',
|
code: 'FILE_TOO_LARGE',
|
||||||
message: `单个文件不能超过 ${Math.round(MAX_FILE_BED_FILE_SIZE / 1024 / 1024)}MB`
|
message: formatEffectiveMaxFileSizeMessage('file')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,13 +269,13 @@ export async function processSingleVideoUpload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes.byteLength > MAX_VIDEO_FILE_SIZE) {
|
if (bytes.byteLength > getEffectiveMaxFileSizeBytes('video')) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
name: originalName,
|
name: originalName,
|
||||||
error: {
|
error: {
|
||||||
code: 'FILE_TOO_LARGE',
|
code: 'FILE_TOO_LARGE',
|
||||||
message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB`
|
message: formatEffectiveMaxFileSizeMessage('video')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { findUserByUsername } from '../db'
|
import {
|
||||||
|
findUserByUsername,
|
||||||
|
getUploadSourcesForKeys,
|
||||||
|
insertActivityLog
|
||||||
|
} from '../db'
|
||||||
import {
|
import {
|
||||||
getImageIndexRow,
|
getImageIndexRow,
|
||||||
insertImageIndex,
|
insertImageIndex,
|
||||||
@@ -52,6 +56,24 @@ export async function applySiteSyncMedia(
|
|||||||
mediaType: media.mediaType
|
mediaType: media.mediaType
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
media.uploadSource
|
||||||
|
&& getUploadSourcesForKeys([media.key]).get(media.key) !== media.uploadSource
|
||||||
|
) {
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'upload',
|
||||||
|
key: media.key,
|
||||||
|
originalName: media.originalName,
|
||||||
|
size: media.size,
|
||||||
|
contentType: media.contentType,
|
||||||
|
source: media.uploadSource,
|
||||||
|
userId: owner?.id ?? null,
|
||||||
|
backendId: backend.id,
|
||||||
|
status: 'success',
|
||||||
|
createdAt: media.uploadedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (media.thumbKey) updateImageThumb(media.key, media.thumbKey)
|
if (media.thumbKey) updateImageThumb(media.key, media.thumbKey)
|
||||||
if (media.mediaType === 'video') {
|
if (media.mediaType === 'video') {
|
||||||
updateImageTranscode(media.key, {
|
updateImageTranscode(media.key, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ImageIndexRow } from '../image-index'
|
import type { ImageIndexRow } from '../image-index'
|
||||||
|
import { getUploadSourcesForKeys } from '../db'
|
||||||
import { getTagsForImageKeys } from '../tags'
|
import { getTagsForImageKeys } from '../tags'
|
||||||
import type {
|
import type {
|
||||||
SiteSyncMediaTransfer,
|
SiteSyncMediaTransfer,
|
||||||
@@ -10,6 +11,8 @@ export function toSiteSyncMediaTransfer(
|
|||||||
ownerUsername: string | null,
|
ownerUsername: string | null,
|
||||||
tags: SiteSyncTag[]
|
tags: SiteSyncTag[]
|
||||||
): SiteSyncMediaTransfer {
|
): SiteSyncMediaTransfer {
|
||||||
|
const source = getUploadSourcesForKeys([row.key]).get(row.key)
|
||||||
|
const uploadSource = source === 'web' || source === 'api' ? source : undefined
|
||||||
return {
|
return {
|
||||||
key: row.key,
|
key: row.key,
|
||||||
ownerUsername,
|
ownerUsername,
|
||||||
@@ -18,6 +21,7 @@ export function toSiteSyncMediaTransfer(
|
|||||||
size: row.size,
|
size: row.size,
|
||||||
uploadedAt: row.uploaded_at,
|
uploadedAt: row.uploaded_at,
|
||||||
mediaType: row.media_type,
|
mediaType: row.media_type,
|
||||||
|
...(uploadSource ? { uploadSource } : {}),
|
||||||
thumbKey: row.thumb_key,
|
thumbKey: row.thumb_key,
|
||||||
hlsKey: row.hls_key,
|
hlsKey: row.hls_key,
|
||||||
transcodeStatus: row.transcode_status as SiteSyncMediaTransfer['transcodeStatus'],
|
transcodeStatus: row.transcode_status as SiteSyncMediaTransfer['transcodeStatus'],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SiteSyncMediaTransfer {
|
|||||||
size: number
|
size: number
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
mediaType: MediaType
|
mediaType: MediaType
|
||||||
|
uploadSource?: 'web' | 'api'
|
||||||
thumbKey: string | null
|
thumbKey: string | null
|
||||||
hlsKey: string | null
|
hlsKey: string | null
|
||||||
transcodeStatus: TranscodeStatus
|
transcodeStatus: TranscodeStatus
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
parseLoginRateMax,
|
parseLoginRateMax,
|
||||||
parsePreserveOriginalUpload,
|
parsePreserveOriginalUpload,
|
||||||
parseUploadMaxFileSizeMb,
|
parseUploadMaxFileSizeMb,
|
||||||
|
parseUploadMaxFileSizeMbForKind,
|
||||||
parseUploadRateIpMax,
|
parseUploadRateIpMax,
|
||||||
serializeAllowedMimeTypes,
|
serializeAllowedMimeTypes,
|
||||||
serializeAllowedVideoExtensions,
|
serializeAllowedVideoExtensions,
|
||||||
@@ -18,10 +19,19 @@ import {
|
|||||||
describe('upload-policy parsers', () => {
|
describe('upload-policy parsers', () => {
|
||||||
it('parses upload max file size mb', () => {
|
it('parses upload max file size mb', () => {
|
||||||
expect(parseUploadMaxFileSizeMb('10')).toBe(10)
|
expect(parseUploadMaxFileSizeMb('10')).toBe(10)
|
||||||
expect(parseUploadMaxFileSizeMb('0')).toBeNull()
|
expect(parseUploadMaxFileSizeMb('0')).toBe(0)
|
||||||
expect(parseUploadMaxFileSizeMb('101')).toBeNull()
|
expect(parseUploadMaxFileSizeMb('101')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses separate image, video and file size limits', () => {
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('0', 'image')).toBe(0)
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('100', 'image')).toBe(100)
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('2048', 'video')).toBe(2048)
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('2049', 'video')).toBeNull()
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('1024', 'file')).toBe(1024)
|
||||||
|
expect(parseUploadMaxFileSizeMbForKind('1025', 'file')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes allowed mime types', () => {
|
it('normalizes allowed mime types', () => {
|
||||||
expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([
|
expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([
|
||||||
'image/png',
|
'image/png',
|
||||||
|
|||||||
+146
-28
@@ -10,6 +10,9 @@ import { getActiveBackendRow } from './storage/resolver'
|
|||||||
import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram'
|
import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram'
|
||||||
|
|
||||||
export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
|
export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
|
||||||
|
export const SETTINGS_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 'upload_image_max_file_size_mb'
|
||||||
|
export const SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 'upload_video_max_file_size_mb'
|
||||||
|
export const SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB = 'upload_file_max_file_size_mb'
|
||||||
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
|
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
|
||||||
export const SETTINGS_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
|
export const SETTINGS_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
|
||||||
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
|
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
|
||||||
@@ -21,8 +24,14 @@ export const SETTINGS_LOGIN_RATE_WINDOW_MINUTES = 'login_rate_window_minutes'
|
|||||||
export const SETTINGS_PRESERVE_ORIGINAL_UPLOAD = 'preserve_original_upload'
|
export const SETTINGS_PRESERVE_ORIGINAL_UPLOAD = 'preserve_original_upload'
|
||||||
|
|
||||||
export const DEFAULT_UPLOAD_MAX_FILE_SIZE_MB = 10
|
export const DEFAULT_UPLOAD_MAX_FILE_SIZE_MB = 10
|
||||||
export const MIN_UPLOAD_MAX_FILE_SIZE_MB = 1
|
export const MIN_UPLOAD_MAX_FILE_SIZE_MB = 0
|
||||||
export const MAX_UPLOAD_MAX_FILE_SIZE_MB = 100
|
export const MAX_UPLOAD_MAX_FILE_SIZE_MB = 100
|
||||||
|
export const DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 10
|
||||||
|
export const DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 500
|
||||||
|
export const DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB = 100
|
||||||
|
export const MAX_UPLOAD_IMAGE_MAX_FILE_SIZE_MB = 100
|
||||||
|
export const MAX_UPLOAD_VIDEO_MAX_FILE_SIZE_MB = 2048
|
||||||
|
export const MAX_UPLOAD_FILE_MAX_FILE_SIZE_MB = 1024
|
||||||
|
|
||||||
export const DEFAULT_UPLOAD_RATE_IP_MAX = 60
|
export const DEFAULT_UPLOAD_RATE_IP_MAX = 60
|
||||||
export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120
|
export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120
|
||||||
@@ -78,6 +87,41 @@ export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
export type PolicySource = 'env' | 'db' | 'default'
|
export type PolicySource = 'env' | 'db' | 'default'
|
||||||
|
export type UploadMaxFileSizeKind = 'image' | 'video' | 'file'
|
||||||
|
|
||||||
|
const uploadMaxFileSizeConfig: Record<UploadMaxFileSizeKind, {
|
||||||
|
setting: string
|
||||||
|
env: string
|
||||||
|
fallbackEnv?: string
|
||||||
|
legacySetting?: string
|
||||||
|
defaultMb: number
|
||||||
|
maxMb: number
|
||||||
|
label: string
|
||||||
|
}> = {
|
||||||
|
image: {
|
||||||
|
setting: SETTINGS_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
|
||||||
|
env: 'UPLOAD_IMAGE_MAX_FILE_SIZE_MB',
|
||||||
|
fallbackEnv: 'UPLOAD_MAX_FILE_SIZE_MB',
|
||||||
|
legacySetting: SETTINGS_UPLOAD_MAX_FILE_SIZE_MB,
|
||||||
|
defaultMb: DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
|
||||||
|
maxMb: MAX_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
|
||||||
|
label: '图片'
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
setting: SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
|
||||||
|
env: 'UPLOAD_VIDEO_MAX_FILE_SIZE_MB',
|
||||||
|
defaultMb: DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
|
||||||
|
maxMb: MAX_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
|
||||||
|
label: '视频'
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
setting: SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB,
|
||||||
|
env: 'UPLOAD_FILE_MAX_FILE_SIZE_MB',
|
||||||
|
defaultMb: DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB,
|
||||||
|
maxMb: MAX_UPLOAD_FILE_MAX_FILE_SIZE_MB,
|
||||||
|
label: '文件'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readEnv(name: string): string {
|
function readEnv(name: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +144,17 @@ export function parseUploadMaxFileSizeMb(raw: string): number | null {
|
|||||||
return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB)
|
return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseUploadMaxFileSizeMbForKind(
|
||||||
|
raw: string,
|
||||||
|
kind: UploadMaxFileSizeKind
|
||||||
|
): number | null {
|
||||||
|
return parsePositiveInt(
|
||||||
|
raw,
|
||||||
|
MIN_UPLOAD_MAX_FILE_SIZE_MB,
|
||||||
|
uploadMaxFileSizeConfig[kind].maxMb
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function parseUploadRateIpMax(raw: string): number | null {
|
export function parseUploadRateIpMax(raw: string): number | null {
|
||||||
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX)
|
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX)
|
||||||
}
|
}
|
||||||
@@ -208,9 +263,36 @@ export function serializeForbiddenFileExtensions(extensions: readonly string[]):
|
|||||||
.join(',')
|
.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadMaxFileSizeMbSource(): PolicySource {
|
function getConfiguredUploadMaxFileSizeMbFromDb(kind: UploadMaxFileSizeKind): number | null {
|
||||||
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
|
const config = uploadMaxFileSizeConfig[kind]
|
||||||
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
|
const direct = getSetting(config.setting)
|
||||||
|
if (direct !== null) {
|
||||||
|
const parsed = parseUploadMaxFileSizeMbForKind(direct, kind)
|
||||||
|
if (parsed !== null) return parsed
|
||||||
|
}
|
||||||
|
if (config.legacySetting) {
|
||||||
|
const legacy = getSetting(config.legacySetting)
|
||||||
|
if (legacy !== null) {
|
||||||
|
const parsed = parseUploadMaxFileSizeMbForKind(legacy, kind)
|
||||||
|
if (parsed !== null) return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredUploadMaxFileSizeMbFromEnv(kind: UploadMaxFileSizeKind): number | null {
|
||||||
|
const config = uploadMaxFileSizeConfig[kind]
|
||||||
|
const direct = parseUploadMaxFileSizeMbForKind(readEnv(config.env), kind)
|
||||||
|
if (direct !== null) return direct
|
||||||
|
if (config.fallbackEnv) {
|
||||||
|
return parseUploadMaxFileSizeMbForKind(readEnv(config.fallbackEnv), kind)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadMaxFileSizeMbSource(kind: UploadMaxFileSizeKind): PolicySource {
|
||||||
|
if (getConfiguredUploadMaxFileSizeMbFromDb(kind) !== null) return 'db'
|
||||||
|
if (getConfiguredUploadMaxFileSizeMbFromEnv(kind) !== null) return 'env'
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,23 +352,18 @@ function getPreserveOriginalUploadSource(): PolicySource {
|
|||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUploadMaxFileSizeMb(): number {
|
export function getUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||||
const dbRaw = getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB)
|
return getConfiguredUploadMaxFileSizeMbFromDb(kind)
|
||||||
if (dbRaw !== null) {
|
?? getConfiguredUploadMaxFileSizeMbFromEnv(kind)
|
||||||
const parsed = parseUploadMaxFileSizeMb(dbRaw)
|
?? uploadMaxFileSizeConfig[kind].defaultMb
|
||||||
if (parsed !== null) return parsed
|
|
||||||
}
|
|
||||||
const fromEnv = parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
|
|
||||||
if (fromEnv !== null) return fromEnv
|
|
||||||
return DEFAULT_UPLOAD_MAX_FILE_SIZE_MB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMaxFileSizeBytes(): number {
|
export function getMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||||
return getUploadMaxFileSizeMb() * 1024 * 1024
|
return getUploadMaxFileSizeMb(kind) * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEffectiveMaxFileSizeBytes(): number {
|
export function getEffectiveMaxFileSizeBytes(kind: UploadMaxFileSizeKind = 'image'): number {
|
||||||
const configured = getMaxFileSizeBytes()
|
const configured = getMaxFileSizeBytes(kind)
|
||||||
const row = getActiveBackendRow()
|
const row = getActiveBackendRow()
|
||||||
if (row?.type === 'telegram') {
|
if (row?.type === 'telegram') {
|
||||||
return Math.min(configured, TELEGRAM_MAX_FILE_BYTES)
|
return Math.min(configured, TELEGRAM_MAX_FILE_BYTES)
|
||||||
@@ -294,6 +371,10 @@ export function getEffectiveMaxFileSizeBytes(): number {
|
|||||||
return configured
|
return configured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEffectiveUploadMaxFileSizeMb(kind: UploadMaxFileSizeKind): number {
|
||||||
|
return Math.floor(getEffectiveMaxFileSizeBytes(kind) / (1024 * 1024))
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllowedMimeTypesList(): AllowedMimeType[] {
|
export function getAllowedMimeTypesList(): AllowedMimeType[] {
|
||||||
const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES)
|
const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES)
|
||||||
if (dbRaw !== null) {
|
if (dbRaw !== null) {
|
||||||
@@ -418,27 +499,48 @@ export function getLoginRateLimitSettings(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMaxFileSizeMessage(): string {
|
export function formatMaxFileSizeMessage(kind: UploadMaxFileSizeKind = 'image'): string {
|
||||||
return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB`
|
return `${uploadMaxFileSizeConfig[kind].label}大小不能超过 ${getUploadMaxFileSizeMb(kind)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEffectiveMaxFileSizeMessage(): string {
|
export function formatEffectiveMaxFileSizeMessage(
|
||||||
|
kind: UploadMaxFileSizeKind = 'image'
|
||||||
|
): string {
|
||||||
const row = getActiveBackendRow()
|
const row = getActiveBackendRow()
|
||||||
if (row?.type === 'telegram') {
|
if (row?.type === 'telegram') {
|
||||||
const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024)
|
const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024)
|
||||||
const siteMb = getUploadMaxFileSizeMb()
|
const siteMb = getUploadMaxFileSizeMb(kind)
|
||||||
if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) {
|
if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) {
|
||||||
return formatMaxFileSizeMessage()
|
return formatMaxFileSizeMessage(kind)
|
||||||
}
|
}
|
||||||
return `图片大小不能超过 ${mb} MB(Telegram 存储上限)`
|
return `${uploadMaxFileSizeConfig[kind].label}大小不能超过 ${mb} MB(Telegram 存储上限)`
|
||||||
}
|
}
|
||||||
return formatMaxFileSizeMessage()
|
return formatMaxFileSizeMessage(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUploadPolicySettingsPayload() {
|
export function getUploadPolicySettingsPayload() {
|
||||||
return {
|
return {
|
||||||
uploadMaxFileSizeMb: getUploadMaxFileSizeMb(),
|
uploadImageMaxFileSizeMb: getUploadMaxFileSizeMb('image'),
|
||||||
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource(),
|
uploadImageMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('image'),
|
||||||
|
uploadImageMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
|
||||||
|
readEnv('UPLOAD_IMAGE_MAX_FILE_SIZE_MB'),
|
||||||
|
'image'
|
||||||
|
) ?? parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
|
||||||
|
?? DEFAULT_UPLOAD_IMAGE_MAX_FILE_SIZE_MB,
|
||||||
|
uploadVideoMaxFileSizeMb: getUploadMaxFileSizeMb('video'),
|
||||||
|
uploadVideoMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('video'),
|
||||||
|
uploadVideoMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
|
||||||
|
readEnv('UPLOAD_VIDEO_MAX_FILE_SIZE_MB'),
|
||||||
|
'video'
|
||||||
|
) ?? DEFAULT_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
|
||||||
|
uploadFileMaxFileSizeMb: getUploadMaxFileSizeMb('file'),
|
||||||
|
uploadFileMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('file'),
|
||||||
|
uploadFileMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMbForKind(
|
||||||
|
readEnv('UPLOAD_FILE_MAX_FILE_SIZE_MB'),
|
||||||
|
'file'
|
||||||
|
) ?? DEFAULT_UPLOAD_FILE_MAX_FILE_SIZE_MB,
|
||||||
|
uploadMaxFileSizeMb: getUploadMaxFileSizeMb('image'),
|
||||||
|
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource('image'),
|
||||||
uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
|
uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
|
||||||
?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
|
?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
|
||||||
allowedMimeTypes: getAllowedMimeTypesList(),
|
allowedMimeTypes: getAllowedMimeTypesList(),
|
||||||
@@ -477,6 +579,9 @@ export function getUploadPolicySettingsPayload() {
|
|||||||
|
|
||||||
export function setUploadPolicySettings(input: {
|
export function setUploadPolicySettings(input: {
|
||||||
uploadMaxFileSizeMb?: number
|
uploadMaxFileSizeMb?: number
|
||||||
|
uploadImageMaxFileSizeMb?: number
|
||||||
|
uploadVideoMaxFileSizeMb?: number
|
||||||
|
uploadFileMaxFileSizeMb?: number
|
||||||
allowedMimeTypes?: readonly string[]
|
allowedMimeTypes?: readonly string[]
|
||||||
allowedVideoExtensions?: readonly string[]
|
allowedVideoExtensions?: readonly string[]
|
||||||
forbiddenFileExtensions?: readonly string[]
|
forbiddenFileExtensions?: readonly string[]
|
||||||
@@ -487,8 +592,21 @@ export function setUploadPolicySettings(input: {
|
|||||||
loginRateWindowMinutes?: number
|
loginRateWindowMinutes?: number
|
||||||
preserveOriginalUpload?: boolean
|
preserveOriginalUpload?: boolean
|
||||||
}): void {
|
}): void {
|
||||||
if (input.uploadMaxFileSizeMb !== undefined) {
|
const imageMaxFileSizeMb = input.uploadImageMaxFileSizeMb ?? input.uploadMaxFileSizeMb
|
||||||
setSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB, String(input.uploadMaxFileSizeMb))
|
if (imageMaxFileSizeMb !== undefined) {
|
||||||
|
setSetting(SETTINGS_UPLOAD_IMAGE_MAX_FILE_SIZE_MB, String(imageMaxFileSizeMb))
|
||||||
|
}
|
||||||
|
if (input.uploadVideoMaxFileSizeMb !== undefined) {
|
||||||
|
setSetting(
|
||||||
|
SETTINGS_UPLOAD_VIDEO_MAX_FILE_SIZE_MB,
|
||||||
|
String(input.uploadVideoMaxFileSizeMb)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (input.uploadFileMaxFileSizeMb !== undefined) {
|
||||||
|
setSetting(
|
||||||
|
SETTINGS_UPLOAD_FILE_MAX_FILE_SIZE_MB,
|
||||||
|
String(input.uploadFileMaxFileSizeMb)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (input.allowedMimeTypes !== undefined) {
|
if (input.allowedMimeTypes !== undefined) {
|
||||||
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
|
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
|
||||||
|
|||||||
Reference in new issue
Block a user