release: v1.1.0 — S3 multi-backend storage and admin UI
ci / ci (22, ubuntu-latest) (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
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (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
GitHub Release / release (push) Canceled after 0s
This commit is contained in:
1 parent
7c45a78572
commit
acc64751ef
46 files changed
+4005
-480
No files matched your search
+13
-22
@@ -16,44 +16,35 @@ const navItems = computed(() => {
|
||||
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
||||
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const accountMenuItems = computed<DropdownMenuItem[]>(() => {
|
||||
const items: DropdownMenuItem[] = [
|
||||
{
|
||||
label: t('nav.changePassword'),
|
||||
icon: 'i-lucide-key-round',
|
||||
to: '/account/password'
|
||||
}
|
||||
]
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push({
|
||||
label: t('nav.systemSettings'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/settings'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
||||
{
|
||||
label: t('nav.changePassword'),
|
||||
icon: 'i-lucide-key-round',
|
||||
to: '/account/password'
|
||||
},
|
||||
{
|
||||
label: t('nav.logout'),
|
||||
icon: 'i-lucide-log-out',
|
||||
onSelect() {
|
||||
void handleLogout()
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
}
|
||||
])
|
||||
|
||||
const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||
accountMenuItems.value
|
||||
])
|
||||
|
||||
function isNavActive(to: string) {
|
||||
if (to === '/settings') {
|
||||
return route.path === '/settings'
|
||||
}
|
||||
return route.path.startsWith(to)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { logout, isAdmin } = useAuth()
|
||||
const { logout } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const {
|
||||
@@ -154,25 +154,6 @@ function toggleOpen() {
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/settings"
|
||||
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-settings"
|
||||
class="size-4 opacity-70"
|
||||
/>
|
||||
{{ t('nav.systemSettings') }}
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-error transition-colors hover:bg-error/5"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageBackendItem } from '~/types/storage'
|
||||
|
||||
type CardAction = {
|
||||
key: string
|
||||
icon: string
|
||||
label: string
|
||||
shortLabel: string
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
backend: StorageBackendItem
|
||||
envOverride?: boolean
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
setDefault: []
|
||||
edit: []
|
||||
test: []
|
||||
toggleEnabled: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const typeIcon = computed(() =>
|
||||
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
)
|
||||
|
||||
const typeLabel = computed(() =>
|
||||
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
|
||||
)
|
||||
|
||||
const servingLabel = computed(() =>
|
||||
props.backend.servingMode === 'public'
|
||||
? t('storage.servingPublic')
|
||||
: t('storage.servingProxy')
|
||||
)
|
||||
|
||||
const bucketLine = computed(() => {
|
||||
if (props.backend.type !== 's3') return null
|
||||
return props.backend.config.bucket || t('storage.notConfigured')
|
||||
})
|
||||
|
||||
const actionsDisabled = computed(() => props.envOverride || props.busy)
|
||||
|
||||
const actions = computed<CardAction[]>(() => {
|
||||
const isLocal = props.backend.type === 'local'
|
||||
const items: CardAction[] = []
|
||||
|
||||
if (!props.backend.isDefault && props.backend.enabled) {
|
||||
items.push({
|
||||
key: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
label: t('storage.setDefault'),
|
||||
shortLabel: isLocal ? t('storage.setDefault') : t('storage.actionSetDefault'),
|
||||
onClick: () => emit('setDefault')
|
||||
})
|
||||
}
|
||||
|
||||
if (props.backend.type === 's3') {
|
||||
items.push({
|
||||
key: 'edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
label: t('common.edit'),
|
||||
shortLabel: t('common.edit'),
|
||||
onClick: () => emit('edit')
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'test',
|
||||
icon: 'i-lucide-plug-zap',
|
||||
label: t('storage.testConnection'),
|
||||
shortLabel: isLocal ? t('storage.testConnection') : t('storage.actionTest'),
|
||||
onClick: () => emit('test')
|
||||
})
|
||||
|
||||
if (props.backend.type !== 'local') {
|
||||
items.push({
|
||||
key: 'toggle',
|
||||
icon: props.backend.enabled ? 'i-lucide-pause' : 'i-lucide-play',
|
||||
label: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
|
||||
shortLabel: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
|
||||
disabled: props.backend.isDefault && props.backend.enabled,
|
||||
onClick: () => emit('toggleEnabled')
|
||||
})
|
||||
}
|
||||
|
||||
if (props.backend.type !== 'local' && !props.backend.isDefault) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
icon: 'i-lucide-trash-2',
|
||||
label: t('common.delete'),
|
||||
shortLabel: t('common.delete'),
|
||||
danger: true,
|
||||
onClick: () => emit('delete')
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const actionGridClass = computed(() => {
|
||||
switch (actions.value.length) {
|
||||
case 1: return 'grid-cols-1'
|
||||
case 2: return 'grid-cols-2'
|
||||
case 3: return 'grid-cols-3'
|
||||
case 4: return 'grid-cols-4'
|
||||
default: return 'grid-cols-5'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="flex h-full flex-col rounded-2xl border border-default bg-elevated p-4 shadow-sm"
|
||||
:class="{
|
||||
'ring-2 ring-primary/30': backend.isDefault,
|
||||
'opacity-75': !backend.enabled
|
||||
}"
|
||||
>
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 items-start gap-2.5">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
|
||||
>
|
||||
<UIcon
|
||||
:name="typeIcon"
|
||||
class="size-4.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-sm font-semibold">
|
||||
{{ backend.name }}
|
||||
</h3>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ typeLabel }}
|
||||
<template v-if="bucketLine">
|
||||
· {{ bucketLine }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
<UBadge
|
||||
v-if="backend.enabled"
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('storage.badgeEnabled') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-if="backend.isDefault"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('storage.badgeDefault') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-if="!backend.enabled"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('storage.badgeDisabled') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorageUsageBar
|
||||
:capacity="backend.capacity"
|
||||
:index-bytes="backend.usage.bytes"
|
||||
/>
|
||||
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
{{ t('storage.servingMode') }}: {{ servingLabel }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 border-t border-default pt-4">
|
||||
<div
|
||||
class="grid gap-1.5"
|
||||
:class="actionGridClass"
|
||||
>
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-1 rounded-lg border px-1 py-2 transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:class="action.danger
|
||||
? 'border-error/30 text-error hover:bg-error/5'
|
||||
: 'border-default text-muted hover:bg-muted/40 hover:text-highlighted'"
|
||||
:title="action.label"
|
||||
:aria-label="action.label"
|
||||
:disabled="actionsDisabled || action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<UIcon
|
||||
:name="action.icon"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="w-full truncate text-center text-[11px] leading-none">
|
||||
{{ action.shortLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
import type { QuotaUnit } from '~/composables/useFileSize'
|
||||
import type { StorageBackendItem } from '~/types/storage'
|
||||
|
||||
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
|
||||
|
||||
export interface StorageFormPayload {
|
||||
name: string
|
||||
config: {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
prefix?: string
|
||||
forcePathStyle: boolean
|
||||
}
|
||||
secrets: {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
servingMode: 'proxy' | 'public'
|
||||
publicUrl: string
|
||||
quotaValue: string
|
||||
quotaUnit: QuotaUnit
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
backend?: StorageBackendItem | null
|
||||
createMode?: boolean
|
||||
saving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: StorageFormPayload]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bytesToQuotaInput, QUOTA_UNITS } = useFileSize()
|
||||
|
||||
const name = ref('')
|
||||
const provider = ref<StorageProvider>('r2')
|
||||
const endpoint = ref('')
|
||||
const region = ref('auto')
|
||||
const bucket = ref('')
|
||||
const prefix = ref('')
|
||||
const forcePathStyle = ref(false)
|
||||
const accessKeyId = ref('')
|
||||
const secretAccessKey = ref('')
|
||||
const servingMode = ref<'proxy' | 'public'>('proxy')
|
||||
const publicUrl = ref('')
|
||||
const quotaValue = ref('')
|
||||
const quotaUnit = ref<QuotaUnit>('GB')
|
||||
|
||||
const quotaUnitOptions = computed(() =>
|
||||
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
|
||||
)
|
||||
|
||||
const providerOptions = computed(() => [
|
||||
{ label: t('storage.providerR2'), value: 'r2' as const },
|
||||
{ label: t('storage.providerCos'), value: 'cos' as const },
|
||||
{ label: t('storage.providerOss'), value: 'oss' as const },
|
||||
{ label: t('storage.providerAws'), value: 'aws' as const },
|
||||
{ label: t('storage.providerCustom'), value: 'custom' as const }
|
||||
])
|
||||
|
||||
const servingModeOptions = computed(() => [
|
||||
{ label: t('storage.servingProxy'), value: 'proxy' },
|
||||
{ label: t('storage.servingPublic'), value: 'public' }
|
||||
])
|
||||
|
||||
function providerLabel(value: StorageProvider): string {
|
||||
return providerOptions.value.find(option => option.value === value)?.label
|
||||
?? t('storage.backendCloud')
|
||||
}
|
||||
|
||||
function applyProviderTemplate(value: StorageProvider) {
|
||||
if (!props.createMode && props.backend) return
|
||||
if (props.createMode) {
|
||||
name.value = providerLabel(value)
|
||||
}
|
||||
switch (value) {
|
||||
case 'r2':
|
||||
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
|
||||
region.value = 'auto'
|
||||
forcePathStyle.value = false
|
||||
break
|
||||
case 'cos':
|
||||
endpoint.value = 'https://cos.{region}.myqcloud.com'
|
||||
region.value = 'ap-guangzhou'
|
||||
forcePathStyle.value = true
|
||||
break
|
||||
case 'oss':
|
||||
endpoint.value = 'https://oss-{region}.aliyuncs.com'
|
||||
region.value = 'cn-hangzhou'
|
||||
forcePathStyle.value = false
|
||||
break
|
||||
case 'aws':
|
||||
endpoint.value = 'https://s3.{region}.amazonaws.com'
|
||||
region.value = 'us-east-1'
|
||||
forcePathStyle.value = false
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function detectProvider(config: StorageBackendItem['config']): StorageProvider {
|
||||
const ep = config.endpoint ?? ''
|
||||
if (ep.includes('r2.cloudflarestorage.com')) return 'r2'
|
||||
if (ep.includes('myqcloud.com')) return 'cos'
|
||||
if (ep.includes('aliyuncs.com')) return 'oss'
|
||||
if (ep.includes('amazonaws.com')) return 'aws'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
function loadBackend(backend: StorageBackendItem | null | undefined) {
|
||||
if (!backend || backend.type !== 's3') {
|
||||
if (props.createMode) {
|
||||
name.value = ''
|
||||
provider.value = 'r2'
|
||||
applyProviderTemplate('r2')
|
||||
bucket.value = ''
|
||||
prefix.value = ''
|
||||
accessKeyId.value = ''
|
||||
secretAccessKey.value = ''
|
||||
servingMode.value = 'proxy'
|
||||
publicUrl.value = ''
|
||||
quotaValue.value = ''
|
||||
quotaUnit.value = 'GB'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
name.value = backend.name
|
||||
provider.value = detectProvider(backend.config)
|
||||
endpoint.value = backend.config.endpoint ?? ''
|
||||
region.value = backend.config.region ?? 'auto'
|
||||
bucket.value = backend.config.bucket ?? ''
|
||||
prefix.value = backend.config.prefix ?? ''
|
||||
forcePathStyle.value = backend.config.forcePathStyle ?? false
|
||||
servingMode.value = backend.servingMode
|
||||
publicUrl.value = backend.publicUrl
|
||||
const quota = bytesToQuotaInput(backend.quotaBytes)
|
||||
quotaValue.value = quota.value
|
||||
quotaUnit.value = quota.unit
|
||||
accessKeyId.value = ''
|
||||
secretAccessKey.value = ''
|
||||
}
|
||||
|
||||
watch(() => props.backend, loadBackend, { immediate: true })
|
||||
|
||||
watch(provider, (value) => {
|
||||
if (props.createMode) {
|
||||
applyProviderTemplate(value)
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit', {
|
||||
name: name.value.trim() || providerLabel(provider.value),
|
||||
config: {
|
||||
endpoint: endpoint.value.trim(),
|
||||
region: region.value.trim() || 'auto',
|
||||
bucket: bucket.value.trim(),
|
||||
prefix: prefix.value.trim() || undefined,
|
||||
forcePathStyle: forcePathStyle.value
|
||||
},
|
||||
secrets: {
|
||||
accessKeyId: accessKeyId.value.trim(),
|
||||
secretAccessKey: secretAccessKey.value.trim()
|
||||
},
|
||||
servingMode: servingMode.value,
|
||||
publicUrl: publicUrl.value.trim(),
|
||||
quotaValue: String(quotaValue.value ?? '').trim(),
|
||||
quotaUnit: quotaUnit.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.provider') }}</label>
|
||||
<USelect
|
||||
v-model="provider"
|
||||
:items="providerOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.backendName') }}</label>
|
||||
<UInput
|
||||
v-model="name"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.endpoint') }}</label>
|
||||
<UInput
|
||||
v-model="endpoint"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.region') }}</label>
|
||||
<UInput
|
||||
v-model="region"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.bucket') }}</label>
|
||||
<UInput
|
||||
v-model="bucket"
|
||||
class="w-full font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.prefix') }}</label>
|
||||
<UInput
|
||||
v-model="prefix"
|
||||
:placeholder="t('storage.prefixPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<UCheckbox v-model="forcePathStyle" />
|
||||
<span class="text-sm">{{ t('storage.forcePathStyle') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.accessKey') }}</label>
|
||||
<UInput
|
||||
v-model="accessKeyId"
|
||||
:placeholder="backend?.secretsMasked.accessKeyId || ''"
|
||||
class="w-full font-mono text-sm"
|
||||
:required="createMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.secretKey') }}</label>
|
||||
<UInput
|
||||
v-model="secretAccessKey"
|
||||
type="password"
|
||||
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
|
||||
class="w-full font-mono text-sm"
|
||||
:required="createMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.servingMode') }}</label>
|
||||
<URadioGroup
|
||||
v-model="servingMode"
|
||||
:items="servingModeOptions"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('storage.servingHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="servingMode === 'public'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
|
||||
<UInput
|
||||
v-model="publicUrl"
|
||||
:placeholder="t('storage.publicUrlPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">
|
||||
{{ t('storage.quotaTotal') }}
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<UInput
|
||||
:model-value="String(quotaValue)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
:placeholder="t('storage.quotaPlaceholder')"
|
||||
class="min-w-0 flex-1"
|
||||
@update:model-value="quotaValue = String($event ?? '')"
|
||||
/>
|
||||
<USelect
|
||||
v-model="quotaUnit"
|
||||
:items="quotaUnitOptions"
|
||||
class="w-24 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('storage.quotaHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<UButton
|
||||
type="button"
|
||||
:label="t('common.cancel')"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="emit('cancel')"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="t('common.save')"
|
||||
icon="i-lucide-save"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageCapacity } from '~/types/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
capacity: StorageCapacity
|
||||
indexBytes: number
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
|
||||
const displayUsedBytes = computed(() =>
|
||||
props.capacity.source === 'disk' ? props.capacity.usedBytes : props.indexBytes
|
||||
)
|
||||
|
||||
const hasBar = computed(() =>
|
||||
props.capacity.totalBytes != null && props.capacity.totalBytes > 0
|
||||
)
|
||||
|
||||
const displayPercent = computed(() => props.capacity.percent ?? 0)
|
||||
|
||||
const overQuota = computed(() =>
|
||||
props.capacity.source === 'quota' && displayPercent.value > 100
|
||||
)
|
||||
|
||||
const barWidth = computed(() => {
|
||||
if (!hasBar.value) return 0
|
||||
const percent = Math.min(displayPercent.value, 100)
|
||||
return Math.max(percent, displayUsedBytes.value > 0 ? 4 : 0)
|
||||
})
|
||||
|
||||
function formatOptional(bytes: number | null): string {
|
||||
if (bytes == null) return '—'
|
||||
return formatFileSize(bytes)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div class="rounded-lg bg-muted/30 px-2 py-2">
|
||||
<p class="text-muted">
|
||||
{{ $t('storage.statTotal') }}
|
||||
</p>
|
||||
<p class="mt-1 font-medium tabular-nums text-highlighted">
|
||||
{{ formatOptional(capacity.totalBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-muted/30 px-2 py-2">
|
||||
<p class="text-muted">
|
||||
{{ $t('storage.statUsed') }}
|
||||
</p>
|
||||
<p class="mt-1 font-medium tabular-nums text-highlighted">
|
||||
{{ formatFileSize(displayUsedBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-muted/30 px-2 py-2">
|
||||
<p class="text-muted">
|
||||
{{ $t('storage.statFree') }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-1 font-medium tabular-nums"
|
||||
:class="overQuota ? 'text-error' : 'text-highlighted'"
|
||||
>
|
||||
{{ formatOptional(capacity.freeBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasBar"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted">{{ $t('storage.usageRate') }}</span>
|
||||
<span
|
||||
class="tabular-nums font-medium"
|
||||
:class="overQuota ? 'text-error' : ''"
|
||||
>
|
||||
{{ displayPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="overQuota ? 'bg-error/80' : 'bg-primary/70'"
|
||||
:style="{ width: `${barWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,55 @@
|
||||
export function useFileSize() {
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
export type QuotaUnit = typeof QUOTA_UNITS[number]
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes === 0) return '0 B'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
let value = bytes / 1024
|
||||
let unitIndex = 0
|
||||
|
||||
while (unitIndex < units.length - 1 && value >= 1024) {
|
||||
value /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return { formatFileSize }
|
||||
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
|
||||
return `${value.toFixed(decimals)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
export function bytesToQuotaInput(bytes: number | null): { value: string, unit: QuotaUnit } {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return { value: '', unit: 'GB' }
|
||||
}
|
||||
|
||||
let value = bytes / 1024
|
||||
let unitIndex = 0
|
||||
|
||||
while (unitIndex < QUOTA_UNITS.length - 1 && value >= 1024) {
|
||||
value /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
|
||||
const rounded = Number(value.toFixed(decimals))
|
||||
const unit: QuotaUnit = QUOTA_UNITS[unitIndex] ?? 'GB'
|
||||
return { value: String(rounded), unit }
|
||||
}
|
||||
|
||||
export function quotaToBytes(value: string | number, unit: QuotaUnit): number | null {
|
||||
const trimmed = String(value ?? '').trim()
|
||||
if (!trimmed) return null
|
||||
const amount = Number(trimmed)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
|
||||
const unitIndex = QUOTA_UNITS.indexOf(unit)
|
||||
if (unitIndex < 0) return null
|
||||
|
||||
return Math.round(amount * 1024 ** (unitIndex + 1))
|
||||
}
|
||||
|
||||
export function useFileSize() {
|
||||
return { formatFileSize, bytesToQuotaInput, quotaToBytes, QUOTA_UNITS }
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageFormPayload } from '~/components/storage/StorageBackendForm.vue'
|
||||
import type { StorageBackendItem, StorageListResponse } from '~/types/storage'
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const busyId = ref<string | null>(null)
|
||||
const storageData = ref<StorageListResponse | null>(null)
|
||||
|
||||
const formOpen = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const editingBackend = ref<StorageBackendItem | null>(null)
|
||||
const formInstanceKey = ref('create')
|
||||
const savingForm = ref(false)
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deleteTarget = ref<StorageBackendItem | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadStorage() {
|
||||
loading.value = true
|
||||
try {
|
||||
storageData.value = await $fetch<StorageListResponse>('/api/admin/storage', {
|
||||
credentials: 'include'
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('storage.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const { quotaToBytes } = useFileSize()
|
||||
|
||||
function buildPatchBody(payload: StorageFormPayload) {
|
||||
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
|
||||
const body: Record<string, unknown> = {
|
||||
name: payload.name,
|
||||
servingMode: payload.servingMode,
|
||||
publicUrl: payload.publicUrl,
|
||||
quotaBytes,
|
||||
config: payload.config
|
||||
}
|
||||
if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
|
||||
body.secrets = payload.secrets
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
formMode.value = 'create'
|
||||
editingBackend.value = null
|
||||
formInstanceKey.value = `create-${Date.now()}`
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(backend: StorageBackendItem) {
|
||||
formMode.value = 'edit'
|
||||
editingBackend.value = backend
|
||||
formInstanceKey.value = backend.id
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function onFormSubmit(payload: StorageFormPayload) {
|
||||
if (!quotaToBytes(payload.quotaValue, payload.quotaUnit)) {
|
||||
toast.add({ title: t('storage.quotaRequired'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) {
|
||||
toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savingForm.value = true
|
||||
try {
|
||||
const body = buildPatchBody(payload)
|
||||
if (formMode.value === 'create') {
|
||||
await $fetch('/api/admin/storage', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body
|
||||
})
|
||||
toast.add({ title: t('storage.created'), color: 'success' })
|
||||
} else if (editingBackend.value) {
|
||||
await $fetch(`/api/admin/storage/${editingBackend.value.id}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body
|
||||
})
|
||||
toast.add({ title: t('storage.saved'), color: 'success' })
|
||||
}
|
||||
formOpen.value = false
|
||||
await loadStorage()
|
||||
} catch {
|
||||
toast.add({ title: t('storage.saveFailed'), color: 'error' })
|
||||
} finally {
|
||||
savingForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(backend: StorageBackendItem) {
|
||||
busyId.value = backend.id
|
||||
try {
|
||||
await $fetch(`/api/admin/storage/${backend.id}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { isDefault: true, enabled: true }
|
||||
})
|
||||
toast.add({ title: t('storage.defaultUpdated'), color: 'success' })
|
||||
await loadStorage()
|
||||
} catch {
|
||||
toast.add({ title: t('storage.saveFailed'), color: 'error' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function testBackend(backend: StorageBackendItem) {
|
||||
busyId.value = backend.id
|
||||
try {
|
||||
const result = await $fetch<{ ok: boolean, message: string }>(
|
||||
`/api/admin/storage/${backend.id}/test`,
|
||||
{ method: 'POST', credentials: 'include' }
|
||||
)
|
||||
toast.add({
|
||||
title: result.ok ? t('storage.testSuccess') : t('storage.testFailed'),
|
||||
description: result.message,
|
||||
color: result.ok ? 'success' : 'error'
|
||||
})
|
||||
} catch {
|
||||
toast.add({ title: t('storage.testFailed'), color: 'error' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(backend: StorageBackendItem) {
|
||||
busyId.value = backend.id
|
||||
try {
|
||||
await $fetch(`/api/admin/storage/${backend.id}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { enabled: !backend.enabled }
|
||||
})
|
||||
await loadStorage()
|
||||
} catch {
|
||||
toast.add({ title: t('storage.saveFailed'), color: 'error' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(backend: StorageBackendItem) {
|
||||
if (backend.usage.count > 0) {
|
||||
toast.add({ title: t('storage.deleteBlockedImages'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
deleteTarget.value = backend
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function executeDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await $fetch(`/api/admin/storage/${deleteTarget.value.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
toast.add({ title: t('storage.deleted'), color: 'success' })
|
||||
deleteOpen.value = false
|
||||
deleteTarget.value = null
|
||||
await loadStorage()
|
||||
} catch (error: unknown) {
|
||||
const message = typeof error === 'object' && error !== null && 'data' in error
|
||||
? String((error as { data?: { message?: string } }).data?.message ?? '')
|
||||
: ''
|
||||
toast.add({
|
||||
title: message || t('storage.deleteFailed'),
|
||||
color: 'error'
|
||||
})
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
if (!isAuthenticated.value || !isAdmin.value) {
|
||||
await navigateTo('/')
|
||||
return
|
||||
}
|
||||
await loadStorage()
|
||||
})
|
||||
|
||||
watch(isAuthenticated, async (authed) => {
|
||||
if (authed && isAdmin.value) {
|
||||
await loadStorage()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<div
|
||||
v-if="isChecking"
|
||||
class="flex min-h-screen items-center justify-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-8 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminLoginGate v-else-if="!isAuthenticated" />
|
||||
|
||||
<AppShell v-else-if="isAdmin">
|
||||
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-hard-drive"
|
||||
class="mt-0.5 size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-base font-semibold">
|
||||
{{ t('storage.pageTitle') }}
|
||||
</h1>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ t('storage.pageSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
:label="t('storage.addBackend')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
:disabled="storageData?.envOverride"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-5 sm:p-6">
|
||||
<UAlert
|
||||
v-if="storageData?.envOverride"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
class="mb-5"
|
||||
:title="t('storage.envOverride')"
|
||||
icon="i-lucide-triangle-alert"
|
||||
/>
|
||||
|
||||
<p class="mb-5 text-xs text-muted">
|
||||
{{ t('storage.switchHint') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex justify-center py-16"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-6 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="storageData"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<StorageBackendCard
|
||||
v-for="backend in storageData.backends"
|
||||
:key="backend.id"
|
||||
:backend="backend"
|
||||
:env-override="storageData.envOverride"
|
||||
:busy="busyId === backend.id"
|
||||
@set-default="setDefault(backend)"
|
||||
@edit="openEdit(backend)"
|
||||
@test="testBackend(backend)"
|
||||
@toggle-enabled="toggleEnabled(backend)"
|
||||
@delete="confirmDelete(backend)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
<UModal v-model:open="formOpen">
|
||||
<template #content>
|
||||
<div class="p-5 sm:p-6">
|
||||
<h2 class="mb-4 text-base font-semibold">
|
||||
{{ formMode === 'create' ? t('storage.addBackend') : t('storage.editBackend') }}
|
||||
</h2>
|
||||
<div class="max-h-[75vh] overflow-y-auto pr-1">
|
||||
<StorageBackendForm
|
||||
:key="formInstanceKey"
|
||||
:backend="editingBackend"
|
||||
:create-mode="formMode === 'create'"
|
||||
:saving="savingForm"
|
||||
@submit="onFormSubmit"
|
||||
@cancel="formOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="deleteOpen">
|
||||
<template #content>
|
||||
<div class="p-5 sm:p-6">
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ t('storage.deleteConfirmTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
{{ t('storage.deleteConfirmBody', { name: deleteTarget?.name ?? '' }) }}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="() => { deleteOpen = false }"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.delete')"
|
||||
color="error"
|
||||
:loading="deleting"
|
||||
@click="executeDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ServingMode = 'proxy' | 'public'
|
||||
|
||||
export interface StorageCapacity {
|
||||
totalBytes: number | null
|
||||
usedBytes: number
|
||||
freeBytes: number | null
|
||||
percent: number | null
|
||||
source: 'disk' | 'quota'
|
||||
}
|
||||
|
||||
export interface StorageBackendItem {
|
||||
id: string
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
config: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
prefix?: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
secretsMasked: {
|
||||
accessKeyId?: string
|
||||
secretAccessKey?: string
|
||||
}
|
||||
servingMode: ServingMode
|
||||
publicUrl: string
|
||||
quotaBytes: number | null
|
||||
enabled: boolean
|
||||
isDefault: boolean
|
||||
usage: { count: number, bytes: number }
|
||||
capacity: StorageCapacity
|
||||
}
|
||||
|
||||
export interface StorageListResponse {
|
||||
backends: StorageBackendItem[]
|
||||
activeBackendId: string
|
||||
envOverride: boolean
|
||||
}
|
||||
Reference in new issue
Block a user