release: v1.4.1 — 上传策略设置、保留原图与上传 tagNames
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

管理员可在设置页配置上传大小、格式、限流与保留原图;上传 API 支持按标签名称打标。
This commit is contained in:
O96u committed 2026-09-16 10:03:05 +08:00
1 parent d0eb364645
commit 6cc4f27f7d
25 files changed
+1354 -172

No files matched your search

@@ -0,0 +1,329 @@
<script setup lang="ts">
type PolicySource = 'env' | 'db' | 'default'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
uploadRateIpMaxSource?: PolicySource
uploadRateTokenMaxSource?: PolicySource
uploadRateWindowMinutesSource?: PolicySource
loginRateMaxSource?: PolicySource
loginRateWindowMinutesSource?: PolicySource
preserveOriginalUploadSource?: PolicySource
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
}>()
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
const loginRateMaxDraft = defineModel<number>('loginRateMax')
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
const { t } = useI18n()
function sourceBadge(source: PolicySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
default:
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
}
}
function toggleMime(mime: MimeType, checked: boolean) {
if (!allowedMimeTypesDraft.value) return
if (checked) {
if (!allowedMimeTypesDraft.value.includes(mime)) {
allowedMimeTypesDraft.value = [...allowedMimeTypesDraft.value, mime]
}
return
}
if (allowedMimeTypesDraft.value.length <= 1) return
allowedMimeTypesDraft.value = allowedMimeTypesDraft.value.filter(item => item !== mime)
}
function isMimeChecked(mime: MimeType): boolean {
return allowedMimeTypesDraft.value?.includes(mime) ?? false
}
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)))
}
function onUploadMaxFileSizeInput(event: Event) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
(event.target as HTMLInputElement).value
)
}
function onUploadMaxFileSizeNumberInput(value: string | number) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(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="mt-3 flex items-center gap-3"
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<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"
size="xs"
class="w-14 tabular-nums"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div>
</div>
<div class="py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedMimeTypes') }}
</p>
<UBadge
v-if="allowedMimeTypesSource"
:color="sourceBadge(allowedMimeTypesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedMimeTypesSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedMimeTypesHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedMimeTypesSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in mimeTypeOptions ?? []"
:key="option.mime"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isMimeChecked(option.mime)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
:title="option.mime"
>
<UCheckbox
:model-value="isMimeChecked(option.mime)"
@update:model-value="(v) => toggleMime(option.mime, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadRateGroup') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadRateFootnote') }}
</p>
<div class="mt-3 grid gap-4 sm:grid-cols-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateIpMax') }}
</p>
<UBadge
v-if="uploadRateIpMaxSource"
:color="sourceBadge(uploadRateIpMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateIpMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateIpMaxDraft"
type="number"
:min="1"
:max="1000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateIpMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateTokenMax') }}
</p>
<UBadge
v-if="uploadRateTokenMaxSource"
:color="sourceBadge(uploadRateTokenMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateTokenMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateTokenMaxDraft"
type="number"
:min="1"
:max="5000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateTokenMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateWindowMinutes') }}
</p>
<UBadge
v-if="uploadRateWindowMinutesSource"
:color="sourceBadge(uploadRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="uploadRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="uploadRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
</div>
</template>
<SettingsToggleRow
v-else-if="part === 'image-processing'"
v-model="preserveOriginalUploadDraft!"
:disabled="preserveOriginalUploadSource === 'env'"
>
<template #title>
<span class="inline-flex flex-wrap items-center gap-2">
{{ t('settings.preserveOriginalUpload') }}
<UBadge
v-if="preserveOriginalUploadSource"
:color="sourceBadge(preserveOriginalUploadSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(preserveOriginalUploadSource).label }}
</UBadge>
</span>
</template>
<template #hint>
{{ t('settings.preserveOriginalUploadHint') }}
</template>
</SettingsToggleRow>
<template v-else-if="part === 'login-rate'">
<div class="grid gap-4 py-4 sm:grid-cols-2">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateMax') }}
</p>
<UBadge
v-if="loginRateMaxSource"
:color="sourceBadge(loginRateMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="loginRateMaxDraft"
type="number"
:min="1"
:max="100"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="loginRateMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateWindowMinutes') }}
</p>
<UBadge
v-if="loginRateWindowMinutesSource"
:color="sourceBadge(loginRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="loginRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="loginRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
<p class="pb-4 text-xs leading-relaxed text-muted">
{{ t('settings.loginRateFootnote') }}
</p>
</template>
</template>
@@ -1,4 +1,8 @@
<script setup lang="ts">
defineProps<{
part: 'upload-preference' | 'image-processing'
}>()
const {
compressEnabled,
autoCopyMarkdown,
@@ -44,111 +48,107 @@ onMounted(() => {
</script>
<template>
<SettingsSection :title="t('preferences.processing')">
<SettingsGroup>
<SettingsToggleRow
v-model="compressEnabled"
:title="t('preferences.clientCompress')"
:hint="t('preferences.clientCompressHint')"
/>
<div
v-if="userSettings"
class="flex items-start justify-between gap-4 py-4"
>
<div class="min-w-0 flex-1 pr-2">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.autoDeleteHint') }}
</p>
</div>
<USwitch
v-model="userAutoDeleteEnabled"
class="mt-0.5 shrink-0"
/>
</div>
<div
v-else
class="flex justify-center py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
<div class="py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('preferences.webpQuality') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
@input="onClientQualityInput"
>
<template v-if="part === 'image-processing'">
<SettingsToggleRow
v-model="compressEnabled"
:title="t('preferences.clientCompress')"
:hint="t('preferences.clientCompressHint')"
/>
<div
v-if="userSettings"
class="flex items-start justify-between gap-4 py-4"
>
<div class="min-w-0 flex-1 pr-2">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
<span>{{ t('preferences.autoDeletePrefix') }}</span>
<UInput
:model-value="clientWebpQuality"
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="100"
max="3650"
size="xs"
class="w-14 shrink-0 tabular-nums"
@update:model-value="onClientQualityNumberInput"
class="w-14 tabular-nums"
:class="userAutoDeleteEnabled ? '' : 'pointer-events-none opacity-40'"
/>
</div>
</div>
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('preferences.title')">
<SettingsGroup>
<SettingsToggleRow
v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')"
:hint="t('preferences.autoCopyHint')"
/>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.defaultCopyFormat') }}
<span>{{ t('preferences.autoDeleteSuffix') }}</span>
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.defaultCopyFormatHint') }}
{{ t('preferences.autoDeleteHint') }}
</p>
<div
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</SettingsGroup>
</SettingsSection>
<USwitch
v-model="userAutoDeleteEnabled"
class="mt-0.5 shrink-0"
/>
</div>
<div
v-else
class="flex justify-center py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
<div class="py-4">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ t('preferences.webpQuality') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('preferences.webpQualityHint') }}
</p>
</div>
<div class="mt-3 flex items-center gap-3">
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
@input="onClientQualityInput"
>
<UInput
:model-value="clientWebpQuality"
type="number"
min="1"
max="100"
size="xs"
class="w-14 shrink-0 tabular-nums"
@update:model-value="onClientQualityNumberInput"
/>
</div>
</div>
</template>
<template v-else>
<SettingsToggleRow
v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')"
:hint="t('preferences.autoCopyHint')"
/>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.defaultCopyFormat') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.defaultCopyFormatHint') }}
</p>
<div
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</template>
</template>
+16 -1
View File
@@ -73,11 +73,26 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.token'
},
{
name: 'tagIds',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagIds'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
}
],
curl: `curl -X POST "${baseUrl}/api/images/upload" \\
${auth} \\
-F "image=@./demo.png"`,
-F "image=@./demo.png" \\
-F 'tagNames=["博客","截图"]'`,
responseExample: `{
"success": true,
"items": [
+114 -2
View File
@@ -3,10 +3,13 @@ import type { SettingsTab } from '~/types/settings'
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
import { willActivateDomainSeparation } from '~/utils/domain-separation'
import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
type PolicySource = 'env' | 'db' | 'default'
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
interface SettingsResponse {
apiUploadToken: string
@@ -42,6 +45,23 @@ interface SettingsResponse {
capApiEndpoint: string
capSecret: string
appVersion: string
uploadMaxFileSizeMb: number
uploadMaxFileSizeMbSource: PolicySource
allowedMimeTypes: MimeType[]
allowedMimeTypesSource: PolicySource
uploadRateIpMax: number
uploadRateIpMaxSource: PolicySource
uploadRateTokenMax: number
uploadRateTokenMaxSource: PolicySource
uploadRateWindowMinutes: number
uploadRateWindowMinutesSource: PolicySource
loginRateMax: number
loginRateMaxSource: PolicySource
loginRateWindowMinutes: number
loginRateWindowMinutesSource: PolicySource
preserveOriginalUpload: boolean
preserveOriginalUploadSource: PolicySource
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
@@ -143,6 +163,15 @@ const disableDomainSeparationOpen = ref(false)
const pendingDomainSeparationDisable = ref(false)
const domainSeparationSaveConfirmOpen = ref(false)
const uploadMaxFileSizeMbDraft = ref(10)
const allowedMimeTypesDraft = ref<MimeType[]>([])
const uploadRateIpMaxDraft = ref(60)
const uploadRateTokenMaxDraft = ref(120)
const uploadRateWindowMinutesDraft = ref(15)
const loginRateMaxDraft = ref(10)
const loginRateWindowMinutesDraft = ref(15)
const preserveOriginalUploadDraft = ref(false)
interface ReleaseCheckResponse {
currentVersion: string
latestVersion: string | null
@@ -152,6 +181,10 @@ interface ReleaseCheckResponse {
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
function mimeListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.slice().sort().join(',') === right.slice().sort().join(',')
}
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
@@ -166,6 +199,14 @@ const hasServerChanges = computed(() => {
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|| capSecretDraft.value !== settings.value.capSecret
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
})
const domainSeparationWouldActivate = computed(() =>
@@ -221,6 +262,14 @@ function applySettings(data: SettingsResponse) {
capApiEndpointDraft.value = data.capApiEndpoint
capSecretDraft.value = data.capSecret
domainSeparationDraft.value = data.domainSeparation
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
uploadRateIpMaxDraft.value = data.uploadRateIpMax
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
loginRateMaxDraft.value = data.loginRateMax
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
}
async function patchSettings(body: Record<string, unknown>) {
@@ -296,6 +345,11 @@ async function performSaveServerSettings() {
savingServer.value = true
try {
if (allowedMimeTypesDraft.value.length === 0) {
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
return
}
await patchSettings({
allowedRefererHosts: refererDraft.value,
domainSeparation: domainSeparationDraft.value,
@@ -308,7 +362,15 @@ async function performSaveServerSettings() {
turnstileSiteKey: turnstileSiteKeyDraft.value,
turnstileSecretKey: turnstileSecretKeyDraft.value,
capApiEndpoint: capApiEndpointDraft.value,
capSecret: capSecretDraft.value
capSecret: capSecretDraft.value,
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
allowedMimeTypes: allowedMimeTypesDraft.value,
uploadRateIpMax: uploadRateIpMaxDraft.value,
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
loginRateMax: loginRateMaxDraft.value,
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
preserveOriginalUpload: preserveOriginalUploadDraft.value
})
toast.add({ title: t('settings.saved'), color: 'success' })
} catch (error: unknown) {
@@ -492,7 +554,42 @@ watch(isAdmin, () => {
</SettingsGroup>
</SettingsSection>
<SettingsUserPreferencesFields />
<SettingsSection
:title="t('preferences.title')"
:hint="t('settings.uploadLimitsHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
v-model:allowed-mime-types="allowedMimeTypesDraft"
v-model:upload-rate-ip-max="uploadRateIpMaxDraft"
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"
:allowed-mime-types-source="settings.allowedMimeTypesSource"
:upload-rate-ip-max-source="settings.uploadRateIpMaxSource"
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
:mime-type-options="settings.mimeTypeOptions"
/>
<SettingsUserPreferencesFields part="upload-preference" />
</SettingsGroup>
</SettingsSection>
<SettingsSection
:title="t('preferences.processing')"
:hint="t('settings.imageProcessingHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:preserve-original-upload="preserveOriginalUploadDraft"
part="image-processing"
:preserve-original-upload-source="settings.preserveOriginalUploadSource"
/>
<SettingsUserPreferencesFields part="image-processing" />
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('settings.systemInfo')">
<SettingsGroup>
@@ -897,6 +994,21 @@ watch(isAdmin, () => {
</div>
</SettingsGroup>
</SettingsSection>
<SettingsSection
:title="t('settings.rateLimits')"
:hint="t('settings.loginRateLimitsHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:login-rate-max="loginRateMaxDraft"
v-model:login-rate-window-minutes="loginRateWindowMinutesDraft"
part="login-rate"
:login-rate-max-source="settings.loginRateMaxSource"
:login-rate-window-minutes-source="settings.loginRateWindowMinutesSource"
/>
</SettingsGroup>
</SettingsSection>
</SettingsPanel>
<div
v-else