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>
|
||||
Reference in new issue
Block a user