ImageBase64Converter.jsx 10.1 KB
Newer Older
1
// ImageBase64Converter.jsx
fisherdaddy's avatar
fisherdaddy committed
2
import React, { useState, useCallback, useRef } from 'react';
3
import { useTranslation } from '../js/i18n';
fisherdaddy's avatar
fisherdaddy committed
4 5 6 7 8 9 10 11 12
import SEO from './SEO';
import styled from 'styled-components';
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';

// 复用相同的样式组件
const Container = styled.div`
  min-height: 100vh;
  background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
fisherdaddy's avatar
fisherdaddy committed
13
  padding: 6rem 2rem 2rem;
fisherdaddy's avatar
fisherdaddy committed
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
  position: relative;
  
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: 
      linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
      linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
    background-size: 20px 20px;
    pointer-events: none;
  }
29 30
`;

fisherdaddy's avatar
fisherdaddy committed
31 32 33 34 35
const ContentWrapper = styled.div`
  max-width: 1400px;
  margin: 0 auto;
  position: relative;
  z-index: 1;
36 37
`;

fisherdaddy's avatar
fisherdaddy committed
38 39 40 41 42 43 44 45 46
const Title = styled.h2`
  font-size: 1.8rem;
  margin-bottom: 1.5rem;
  background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  font-weight: 700;
  letter-spacing: -0.02em;
  text-align: center;
fisherdaddy's avatar
fisherdaddy committed
47 48
`;

fisherdaddy's avatar
fisherdaddy committed
49
const FileInputWrapper = styled.label`
fisherdaddy's avatar
fisherdaddy committed
50 51
  position: relative;
  width: 100%;
fisherdaddy's avatar
fisherdaddy committed
52
  height: 200px;
fisherdaddy's avatar
fisherdaddy committed
53 54 55 56 57 58 59
  border: 2px dashed rgba(99, 102, 241, 0.2);
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.3s ease;
fisherdaddy's avatar
fisherdaddy committed
60 61
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(10px);
fisherdaddy's avatar
fisherdaddy committed
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77

  &:hover {
    border-color: rgba(99, 102, 241, 0.4);
    background: rgba(99, 102, 241, 0.05);
  }

  input {
    position: absolute;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
  }

  span {
    color: #6366F1;
fisherdaddy's avatar
fisherdaddy committed
78
    font-size: 1rem;
fisherdaddy's avatar
fisherdaddy committed
79 80 81
    display: flex;
    align-items: center;
    gap: 8px;
fisherdaddy's avatar
fisherdaddy committed
82
    pointer-events: none;
fisherdaddy's avatar
fisherdaddy committed
83
  }
84 85
`;

fisherdaddy's avatar
fisherdaddy committed
86
const ResultArea = styled.div`
87
  width: 100%;
fisherdaddy's avatar
fisherdaddy committed
88 89
  min-height: 200px;
  padding: 1rem;
fisherdaddy's avatar
fisherdaddy committed
90 91 92
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(99, 102, 241, 0.1);
fisherdaddy's avatar
fisherdaddy committed
93 94 95 96 97 98 99 100 101 102 103 104 105 106
  border-radius: 12px;
  position: relative;
  
  textarea {
    width: 100%;
    min-height: 180px;
    border: none;
    background: transparent;
    resize: vertical;
    outline: none;
    font-family: monospace;
    font-size: 0.875rem;
    line-height: 1.5;
  }
107 108
`;

fisherdaddy's avatar
fisherdaddy committed
109
const ActionButton = styled.button`
fisherdaddy's avatar
fisherdaddy committed
110 111 112
  display: inline-flex;
  align-items: center;
  gap: 1.5px;
fisherdaddy's avatar
fisherdaddy committed
113
  padding: 6px 12px;
fisherdaddy's avatar
fisherdaddy committed
114 115 116 117 118
  font-size: 0.875rem;
  font-weight: 500;
  border-radius: 0.5rem;
  transition: all 0.2s;
  border: none;
119 120
  cursor: pointer;

fisherdaddy's avatar
fisherdaddy committed
121 122 123 124 125 126 127 128 129 130 131 132 133
  ${props => props.variant === 'success' && `
    background-color: #DEF7EC;
    color: #03543F;
  `}

  ${props => !props.variant && `
    background-color: rgba(255, 255, 255, 0.5);
    color: #4B5563;
    &:hover {
      background-color: #EEF2FF;
      color: #4F46E5;
    }
  `}
134

fisherdaddy's avatar
fisherdaddy committed
135 136 137
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
138 139 140
  }
`;

fisherdaddy's avatar
fisherdaddy committed
141 142 143 144
const PreviewImage = styled.img`
  max-width: 100%;
  max-height: 300px;
  margin: 1rem 0;
145
  border-radius: 8px;
fisherdaddy's avatar
fisherdaddy committed
146
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
fisherdaddy's avatar
fisherdaddy committed
147
`;
148

fisherdaddy's avatar
fisherdaddy committed
149
const ImageDetails = styled.div`
fisherdaddy's avatar
fisherdaddy committed
150
  font-size: 0.875rem;
fisherdaddy's avatar
fisherdaddy committed
151
  color: #6B7280;
fisherdaddy's avatar
fisherdaddy committed
152
  margin-top: 0.5rem;
153 154 155 156
`;

function ImageBase64Converter() {
  const { t } = useTranslation();
fisherdaddy's avatar
fisherdaddy committed
157
  const isLoading = usePageLoading();
158
  const [base64String, setBase64String] = useState('');
fisherdaddy's avatar
fisherdaddy committed
159
  const [previewUrl, setPreviewUrl] = useState('');
160
  const [isCopied, setIsCopied] = useState(false);
fisherdaddy's avatar
fisherdaddy committed
161
  const [imageFile, setImageFile] = useState(null);
162
  const [error, setError] = useState('');
fisherdaddy's avatar
fisherdaddy committed
163 164 165 166 167 168 169 170 171
  const fileInputRef = useRef(null);

  // 清除所有状态
  const clearStates = () => {
    setBase64String('');
    setPreviewUrl('');
    setImageFile(null);
    setError('');
  };
172

fisherdaddy's avatar
fisherdaddy committed
173 174 175
  // 处理图片转 Base64
  const handleFileChange = (event) => {
    const file = event.target.files[0];
176 177 178
    if (file) {
      setImageFile(file);
      const reader = new FileReader();
fisherdaddy's avatar
fisherdaddy committed
179 180 181 182 183 184 185 186
      reader.onload = (e) => {
        const base64 = e.target.result;
        setBase64String(base64);
        setPreviewUrl(base64);
        setError('');
      };
      reader.onerror = () => {
        setError(t('tools.imageBase64Converter.readError'));
187 188 189
      };
      reader.readAsDataURL(file);
    }
fisherdaddy's avatar
fisherdaddy committed
190 191
    // 重置 input 的 value,这样同一个文件也能触发 change 事件
    event.target.value = '';
192 193
  };

fisherdaddy's avatar
fisherdaddy committed
194 195 196 197 198 199 200 201 202 203 204
  // 处理 Base64 转图片
  const handleBase64Input = (event) => {
    const input = event.target.value;
    setBase64String(input);
    setError('');

    if (!input) {
      setPreviewUrl('');
      setImageFile(null);
      return;
    }
205

fisherdaddy's avatar
fisherdaddy committed
206 207 208 209 210 211 212 213 214 215
    try {
      // 尝试验证和修复 base64 字符串
      let validBase64 = input.trim();
      
      // 如果不是以 data:image 开头,尝试添加
      if (!validBase64.startsWith('data:image')) {
        // 检查是否只包含 base64 字符
        const base64Regex = /^[A-Za-z0-9+/=]+$/;
        if (base64Regex.test(validBase64)) {
          validBase64 = `data:image/png;base64,${validBase64}`;
216 217 218
        }
      }

fisherdaddy's avatar
fisherdaddy committed
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
      // 创建一个新的图片对象来验证 base64 字符串
      const img = new Image();
      img.onload = () => {
        setPreviewUrl(validBase64);
        setError('');
      };
      img.onerror = () => {
        setPreviewUrl('');
        setError(t('tools.imageBase64Converter.invalidBase64'));
      };
      img.src = validBase64;
    } catch (err) {
      setPreviewUrl('');
      setError(t('tools.imageBase64Converter.invalidBase64'));
    }
234 235 236 237
  };

  // 下载图片
  const handleDownload = () => {
fisherdaddy's avatar
fisherdaddy committed
238 239
    if (!previewUrl) return;
    
240
    const link = document.createElement('a');
fisherdaddy's avatar
fisherdaddy committed
241 242
    link.href = previewUrl;
    link.download = imageFile ? imageFile.name : 'image.png';
243 244 245 246 247
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };

fisherdaddy's avatar
fisherdaddy committed
248 249 250 251 252 253 254
  const handleCopy = useCallback(() => {
    navigator.clipboard.writeText(base64String).then(() => {
      setIsCopied(true);
      setTimeout(() => setIsCopied(false), 2000);
    });
  }, [base64String]);

255 256
  return (
    <>
fisherdaddy's avatar
fisherdaddy committed
257
      {isLoading && <LoadingOverlay />}
258 259 260 261
      <SEO
        title={t('tools.imageBase64Converter.title')}
        description={t('tools.imageBase64Converter.description')}
      />
fisherdaddy's avatar
fisherdaddy committed
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307
      <Container>
        <ContentWrapper>
          <Title>{t('tools.imageBase64Converter.title')}</Title>
          
          <div className="flex flex-col gap-6">
            <div className="space-y-2">
              <div className="block text-sm font-medium text-gray-700">
                {t('tools.imageBase64Converter.imageToBase64')}
              </div>
              <FileInputWrapper>
                <input
                  type="file"
                  accept="image/*"
                  onChange={handleFileChange}
                />
                <span>
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                    <path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
                  </svg>
                  {t('tools.imageBase64Converter.dragOrClick')}
                </span>
              </FileInputWrapper>
            </div>

            <div className="space-y-2">
              <label className="block text-sm font-medium text-gray-700">
                {t('tools.imageBase64Converter.base64ToImage')}
              </label>
              <ResultArea>
                <textarea
                  value={base64String}
                  onChange={handleBase64Input}
                  placeholder={t('tools.imageBase64Converter.base64InputPlaceholder')}
                />
              </ResultArea>
              <div className="flex justify-end gap-2">
                {error && (
                  <div className="text-red-500 text-sm flex-1 pt-2">
                    {error}
                  </div>
                )}
                <ActionButton
                  onClick={handleCopy}
                  disabled={!base64String}
                  variant={isCopied ? 'success' : undefined}
                >
308
                  {isCopied ? (
fisherdaddy's avatar
fisherdaddy committed
309 310 311 312 313 314
                    <>
                      <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
                        <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
                      </svg>
                      {t('copied')}
                    </>
315
                  ) : (
fisherdaddy's avatar
fisherdaddy committed
316 317 318 319 320 321
                    <>
                      <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
                        <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                      </svg>
                      {t('copy')}
                    </>
322
                  )}
fisherdaddy's avatar
fisherdaddy committed
323
                </ActionButton>
fisherdaddy's avatar
fisherdaddy committed
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
              </div>
            </div>

            {previewUrl && (
              <div className="space-y-2">
                <div className="flex justify-between items-center">
                  <label className="block text-sm font-medium text-gray-700">
                    {t('tools.imageBase64Converter.preview')}
                  </label>
                  <ActionButton
                    onClick={handleDownload}
                  >
                    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
                      <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
                    </svg>
                    {t('tools.imageBase64Converter.download')}
                  </ActionButton>
                </div>
                <PreviewImage src={previewUrl} alt="Preview" />
fisherdaddy's avatar
fisherdaddy committed
343 344 345 346 347 348
                {imageFile && (
                  <ImageDetails>
                    {t('tools.imageBase64Converter.fileName')}: {imageFile.name}<br />
                    {t('tools.imageBase64Converter.fileSize')}: {(imageFile.size / 1024).toFixed(2)} KB
                  </ImageDetails>
                )}
fisherdaddy's avatar
fisherdaddy committed
349
              </div>
fisherdaddy's avatar
fisherdaddy committed
350
            )}
fisherdaddy's avatar
fisherdaddy committed
351 352 353
          </div>
        </ContentWrapper>
      </Container>
354 355 356 357 358
    </>
  );
}

export default ImageBase64Converter;