mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
feat(admin): convert storage form dialog to right-side drawer (#276)
Replace the centered Dialog modal with a Sheet (right-side drawer) for the storage create/edit form. The drawer slides in from the right with a scrollable form body and pinned footer. Also fixes import ordering in the storages route. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -1,227 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { Storage } from '@shared/types'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { createStorage, updateStorage } from '@/lib/api'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
|
||||
type Unit = keyof typeof UNITS
|
||||
|
||||
function bytesToDisplay(bytes: number): { value: number; unit: Unit } {
|
||||
if (bytes === 0) return { value: 0, unit: 'GB' }
|
||||
if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' }
|
||||
if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' }
|
||||
return { value: bytes / UNITS.MB, unit: 'MB' }
|
||||
}
|
||||
|
||||
const storageFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
mode: z.enum(['private', 'public']),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string().min(1),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
customHost: z.string().optional(),
|
||||
capacityValue: z.coerce.number().min(0),
|
||||
capacityUnit: z.enum(['MB', 'GB', 'TB']),
|
||||
})
|
||||
|
||||
type StorageFormValues = z.infer<typeof storageFormSchema>
|
||||
|
||||
const DEFAULT_VALUES: StorageFormValues = {
|
||||
title: '',
|
||||
mode: 'private',
|
||||
bucket: '',
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
filePath: '$UID/$RAW_NAME',
|
||||
customHost: '',
|
||||
capacityValue: 0,
|
||||
capacityUnit: 'GB',
|
||||
}
|
||||
|
||||
interface StorageFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storage: Storage | null
|
||||
}
|
||||
|
||||
export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
const isEditing = storage !== null
|
||||
|
||||
const form = useForm<StorageFormValues>({
|
||||
resolver: zodResolver(storageFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (storage) {
|
||||
const { value, unit } = bytesToDisplay(storage.capacity ?? 0)
|
||||
form.reset({
|
||||
title: storage.title,
|
||||
mode: storage.mode,
|
||||
bucket: storage.bucket,
|
||||
endpoint: storage.endpoint,
|
||||
region: storage.region,
|
||||
accessKey: storage.accessKey,
|
||||
secretKey: storage.secretKey,
|
||||
filePath: storage.filePath,
|
||||
customHost: storage.customHost || '',
|
||||
capacityValue: value,
|
||||
capacityUnit: unit,
|
||||
})
|
||||
} else {
|
||||
form.reset(DEFAULT_VALUES)
|
||||
}
|
||||
setShowSecret(false)
|
||||
}, [open, storage, form])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => {
|
||||
const capacity = capacityValue * UNITS[capacityUnit]
|
||||
return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
onOpenChange(false)
|
||||
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: StorageFormValues) {
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<FormField label={t('admin.storages.fieldTitle')} error={form.formState.errors.title?.message}>
|
||||
<Input {...form.register('title')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldMode')} error={form.formState.errors.mode?.message}>
|
||||
<select
|
||||
{...form.register('mode')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="private">{t('admin.storages.modePrivate')}</option>
|
||||
<option value="public">{t('admin.storages.modePublic')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldBucket')} error={form.formState.errors.bucket?.message}>
|
||||
<Input {...form.register('bucket')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldEndpoint')} error={form.formState.errors.endpoint?.message}>
|
||||
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldRegion')} error={form.formState.errors.region?.message}>
|
||||
<Input {...form.register('region')} placeholder="auto" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldAccessKey')} error={form.formState.errors.accessKey?.message}>
|
||||
<Input {...form.register('accessKey')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldSecretKey')} error={form.formState.errors.secretKey?.message}>
|
||||
<div className="relative">
|
||||
<Input {...form.register('secretKey')} type={showSecret ? 'text' : 'password'} className="pr-10" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldFilePath')} error={form.formState.errors.filePath?.message}>
|
||||
<Input {...form.register('filePath')} placeholder="$UID/$RAW_NAME" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldCustomHost')} error={form.formState.errors.customHost?.message}>
|
||||
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldCapacity')} error={form.formState.errors.capacityValue?.message}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={0} step={1} className="w-32" {...form.register('capacityValue')} />
|
||||
<Select
|
||||
value={form.watch('capacityUnit')}
|
||||
onValueChange={(v) => form.setValue('capacityUnit', v as Unit)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MB">MB</SelectItem>
|
||||
<SelectItem value="GB">GB</SelectItem>
|
||||
<SelectItem value="TB">TB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{form.watch('capacityValue') > 0
|
||||
? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}`
|
||||
: t('admin.storages.capacityUnlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.storages.capacityHint')}</p>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { Storage } from '@shared/types'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { createStorage, updateStorage } from '@/lib/api'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
const UNITS = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
|
||||
type Unit = keyof typeof UNITS
|
||||
|
||||
function bytesToDisplay(bytes: number): { value: number; unit: Unit } {
|
||||
if (bytes === 0) return { value: 0, unit: 'GB' }
|
||||
if (bytes >= UNITS.TB && bytes % UNITS.TB === 0) return { value: bytes / UNITS.TB, unit: 'TB' }
|
||||
if (bytes >= UNITS.GB && bytes % UNITS.GB === 0) return { value: bytes / UNITS.GB, unit: 'GB' }
|
||||
return { value: bytes / UNITS.MB, unit: 'MB' }
|
||||
}
|
||||
|
||||
const storageFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
mode: z.enum(['private', 'public']),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string().min(1),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
customHost: z.string().optional(),
|
||||
capacityValue: z.coerce.number().min(0),
|
||||
capacityUnit: z.enum(['MB', 'GB', 'TB']),
|
||||
})
|
||||
|
||||
type StorageFormValues = z.infer<typeof storageFormSchema>
|
||||
|
||||
const DEFAULT_VALUES: StorageFormValues = {
|
||||
title: '',
|
||||
mode: 'private',
|
||||
bucket: '',
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
filePath: '$UID/$RAW_NAME',
|
||||
customHost: '',
|
||||
capacityValue: 0,
|
||||
capacityUnit: 'GB',
|
||||
}
|
||||
|
||||
interface StorageFormDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storage: Storage | null
|
||||
}
|
||||
|
||||
export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
const isEditing = storage !== null
|
||||
|
||||
const form = useForm<StorageFormValues>({
|
||||
resolver: zodResolver(storageFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (storage) {
|
||||
const { value, unit } = bytesToDisplay(storage.capacity ?? 0)
|
||||
form.reset({
|
||||
title: storage.title,
|
||||
mode: storage.mode,
|
||||
bucket: storage.bucket,
|
||||
endpoint: storage.endpoint,
|
||||
region: storage.region,
|
||||
accessKey: storage.accessKey,
|
||||
secretKey: storage.secretKey,
|
||||
filePath: storage.filePath,
|
||||
customHost: storage.customHost || '',
|
||||
capacityValue: value,
|
||||
capacityUnit: unit,
|
||||
})
|
||||
} else {
|
||||
form.reset(DEFAULT_VALUES)
|
||||
}
|
||||
setShowSecret(false)
|
||||
}, [open, storage, form])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => {
|
||||
const capacity = capacityValue * UNITS[capacityUnit]
|
||||
return isEditing ? updateStorage(storage.id, { ...rest, capacity }) : createStorage({ ...rest, capacity })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
onOpenChange(false)
|
||||
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: StorageFormValues) {
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEditing ? t('admin.storages.editTitle') : t('admin.storages.addTitle')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
<div className="grid gap-4">
|
||||
<FormField label={t('admin.storages.fieldTitle')} error={form.formState.errors.title?.message}>
|
||||
<Input {...form.register('title')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldMode')} error={form.formState.errors.mode?.message}>
|
||||
<select
|
||||
{...form.register('mode')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="private">{t('admin.storages.modePrivate')}</option>
|
||||
<option value="public">{t('admin.storages.modePublic')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldBucket')} error={form.formState.errors.bucket?.message}>
|
||||
<Input {...form.register('bucket')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldEndpoint')} error={form.formState.errors.endpoint?.message}>
|
||||
<Input {...form.register('endpoint')} placeholder="https://s3.amazonaws.com" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldRegion')} error={form.formState.errors.region?.message}>
|
||||
<Input {...form.register('region')} placeholder="auto" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldAccessKey')} error={form.formState.errors.accessKey?.message}>
|
||||
<Input {...form.register('accessKey')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldSecretKey')} error={form.formState.errors.secretKey?.message}>
|
||||
<div className="relative">
|
||||
<Input {...form.register('secretKey')} type={showSecret ? 'text' : 'password'} className="pr-10" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldFilePath')} error={form.formState.errors.filePath?.message}>
|
||||
<Input {...form.register('filePath')} placeholder="$UID/$RAW_NAME" />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldCustomHost')} error={form.formState.errors.customHost?.message}>
|
||||
<Input {...form.register('customHost')} placeholder={t('admin.storages.customHostPlaceholder')} />
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('admin.storages.fieldCapacity')} error={form.formState.errors.capacityValue?.message}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={0} step={1} className="w-32" {...form.register('capacityValue')} />
|
||||
<Select
|
||||
value={form.watch('capacityUnit')}
|
||||
onValueChange={(v) => form.setValue('capacityUnit', v as Unit)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MB">MB</SelectItem>
|
||||
<SelectItem value="GB">GB</SelectItem>
|
||||
<SelectItem value="TB">TB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{form.watch('capacityValue') > 0
|
||||
? `= ${formatSize(form.watch('capacityValue') * UNITS[form.watch('capacityUnit')])}`
|
||||
: t('admin.storages.capacityUnlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.storages.capacityHint')}</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({ label, error, children }: { label: string; error?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,9 @@ const ADMIN_STORAGES_KEYS = [
|
||||
'admin.storages.fieldFilePath',
|
||||
'admin.storages.fieldCustomHost',
|
||||
'admin.storages.customHostPlaceholder',
|
||||
'admin.storages.fieldCapacity',
|
||||
'admin.storages.capacityUnlimited',
|
||||
'admin.storages.capacityHint',
|
||||
]
|
||||
|
||||
const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users']
|
||||
@@ -173,6 +176,18 @@ describe('admin.storages locale keys — English values contract', () => {
|
||||
it('admin.nav.users is "Users"', () => {
|
||||
expect(enLocale['admin.nav.users']).toBe('Users')
|
||||
})
|
||||
|
||||
it('admin.storages.fieldCapacity is "Capacity"', () => {
|
||||
expect(enLocale['admin.storages.fieldCapacity']).toBe('Capacity')
|
||||
})
|
||||
|
||||
it('admin.storages.capacityUnlimited is "Unlimited"', () => {
|
||||
expect(enLocale['admin.storages.capacityUnlimited']).toBe('Unlimited')
|
||||
})
|
||||
|
||||
it('admin.storages.capacityHint is "Maximum storage space. 0 means unlimited."', () => {
|
||||
expect(enLocale['admin.storages.capacityHint']).toBe('Maximum storage space. 0 means unlimited.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin.storages locale keys — i18n runtime translation', () => {
|
||||
@@ -328,4 +343,40 @@ describe('admin.storages locale keys — i18n runtime translation', () => {
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.nav.management')).toBe('管理')
|
||||
})
|
||||
|
||||
it('translates admin.storages.fieldCapacity to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.fieldCapacity')).toBe('Capacity')
|
||||
})
|
||||
|
||||
it('translates admin.storages.fieldCapacity to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.fieldCapacity')).toBe('可用空间')
|
||||
})
|
||||
|
||||
it('translates admin.storages.capacityUnlimited to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.capacityUnlimited')).toBe('Unlimited')
|
||||
})
|
||||
|
||||
it('translates admin.storages.capacityUnlimited to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.capacityUnlimited')).toBe('不限制')
|
||||
})
|
||||
|
||||
it('translates admin.storages.capacityHint to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.storages.capacityHint')).toBe('Maximum storage space. 0 means unlimited.')
|
||||
})
|
||||
|
||||
it('translates admin.storages.capacityHint to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.capacityHint')).toBe('最大存储空间,0 表示不限制。')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatDate, formatSize } from './format'
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('returns "0 B" for 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('returns bytes with no decimal for values under 1 KB', () => {
|
||||
expect(formatSize(512)).toBe('512 B')
|
||||
})
|
||||
|
||||
it('returns KB with one decimal for kilobyte values', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB')
|
||||
})
|
||||
|
||||
it('returns MB with one decimal for megabyte values', () => {
|
||||
expect(formatSize(1024 * 1024)).toBe('1.0 MB')
|
||||
})
|
||||
|
||||
it('returns GB with one decimal for gigabyte values', () => {
|
||||
expect(formatSize(1024 * 1024 * 1024)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('returns TB with one decimal for terabyte values', () => {
|
||||
expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB')
|
||||
})
|
||||
|
||||
it('returns fractional KB for values between 1 KB and 1 MB', () => {
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('returns fractional GB for non-round gigabyte values', () => {
|
||||
expect(formatSize(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB')
|
||||
})
|
||||
|
||||
it('returns single byte without decimal', () => {
|
||||
expect(formatSize(1)).toBe('1 B')
|
||||
})
|
||||
|
||||
it('returns 10 GB correctly', () => {
|
||||
expect(formatSize(10 * 1024 * 1024 * 1024)).toBe('10.0 GB')
|
||||
})
|
||||
|
||||
it('returns 100 MB correctly', () => {
|
||||
expect(formatSize(100 * 1024 * 1024)).toBe('100.0 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns a locale date string for a valid ISO timestamp', () => {
|
||||
const result = formatDate('2024-01-15T00:00:00.000Z')
|
||||
expect(result).toBeTruthy()
|
||||
expect(result).not.toBe('—')
|
||||
})
|
||||
|
||||
it('returns "—" for an empty string', () => {
|
||||
expect(formatDate('')).toBe('—')
|
||||
})
|
||||
|
||||
it('returns "—" for a non-date string', () => {
|
||||
expect(formatDate('not-a-date')).toBe('—')
|
||||
})
|
||||
|
||||
it('returns "—" for a random invalid string', () => {
|
||||
expect(formatDate('foobar')).toBe('—')
|
||||
})
|
||||
|
||||
it('returns a non-empty string for a valid Unix epoch string', () => {
|
||||
const result = formatDate('2000-06-15')
|
||||
expect(result).toBeTruthy()
|
||||
expect(result).not.toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StorageStatus } from '@shared/constants'
|
||||
import type { Storage } from '@shared/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
@@ -5,7 +6,7 @@ import { Database, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog'
|
||||
import { StorageFormDialog } from '@/components/admin/storage-form-dialog'
|
||||
import { StorageFormDrawer } from '@/components/admin/storage-form-drawer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { listStorages } from '@/lib/api'
|
||||
|
||||
@@ -13,8 +14,6 @@ export const Route = createFileRoute('/_authenticated/admin/storages/')({
|
||||
component: StoragesPage,
|
||||
})
|
||||
|
||||
import { StorageStatus } from '@shared/constants'
|
||||
|
||||
function StoragesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
@@ -96,7 +95,7 @@ function StoragesPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<StorageFormDialog open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
|
||||
<StorageFormDrawer open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
|
||||
|
||||
<DeleteStorageDialog
|
||||
open={deleteTarget !== null}
|
||||
|
||||
Reference in New Issue
Block a user