用 Tailwind CSS 重写 Base64Converter 页面

This commit is contained in:
雨霖铃
2026-05-21 23:40:52 +08:00
parent 4753a7b54d
commit 8902bc0f10
4 changed files with 139 additions and 261 deletions
+57 -98
View File
@@ -1,18 +1,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { import { Upload, Trash2 } from 'lucide-react';
Alert,
alpha,
Box,
Button,
CircularProgress,
Paper,
Stack,
Typography,
} from '@mui/material';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import type { ToolbarAction } from '@/components/TextInputArea'; import type { ToolbarAction } from '@/components/TextInputArea';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '@/components/DecodeResultPaper';
@@ -183,34 +172,18 @@ export default function FileMode() {
{direction === 'encode' && ( {direction === 'encode' && (
<> <>
<Box <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
sx={{ className={`flex flex-col items-center justify-center min-h-[180px] border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all duration-200 ${
display: 'flex', isDragging
flexDirection: 'column', ? 'border-blue-500 bg-blue-50'
alignItems: 'center', : info
justifyContent: 'center', ? 'border-blue-500 bg-blue-50/50'
minHeight: 180, : 'border-gray-300 bg-gray-50 hover:border-blue-500 hover:bg-blue-50/50'
border: '2px dashed', }`}
borderColor: isDragging ? 'info.main' : info ? 'info.main' : 'divider',
borderRadius: 3,
p: 4,
bgcolor: (theme) =>
isDragging
? alpha(theme.palette.info.main, 0.08)
: info
? alpha(theme.palette.info.main, 0.04)
: 'action.hover',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: 'info.main',
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
},
}}
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -222,60 +195,47 @@ export default function FileMode() {
}} }}
/> />
{isLoading ? ( {isLoading ? (
<CircularProgress size={40} /> <div className="w-10 h-10 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
) : info ? ( ) : info ? (
<Stack spacing={1} alignItems="center"> <div className="flex flex-col items-center gap-1">
<UploadFileIcon sx={{ fontSize: 40, color: 'info.main' }} /> <Upload className="w-10 h-10 text-blue-600" />
<Typography variant="body2" fontWeight={700}> <span className="text-sm font-bold">{info.name}</span>
{info.name} <span className="text-xs text-gray-500">
</Typography>
<Typography variant="caption" color="text.secondary">
{formatFileSize(info.size)} · {info.type} {formatFileSize(info.size)} · {info.type}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">{t('clickOrDropToReplace')}</span>
{t('clickOrDropToReplace')} </div>
</Typography>
</Stack>
) : ( ) : (
<Stack spacing={1} alignItems="center"> <div className="flex flex-col items-center gap-1">
<UploadFileIcon sx={{ fontSize: 40, color: 'text.disabled' }} /> <Upload className="w-10 h-10 text-gray-400" />
<Typography variant="body2" color="text.secondary" fontWeight={600}> <span className="text-sm text-gray-500 font-semibold">
{t('clickOrDropToFile')} {t('clickOrDropToFile')}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })} {t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
</Typography> </span>
</Stack> </div>
)} )}
</Box> </div>
{error && <Alert severity="error">{error}</Alert>} {error && (
<div
role="alert"
className="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
>
{error}
</div>
)}
{result && ( {result && (
<Paper <div className="p-4 rounded-xl bg-blue-50 border border-blue-200">
elevation={0} <div className="flex justify-between items-center mb-2">
sx={{ <span className="text-xs font-bold text-gray-500">{t('base64Output')}</span>
p: 2, <div className="flex gap-1">
borderRadius: 3,
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
border: '1px solid',
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 1 }}
>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{t('base64Output')}
</Typography>
<Stack direction="row" spacing={1}>
<CopyButton text={result.rawBase64} tooltip={t('copyRaw')} /> <CopyButton text={result.rawBase64} tooltip={t('copyRaw')} />
<CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" /> <CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" />
</Stack> </div>
</Stack> </div>
<TextInputArea <TextInputArea
readOnly readOnly
value={ value={
@@ -286,36 +246,35 @@ export default function FileMode() {
showClear={false} showClear={false}
showCount showCount
/> />
<Stack direction="row" alignItems="center" spacing={2} sx={{ mt: 1 }}> <div className="flex items-center gap-4 mt-2">
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">
{t('originalSize')}: {formatFileSize(result.originalBytes)} {t('originalSize')}: {formatFileSize(result.originalBytes)}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">
{t('encodedSize')}: {formatFileSize(result.outputBytes)} {t('encodedSize')}: {formatFileSize(result.outputBytes)}
</Typography> </span>
<Box sx={{ flex: 1 }} /> <div className="flex-1" />
<Button <button
variant="text" type="button"
size="small"
onClick={handleClear} onClick={handleClear}
startIcon={<DeleteOutlineIcon />} className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded-md transition-colors"
sx={{ borderRadius: 2, minWidth: 0 }}
> >
<Trash2 className="w-3 h-3" />
{t('clear')} {t('clear')}
</Button> </button>
</Stack> </div>
</Paper> </div>
)} )}
{info && !result && ( {info && !result && (
<Button <button
variant="text" type="button"
onClick={handleClear} onClick={handleClear}
startIcon={<DeleteOutlineIcon />} className="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
sx={{ borderRadius: 3 }}
> >
<Trash2 className="w-4 h-4" />
{t('clear')} {t('clear')}
</Button> </button>
)} )}
</> </>
)} )}
+55 -121
View File
@@ -1,17 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { import { Image, Trash2 } from 'lucide-react';
Alert,
alpha,
Box,
Button,
CircularProgress,
Paper,
Stack,
Typography,
} from '@mui/material';
import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea';
import ImageIcon from '@mui/icons-material/Image';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '@/components/DecodeResultPaper';
@@ -189,34 +178,18 @@ export default function ImageMode() {
{direction === 'encode' && ( {direction === 'encode' && (
<> <>
<Box <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
sx={{ className={`flex flex-col items-center justify-center min-h-[180px] border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all duration-200 ${
display: 'flex', isDragging
flexDirection: 'column', ? 'border-blue-500 bg-blue-50'
alignItems: 'center', : info
justifyContent: 'center', ? 'border-blue-500 bg-blue-50/50'
minHeight: 180, : 'border-gray-300 bg-gray-50 hover:border-blue-500 hover:bg-blue-50/50'
border: '2px dashed', }`}
borderColor: isDragging ? 'info.main' : info ? 'info.main' : 'divider',
borderRadius: 3,
p: 4,
bgcolor: (theme) =>
isDragging
? alpha(theme.palette.info.main, 0.08)
: info
? alpha(theme.palette.info.main, 0.04)
: 'action.hover',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: 'info.main',
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
},
}}
> >
<input <input
ref={imageInputRef} ref={imageInputRef}
@@ -229,108 +202,76 @@ export default function ImageMode() {
}} }}
/> />
{isLoading ? ( {isLoading ? (
<CircularProgress size={40} /> <div className="w-10 h-10 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
) : info ? ( ) : info ? (
<Stack spacing={1} alignItems="center"> <div className="flex flex-col items-center gap-1">
{result && ( {result && (
<Box <img
component="img"
src={result.output} src={result.output}
alt="preview" alt="preview"
sx={{ className="max-w-full max-h-40 rounded-lg object-contain mb-2"
maxWidth: '100%',
maxHeight: 160,
borderRadius: 2,
objectFit: 'contain',
}}
/> />
)} )}
<Typography variant="body2" fontWeight={700}> <span className="text-sm font-bold">{info.name}</span>
{info.name} <span className="text-xs text-gray-500">
</Typography>
<Typography variant="caption" color="text.secondary">
{formatFileSize(info.size)} · {info.type} {formatFileSize(info.size)} · {info.type}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">{t('clickOrDropToReplace')}</span>
{t('clickOrDropToReplace')} </div>
</Typography>
</Stack>
) : ( ) : (
<Stack spacing={1} alignItems="center"> <div className="flex flex-col items-center gap-1">
<ImageIcon sx={{ fontSize: 40, color: 'text.disabled' }} /> <Image className="w-10 h-10 text-gray-400" />
<Typography variant="body2" color="text.secondary" fontWeight={600}> <span className="text-sm text-gray-500 font-semibold">
{t('clickOrDropToImage')} {t('clickOrDropToImage')}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">{t('supportedFormats')}</span>
{t('supportedFormats')} </div>
</Typography>
</Stack>
)} )}
</Box> </div>
{error && <Alert severity="error">{error}</Alert>} {error && (
<div
role="alert"
className="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
>
{error}
</div>
)}
{result && ( {result && (
<Paper <div className="p-4 rounded-xl bg-blue-50 border border-blue-200">
elevation={0} <div className="flex justify-between items-center mb-2">
sx={{ <span className="text-xs font-bold text-gray-500">{t('base64Output')}</span>
p: 2, <div className="flex gap-1">
borderRadius: 3,
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
border: '1px solid',
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 1 }}
>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{t('base64Output')}
</Typography>
<Stack direction="row" spacing={1}>
<CopyButton text={result.rawBase64} tooltip={t('copyRaw')} /> <CopyButton text={result.rawBase64} tooltip={t('copyRaw')} />
<CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" /> <CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" />
</Stack> </div>
</Stack> </div>
<Box <div className="font-mono text-xs break-all max-h-[200px] overflow-y-auto leading-relaxed text-blue-600 font-semibold">
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
wordBreak: 'break-all',
maxHeight: 200,
overflowY: 'auto',
lineHeight: 1.6,
color: 'info.main',
fontWeight: 600,
}}
>
{result.output.length > 2000 {result.output.length > 2000
? `${result.output.substring(0, 2000)}...` ? `${result.output.substring(0, 2000)}...`
: result.output} : result.output}
</Box> </div>
<Stack direction="row" spacing={2} sx={{ mt: 1 }}> <div className="flex gap-4 mt-2">
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">
{t('originalSize')}: {formatFileSize(result.originalBytes)} {t('originalSize')}: {formatFileSize(result.originalBytes)}
</Typography> </span>
<Typography variant="caption" color="text.disabled"> <span className="text-xs text-gray-400">
{t('encodedSize')}: {formatFileSize(result.outputBytes)} {t('encodedSize')}: {formatFileSize(result.outputBytes)}
</Typography> </span>
</Stack> </div>
</Paper> </div>
)} )}
{info && ( {info && (
<Button <button
variant="text" type="button"
onClick={handleClear} onClick={handleClear}
startIcon={<DeleteOutlineIcon />} className="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
sx={{ borderRadius: 3 }}
> >
<Trash2 className="w-4 h-4" />
{t('clear')} {t('clear')}
</Button> </button>
)} )}
</> </>
)} )}
@@ -361,17 +302,10 @@ export default function ImageMode() {
onFileNameChange={setDecodedFileName} onFileNameChange={setDecodedFileName}
onDownload={handleDownload} onDownload={handleDownload}
> >
<Box <img
component="img"
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`} src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
alt="decoded preview" alt="decoded preview"
sx={{ className="max-w-full max-h-40 rounded-lg object-contain mb-3"
maxWidth: '100%',
maxHeight: 160,
borderRadius: 2,
objectFit: 'contain',
mb: 1.5,
}}
/> />
</DecodeResultPaper> </DecodeResultPaper>
)} )}
+17 -29
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Alert, alpha, Button, Paper, Stack, Typography } from '@mui/material'; import { ArrowLeftRight } from 'lucide-react';
import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { textToBase64, base64ToText } from '@/utils/base64Converter'; import { textToBase64, base64ToText } from '@/utils/base64Converter';
@@ -71,7 +70,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
{ {
key: 'convert', key: 'convert',
label: actionLabel, label: actionLabel,
icon: <SwapHorizIcon />, icon: <ArrowLeftRight />,
type: 'primary', type: 'primary',
position: 'bottom', position: 'bottom',
disabled: (value: string) => !value.trim(), disabled: (value: string) => !value.trim(),
@@ -121,42 +120,31 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
/> />
{showImageHint && ( {showImageHint && (
<Alert <div className="flex items-center justify-between p-3 rounded-lg bg-blue-50 border border-blue-200">
severity="info" <span className="text-sm text-blue-700">{t('imageDataUriHint')}</span>
action={ <button
<Button color="info" size="small" onClick={onSwitchToImageMode}> type="button"
{t('switchToImageMode')} onClick={onSwitchToImageMode}
</Button> className="px-3 py-1 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-md transition-colors"
} >
> {t('switchToImageMode')}
{t('imageDataUriHint')} </button>
</Alert> </div>
)} )}
{output && ( {output && (
<Paper <div className="p-4 rounded-xl bg-blue-50 border border-blue-200">
elevation={0} <div className="flex justify-between items-center mb-2">
sx={{ <span className="text-xs font-bold text-gray-500">{outputLabel}</span>
p: 2,
borderRadius: 3,
bgcolor: (theme) => alpha(theme.palette.info.main, 0.04),
border: '1px solid',
borderColor: (theme) => alpha(theme.palette.info.main, 0.15),
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{outputLabel}
</Typography>
<CopyButton text={output} /> <CopyButton text={output} />
</Stack> </div>
<TextInputArea <TextInputArea
readOnly readOnly
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output} value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
showClear={false} showClear={false}
showCount showCount
/> />
</Paper> </div>
)} )}
</> </>
); );
+10 -13
View File
@@ -1,7 +1,4 @@
import { Box, Container, Stack } from '@mui/material'; import { Type, Upload, Image } from 'lucide-react';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import ImageIcon from '@mui/icons-material/Image';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { base64ConverterPageStyles } from '@/config/pageTheme'; import { base64ConverterPageStyles } from '@/config/pageTheme';
@@ -28,14 +25,14 @@ export default function Index() {
); );
const modeIcon: Record<PageMode, React.ReactNode> = { const modeIcon: Record<PageMode, React.ReactNode> = {
text: <TextFieldsIcon />, text: <Type />,
file: <UploadFileIcon />, file: <Upload />,
image: <ImageIcon />, image: <Image />,
}; };
return ( return (
<Box> <div>
<Container sx={{ p: 2 }}> <div className="p-2">
<PageHeader <PageHeader
title={t('base64Converter:pageTitle')} title={t('base64Converter:pageTitle')}
subtitle={t('base64Converter:pageSubtitle')} subtitle={t('base64Converter:pageSubtitle')}
@@ -43,7 +40,7 @@ export default function Index() {
iconColor={base64ConverterPageStyles.primaryColor} iconColor={base64ConverterPageStyles.primaryColor}
/> />
<Stack spacing={2.5}> <div className="flex flex-col gap-6">
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
@@ -58,8 +55,8 @@ export default function Index() {
{pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />} {pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />}
{pageMode === 'file' && <FileMode />} {pageMode === 'file' && <FileMode />}
{pageMode === 'image' && <ImageMode />} {pageMode === 'image' && <ImageMode />}
</Stack> </div>
</Container> </div>
</Box> </div>
); );
} }