用 Tailwind CSS 重写 StorageCleaner 页面,创建 shadcn/ui Dialog 组件

This commit is contained in:
雨霖铃
2026-05-22 00:00:04 +08:00
parent a4533be2c1
commit d2334c5e95
8 changed files with 245 additions and 180 deletions
+101
View File
@@ -0,0 +1,101 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+13 -13
View File
@@ -1,5 +1,3 @@
import { Box, Switch, Typography } from '@mui/material';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface AutoRefreshToggleProps { interface AutoRefreshToggleProps {
@@ -10,17 +8,19 @@ interface AutoRefreshToggleProps {
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) { export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
const { t } = useLazyTranslation('storageCleaner'); const { t } = useLazyTranslation('storageCleaner');
return ( return (
<Box sx={storageCleanerPageStyles.AUTO_REFRESH_CONTAINER}> <div className="mb-3 p-4 rounded-lg bg-white border border-gray-200 flex justify-between items-center shadow-sm transition-all hover:shadow-md">
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}> <span className="text-sm font-bold text-gray-700 px-3">
{t('storageCleaner:autoRefresh')} {t('storageCleaner:autoRefresh')}
</Typography> </span>
<Switch <label className="relative inline-flex items-center cursor-pointer">
size="small" <input
checked={autoRefresh} type="checkbox"
onChange={(e) => onChange(e.target.checked)} checked={autoRefresh}
color="warning" onChange={(e) => onChange(e.target.checked)}
sx={storageCleanerPageStyles.AUTO_REFRESH_SWITCH} className="sr-only peer"
/> />
</Box> <div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-amber-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500" />
</label>
</div>
); );
} }
+24 -11
View File
@@ -1,7 +1,6 @@
import { Alert, Box } from '@mui/material'; import { CheckCircle, XCircle } from 'lucide-react';
import type { CleaningResult } from '@/types/storage'; import type { CleaningResult } from '@/types/storage';
import { formatCleaningResult } from '@/utils/storageCleaner'; import { formatCleaningResult } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface CleaningResultProps { interface CleaningResultProps {
@@ -12,16 +11,30 @@ export default function CleaningResult({ result }: CleaningResultProps) {
const { t } = useLazyTranslation('storageCleaner'); const { t } = useLazyTranslation('storageCleaner');
if (!result) return null; if (!result) return null;
const isSuccess = result.success;
return ( return (
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}> <div className="mt-3 animate-in fade-in duration-300">
<Alert <div
severity={result.success ? 'success' : 'error'} className={`flex items-start gap-3 rounded-lg py-3 px-4 shadow-sm ${
sx={storageCleanerPageStyles.CLEANING_RESULT_ALERT} isSuccess ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
}`}
> >
{result.success {isSuccess ? (
? formatCleaningResult(result, t) <CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
: result.error || t('storageCleaner:partialFailure')} ) : (
</Alert> <XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
</Box> )}
<span
className={`text-sm font-semibold leading-relaxed ${
isSuccess ? 'text-green-700' : 'text-red-700'
}`}
>
{isSuccess
? formatCleaningResult(result, t)
: result.error || t('storageCleaner:partialFailure')}
</span>
</div>
</div>
); );
} }
+11 -32
View File
@@ -1,6 +1,4 @@
import { Box, Container, Typography } from '@mui/material'; import { AlertCircle } from 'lucide-react';
import WarningIcon from '@mui/icons-material/Warning';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ErrorDisplayProps { interface ErrorDisplayProps {
@@ -10,35 +8,16 @@ interface ErrorDisplayProps {
export default function ErrorDisplay({ error }: ErrorDisplayProps) { export default function ErrorDisplay({ error }: ErrorDisplayProps) {
const { t } = useLazyTranslation('storageCleaner'); const { t } = useLazyTranslation('storageCleaner');
return ( return (
<Container sx={storageCleanerPageStyles.ERROR_DISPLAY_CONTAINER}> <div className="py-8 flex justify-center items-center min-h-[200px] sm:min-h-[400px] text-center">
<Box sx={{ width: '100%', maxWidth: 320 }}> <div className="w-full max-w-[320px]">
<Box sx={storageCleanerPageStyles.ERROR_DISPLAY_BOX}> <div className="flex flex-col items-center justify-center rounded-lg p-6 shadow-lg border border-red-200 bg-red-50">
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} /> <AlertCircle className="h-9 w-9 text-red-500 mb-3" />
<Typography <p className="text-sm font-bold leading-relaxed mb-4 text-red-600">{error}</p>
variant="body1" <p className="text-xs font-medium leading-relaxed text-gray-500">
color="error.main"
sx={{
fontSize: '0.9rem',
fontWeight: 700,
lineHeight: 1.4,
mb: 3,
}}
>
{error}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: '0.75rem',
fontWeight: 500,
lineHeight: 1.4,
}}
>
{t('storageCleaner:errorStandardOnly')} {t('storageCleaner:errorStandardOnly')}
</Typography> </p>
</Box> </div>
</Box> </div>
</Container> </div>
); );
} }
+22 -19
View File
@@ -1,6 +1,4 @@
import { Box, Checkbox, Typography } from '@mui/material';
import { formatSize } from '@/utils/storageCleaner'; import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface OptionItemProps { interface OptionItemProps {
@@ -20,32 +18,37 @@ export default function OptionItem({
}: OptionItemProps) { }: OptionItemProps) {
const { t } = useLazyTranslation('storageCleaner'); const { t } = useLazyTranslation('storageCleaner');
return ( return (
<Box sx={storageCleanerPageStyles.OPTION_ITEM(checked)}> <div
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}> className={`flex justify-between items-center py-2 px-3 rounded-lg transition-all duration-200 ${
<Typography checked
variant="body2" ? 'bg-amber-50 border border-amber-200'
fontWeight={700} : 'bg-transparent border border-transparent hover:bg-gray-50 hover:shadow-sm'
sx={storageCleanerPageStyles.OPTION_ITEM_LABEL(checked)} }`}
>
<div className="flex-1 min-w-0 mr-3">
<span
className={`block text-xs leading-tight truncate transition-colors ${
checked ? 'text-amber-700 font-bold' : 'text-gray-900 font-bold'
}`}
> >
{t(labelKey)} {t(labelKey)}
</Typography> </span>
{size !== undefined && size > 0 ? ( {size !== undefined && size > 0 ? (
<Typography variant="caption" sx={storageCleanerPageStyles.OPTION_ITEM_SIZE}> <span className="block text-[10px] font-semibold text-gray-500 mt-0.5 opacity-80">
{isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)} {isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)}
</Typography> </span>
) : ( ) : (
<Typography variant="caption" sx={storageCleanerPageStyles.OPTION_ITEM_NO_DATA}> <span className="block text-[10px] font-medium text-gray-400 mt-0.5 italic">
{t('storageCleaner:noData')} {t('storageCleaner:noData')}
</Typography> </span>
)} )}
</Box> </div>
<Checkbox <input
size="small" type="checkbox"
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
color="warning" className="h-4 w-4 rounded border-gray-300 text-amber-500 focus:ring-amber-500"
sx={storageCleanerPageStyles.OPTION_ITEM_CHECKBOX}
/> />
</Box> </div>
); );
} }
+48 -66
View File
@@ -1,15 +1,13 @@
import { Button } from '@/components/ui/button';
import { import {
Box,
Chip,
Dialog, Dialog,
DialogActions,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
Typography, } from '@/components/ui/dialog';
} from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import Button from '@/components/Button';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
export interface StorageCleanerConfirmProps { export interface StorageCleanerConfirmProps {
@@ -32,69 +30,53 @@ export function StorageCleanerConfirm({
.map(([key, _]) => t(`storageCleaner:options.${key as keyof StorageCleanerOptions}`)); .map(([key, _]) => t(`storageCleaner:options.${key as keyof StorageCleanerOptions}`));
return ( return (
<Dialog <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
open={open} <DialogContent className="sm:max-w-[400px] p-6">
onClose={onClose} <DialogHeader>
fullWidth <DialogTitle className="text-center text-xl font-bold tracking-tight">
maxWidth="xs" {t('storageCleaner:confirmTitle')}
slotProps={{ </DialogTitle>
paper: { </DialogHeader>
sx: storageCleanerPageStyles.CONFIRM_DIALOG_PAPER,
},
}}
>
<DialogTitle sx={storageCleanerPageStyles.CONFIRM_DIALOG_TITLE}>
{t('storageCleaner:confirmTitle')}
</DialogTitle>
<DialogContent sx={storageCleanerPageStyles.CONFIRM_DIALOG_CONTENT}> <div className="text-center">
<Typography <DialogDescription className="mb-4 text-sm font-medium text-muted-foreground">
variant="body2" {t('storageCleaner:confirmDesc')}
color="text.secondary" </DialogDescription>
sx={storageCleanerPageStyles.CONFIRM_DIALOG_DESC}
>
{t('storageCleaner:confirmDesc')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.2, justifyContent: 'center', mb: 4 }}> <div className="flex flex-wrap gap-2 justify-center mb-6">
{selectedOptions.map((label) => ( {selectedOptions.map((label) => (
<Chip <span
key={label} key={label}
label={label} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-bold text-amber-700 bg-amber-50 border border-amber-200"
size="small" >
sx={storageCleanerPageStyles.CONFIRM_DIALOG_CHIP} {label}
/> </span>
))} ))}
</Box> </div>
<Box sx={storageCleanerPageStyles.CONFIRM_DIALOG_WARNING_BOX}> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 border border-dashed border-red-200">
<Typography variant="caption" sx={storageCleanerPageStyles.CONFIRM_DIALOG_WARNING_TEXT}> <span className="text-xs font-bold text-red-600 flex items-center gap-1">
<span role="img" aria-label="warning"> <span role="img" aria-label="warning">
</span>{' '} </span>{' '}
{t('storageCleaner:irreversible')} {t('storageCleaner:irreversible')}
</Typography> </span>
</Box> </div>
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-end sm:gap-3 pt-4">
<Button variant="outline" onClick={onClose} className="w-full sm:w-auto">
{t('common:buttons.cancel')}
</Button>
<Button
variant="default"
onClick={onConfirm}
className="w-full sm:w-auto bg-amber-500 hover:bg-amber-600 text-white"
>
{t('storageCleaner:confirmAction')}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 3, pt: 1, gap: 2 }}>
<Button
variant="text"
onClick={onClose}
fullWidth
sx={storageCleanerPageStyles.CONFIRM_DIALOG_CANCEL}
>
{t('common:buttons.cancel')}
</Button>
<Button
variant="contained"
onClick={onConfirm}
fullWidth
sx={storageCleanerPageStyles.CONFIRM_DIALOG_CONFIRM}
>
{t('storageCleaner:confirmAction')}
</Button>
</DialogActions>
</Dialog> </Dialog>
); );
} }
+16 -26
View File
@@ -1,7 +1,5 @@
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import OptionItem from './OptionItem'; import OptionItem from './OptionItem';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface StorageOptionsGridProps { interface StorageOptionsGridProps {
@@ -17,7 +15,7 @@ export default function StorageOptionsGrid({
options, options,
sizes, sizes,
allSelected, allSelected,
someSelected, someSelected: _someSelected,
onOptionChange, onOptionChange,
onSelectAll, onSelectAll,
}: StorageOptionsGridProps) { }: StorageOptionsGridProps) {
@@ -33,11 +31,11 @@ export default function StorageOptionsGrid({
]; ];
return ( return (
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_CONTAINER}> <div className="mb-3 border rounded-lg bg-white shadow-sm overflow-hidden transition-all hover:shadow-md">
<Box sx={{ p: 1.2 }}> <div className="p-3">
<Grid container spacing={1.5}> <div className="grid grid-cols-2 gap-3">
{optionKeys.map(({ key, isCount }) => ( {optionKeys.map(({ key, isCount }) => (
<Grid size={6} key={key}> <div key={key}>
<OptionItem <OptionItem
labelKey={`storageCleaner:options.${key}`} labelKey={`storageCleaner:options.${key}`}
checked={options[key]} checked={options[key]}
@@ -45,28 +43,20 @@ export default function StorageOptionsGrid({
isCount={isCount} isCount={isCount}
onChange={() => onOptionChange(key)} onChange={() => onOptionChange(key)}
/> />
</Grid> </div>
))} ))}
</Grid> </div>
</Box> </div>
<Divider sx={{ mx: 0, borderColor: 'divider' }} /> <div className="border-t" />
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_FOOTER}> <div className="flex justify-between items-center px-5 py-2 transition-colors hover:bg-gray-50 rounded-b-lg">
<Typography <span className="text-xs font-bold text-gray-500">{t('storageCleaner:selectAll')}</span>
variant="body2" <input
fontWeight={700} type="checkbox"
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
>
{t('storageCleaner:selectAll')}
</Typography>
<Checkbox
size="small"
checked={allSelected} checked={allSelected}
indeterminate={someSelected}
onChange={(e) => onSelectAll(e.target.checked)} onChange={(e) => onSelectAll(e.target.checked)}
color="warning" className="h-4 w-4 rounded border-gray-300 text-amber-500 focus:ring-amber-500"
sx={storageCleanerPageStyles.OPTIONS_GRID_CHECKBOX}
/> />
</Box> </div>
</Box> </div>
); );
} }
+10 -13
View File
@@ -1,8 +1,6 @@
import { Box, CircularProgress, Container } from '@mui/material'; import { Button } from '@/components/ui/button';
import Button from '@/components/Button';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm'; import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useStorageCleaner } from './useStorageCleaner'; import { useStorageCleaner } from './useStorageCleaner';
import DomainHeader from './DomainHeader'; import DomainHeader from './DomainHeader';
import StorageOptionsGrid from './StorageOptionsGrid'; import StorageOptionsGrid from './StorageOptionsGrid';
@@ -38,9 +36,9 @@ export default function Index() {
if (isInitializing) { if (isInitializing) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <div className="flex justify-center py-8">
<CircularProgress size={24} color="warning" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-amber-500 border-t-transparent" />
</Box> </div>
); );
} }
@@ -49,8 +47,8 @@ export default function Index() {
} }
return ( return (
<Box> <div>
<Container sx={{ py: 2 }}> <div className="p-2">
<DomainHeader domain={domain} totalSize={totalSize} /> <DomainHeader domain={domain} totalSize={totalSize} />
<StorageOptionsGrid <StorageOptionsGrid
@@ -65,17 +63,16 @@ export default function Index() {
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} /> <AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
<Button <Button
variant="contained" variant="default"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
sx={storageCleanerPageStyles.CONFIRM_DIALOG_CONFIRM}
disabled={isDisabled} disabled={isDisabled}
fullWidth className="w-full bg-amber-500 hover:bg-amber-600 text-white"
> >
{loading ? t('storageCleaner:cleaning') : t('storageCleaner:cleanNow')} {loading ? t('storageCleaner:cleaning') : t('storageCleaner:cleanNow')}
</Button> </Button>
<CleaningResult result={result} /> <CleaningResult result={result} />
</Container> </div>
<StorageCleanerConfirm <StorageCleanerConfirm
open={showConfirm} open={showConfirm}
@@ -83,6 +80,6 @@ export default function Index() {
onConfirm={handleClean} onConfirm={handleClean}
options={options} options={options}
/> />
</Box> </div>
); );
} }