f9821553b3
* fix(ci): fix release workflow artifact upload issue - Split artifact upload into 3 separate steps (Chrome, Firefox, Source) - Update download steps to match new artifact names - Add if-no-files-found: error for better error handling - Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern * docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 * docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 * fix: remove unnecessary animations from StorageCleaner page - Remove entrance animations (animate-in, fade-in, zoom-in) from all components - Remove scale effect (active:scale-[0.99]) from clean button - Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle - Remove bouncing animation from error icon - Remove pulsing animation from warning banner - Keep animate-spin on button loading spinner as functional indicator * fix: remove unnecessary animations from RightClickRestorer page * fix: remove all unnecessary animations from all pages - Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed) - Remove decorative transition effects (transition-all, transition-colors) from all page components - Remove scale effects (active:scale-95) from buttons - Remove bounce animations (animate-bounce) from icons - Keep functional animate-spin on loading spinners as they provide essential loading feedback Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics * docs: 添加项目文档(copilot-instructions、编码规范、各目录 README) - 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范 - 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节) - 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils - 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes Remove hand-written variantClasses/sizeClasses that duplicated shadcn's buttonVariants. Now extends ButtonProps and imports buttonVariants from components/ui/button for consistent styling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors Replace hardcoded red-200/red-50/red-600 with border-destructive, bg-destructive/5, text-destructive. Use variant='destructive' on Button. Align error log styling with PageErrorBoundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls Use existing translation keys for clear success, copy success, and copy error messages instead of hardcoded Chinese text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(QrCodePreview): use shadcn Button instead of manual button styles Replace hand-written button class strings with shadcn Button component using outline and default variants. Removes ~15 lines of duplicated Tailwind classes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(GlobalSnackbar): clarify relationship with sonner toast Add note explaining when to use GlobalSnackbar (provider context, severity levels, custom positioning) vs sonner toast (simple one-off messages). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ImageUploader): wrap handleClearFile with useCallback Prevents unnecessary re-renders by memoizing the callback, consistent with handleFileChange which already uses useCallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(SwitchButtonGroup): remove redundant containerPadding conditional Both branches of the ternary returned 'p-1'. Inline the constant directly into the className string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add missing openExtensionPage import The function was called in handleOpenInTab but never imported from utils/chromeTabs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add selectedIndex boundary protection in keyboard nav Guard against out-of-bounds access when search results change during keyboard navigation. Check selectedIndex < totalItems before accessing arrays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): remove unnecessary empty-dep useMemo getEntryPointType() is a simple config getter. Replace useMemo(() => ..., []) with a direct call — the empty dep array made the memo pointless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(i18n): add missing translation keys for error boundaries and copy messages Add keys for errorBoundary, pageErrorBoundary, router, and messages.copyEmpty in both zh and en locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls Use useTranslation('common') for tooltip, copy empty/success/error toast messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and refresh button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and retry button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): replace hardcoded Chinese with i18n t() calls Use useTranslation('common') for 404 page title and description with entryPointType interpolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码 - 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm) - 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232) - 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools) - MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式 - 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码 (FileMode.tsx, ImageMode.tsx 及其测试文件) - 更新 README.md 移除对已删除文件的引用 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 * refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 * fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 * refactor: 迁移 i18n 系统从 react-i18next 到 chrome.i18n - 移除 react-i18next、i18next 及相关依赖 - 删除旧的 i18n/ 目录和 useLazyTranslation 工具 - 新增 utils/chromeI18n.ts 类型安全 wrapper(useI18n Hook + getMessage) - 生成 public/_locales/{zh,en}/messages.json(298 个翻译 key) - 批量更新 39+ 组件文件的导入和翻译调用 - 转换翻译键格式:namespace:key → namespace_key - 修复 ErrorBoundary/PageErrorBoundary 从 withTranslation HOC 改为直接调用 getMessage - 更新 vitest.setup.ts mock 加载实际翻译文本 - 修复 11 个测试文件的断言以匹配中文翻译 - TypeScript、ESLint、547 项测试全部通过 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: 移除语言切换按钮 chrome.i18n 语言由浏览器设置决定,不支持运行时切换,移除 TopBar 中的语言切换按钮及相关逻辑。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: 移除英文翻译,仅保留中文 删除 public/_locales/en/ 目录,清理 chromeI18n.ts 中未使用的语言切换相关导出。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: 清理未使用的 i18n key(322 → 245) 移除 77 个未被源码引用的翻译 key,包括: - jsonFormat_* (17): 未接入的子模式标题和按钮 - buttons_* (15): 代码使用 common: 命名空间格式,直接 key 未引用 - qrCode_* (14): 未使用的页面标题和状态提示 - timestamp/common/storageCleaner/jsonDiff 等零散未用 key 保留了通过模板字面量动态引用的 key(storageCleaner_options_*、common_buttons_themeMode_*、jsonFormat_*InputPlaceholder 等)。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: reorganize directory structure into src/ Move all source code directories into src/ for cleaner project structure: - pages/, components/, utils/, config/, providers/, types/, lib/, assets/, entrypoints/ → src/ - Use WXT srcDir config to resolve @/ alias to src/ - Update tsconfig, vitest, eslint, tailwind configs - Remove scattered README.md files from subdirectories - Update documentation (AGENTS.md, CODING_STANDARDS.md, README.md) - Fix pre-existing lint error in RouterProvider.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove meaningless comments Remove 50+ noise comments across the codebase: - AI-generated verbose prose (💡 emoji, '超进化', '大闸', etc.) - Comments that restate what the code obviously does - Comments about deleted code - Import-level noise comments - Overly verbose Chinese section markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: clean up unused code and simplify imports - Remove unused imports and variables across 35 files - Simplify component logic and remove dead code - Clean up test files by removing unnecessary setup - Streamline CI workflow configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: remove HTML-Markdown conversion features (htmlToMarkdown & markdownToHtml) Removed both conversion tools and all related code: - Deleted page components: HtmlToMarkdown, MarkdownToHtml - Deleted utility functions: htmlToMarkdown.ts, markdownToHtml.ts - Deleted unit tests for both utilities - Removed feature configs and PageType entries from storage.d.ts - Removed preview mode types and StorageSchema keys - Removed all i18n translation keys from messages.json - Removed vendor-markdown chunk from wxt.config.ts - Removed marked dependency from package.json - Updated feature count in tests (11 -> 9, page order 10 -> 8) - Updated README, AGENTS.md, and copilot-instructions.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: redesign Dashboard layout with search and recently used tools - Add search input at top to filter tools by name/description - Add 'recently used' section showing last 3 used tools as compact chips - Add 'all tools' section header for the tool card grid - Track tool usage via new 'app/recentlyUsedTools' storage key - Update navigateTo to record recently used tools (max 3, LRU order) - Add i18n keys: dashboard_searchPlaceholder, dashboard_recentlyUsed, dashboard_allTools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: replace ToolCard grid with compact icon+name grid on Dashboard - Replace large ToolCard layout with dense grid (3-6 columns responsive) - Each item shows icon + tool name in a compact tile - Hover highlights icon and text with smooth transitions - Remove ToolCard import from Dashboard (component retained for reuse) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove redundant search input from Dashboard (header already has one) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: optimize TopBar search with Cmd+K shortcut and improved styling - Add global Cmd/Ctrl+K keyboard shortcut to focus search input - Show keyboard shortcut hint (⌘K) in search input when empty - Improve input styling: rounded-lg, softer borders, focus transitions - Improve dropdown: rounded-lg, larger shadow, better spacing - Larger result icons (h-8 w-8) with rounded-lg containers - Refined history section with uppercase label and muted icon - Search icon fades in on focus via group-focus-within Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: improve search history to show recently used tools with direct navigation - Store feature keys (PageType) in history instead of translated labels - Show icon + name + description in history items (same as search results) - Click or Enter on history item navigates directly to the tool page - Remove History icon import (no longer needed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: useI18n fallback for empty string translations from chrome.i18n * refactor: 移除设置页面 - 删除 options 入口点目录及相关文件 - 移除 wxt.config.ts 中 options_ui 配置 - 移除 TopBar 组件中的设置按钮和 onOpenOptions prop - 移除 popup/sidepanel App.tsx 中的 handleOpenOptions - 移除相关测试中的设置页面引用 - 移除 messages.json 中设置相关翻译键 - 通过 lint、typecheck、test 验证 * docs: 更新文档,移除设置页面引用 - README.md: 移除 entrypoints/options/ 目录说明 - AGENTS.md: 更新项目结构描述,移除 options/ 入口点 * docs: add comprehensive page development standards (CODING_STANDARDS.md §11) - 新增 §11 页面开发规范(10 个子节),覆盖目录结构分4级、入口组件命名统一为 Index、业务 Hook 内部结构顺序、常量/子组件/存储键/模式切换/布局约定等规范 - 新增 §11.9 页面开发检查清单(10 步逐项确认) - 更新 AGENTS.md 和 copilot-instructions.md 同步引用新规范章节 - 修正 useLazyTranslation → useI18n 与实际代码保持一致 * refactor(pages): apply UI+Hook separation and unify component naming - Dashboard, RightClickRestorer: rename page component to Index - Jwt: extract useJwt.ts hook + JwtSection.tsx sub-component (127→76 lines) - TextStatistics: extract useTextStatistics.ts hook (63→48 lines) - JsonTools: extract useJsonTools.ts hook + constants.ts (199→109 lines) - All pages now follow CODING_STANDARDS.md §11 pattern * refactor(StorageCleaner): improve style consistency and checkbox alignment - Group storage options, auto-refresh toggle, and clean button into unified card - Remove card wrappers from StorageOptionsGrid and AutoRefreshToggle (now sections within parent card) - Align OptionItem and Select All checkboxes with consistent px-3.5 padding - Distinguish indeterminate vs checked checkbox states visually - Standardize spacing (space-y-4), add select-none to page root - Fix import path for StorageCleanerConfirm to relative ./ * fix(StorageCleaner, Base64Converter): fix layout padding and checkbox alignment - StorageCleaner: add px-3.5 to grid container to prevent content sticking to border - StorageCleaner: adjust select-all bar to pl-3.5 pr-7 for checkbox alignment with grid items - Base64Converter: add px-2 to TextMode and Base64ConverterSection for consistent spacing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add initial CLAUDE.md file with reference to @AGENT.md * docs: add comprehensive visual style guide Add VISUAL_STYLE_GUIDE.md to standardize UI development: - Design principles and keywords - Complete color system (CSS variables, semantic usage) - Typography hierarchy and patterns - Spacing, layout, and container specs - Border radius and shadow system - Component specs (Button, Input, Card, Dialog, etc.) - Interaction and animation guidelines - Dark mode implementation rules - Tool color palette and assignment - Code organization standards - Anti-patterns checklist - Quick reference appendix Update .gitignore to allow tracking the style guide. * docs(AGENTS): update i18n section to reflect chrome.i18n migration - Replace react-i18next docs with chrome.i18n API documentation - Update translation file paths from i18n/locales/ to public/_locales/ - Update test mock description for chromeI18n - Remove references to useLazyTranslation, vendor-i18n chunk, and snackbar Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(content): simplify content script structure - Remove messageHandler.ts (6-line pass-through facade) - Import contextMenuHandler directly from content.ts - Replace local getI18nText() with shared getMessage() from utils/chromeI18n Reduces content script files from 3 to 2 and eliminates i18n logic duplication. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(RightClickRestorer): simplify setDomain logic Replace nested if/try/catch with a single ternary expression. isUnsupportedPage() already handles invalid URLs, so new URL() is safe here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(background): extract main world injection script Move the 62-line inline main world injection function from background.ts to a dedicated utils/rightClickInjection.ts file. This separates the RightClickRestorer's DOM patching logic from the service worker's message routing concerns. - background.ts: -62 lines, now delegates to imported function - utils/rightClickInjection.ts: new file with mainWorldInjectionScript() Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(background): simplify RELOAD_TAB with setTimeout Replace the 30-line chrome.alarms API implementation with a simple setTimeout for delayed tab reloads. Rationale: The only usage is a 1-second delay after storage cleaning. Service workers don't go idle within 1 second, so alarms' persistence benefit is unnecessary here. - Removed: alarm naming, create/clear, listener register/unregister - Added: 4-line setTimeout conditional Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(storageCleaner): extract executeScript helpers Extract two generic helpers to eliminate repetitive executeScript boilerplate: - runScript<T>(): for size/query operations (5 functions) - runCleanScript(): for cleanup operations (5 functions) Also add CLEAN_OPTION_KEYS constant shared by formatCleaningResult and isEmptyResult. - 428 lines → 379 lines (-49 lines) - Eliminates ~100 lines of duplicated try/catch/result parsing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(base64Converter): simplify utility functions - Mark formatFileSize() as @deprecated, delegate to formatBytes - Replace manual for-loop in base64ToBytes() with Uint8Array.from() - Replace nested loops in sniffMimeFromBytes() with Array.every() 5 insertions(+), 23 deletions(-) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: replace GlobalSnackbar with sonner toast Remove the custom 373-line GlobalSnackbar component and its Provider/Hook in favor of the already-installed sonner library. Changes: - Delete src/components/GlobalSnackbar.tsx (+ tests) - Remove SnackbarProvider from popup/App.tsx and sidepanel/App.tsx - Replace useSnackbar() calls with toast.success()/toast.error() in: - ImageUploader.tsx - useQrCode.ts - ParsePanel.tsx - LiveClock.tsx - Update test mocks to use sonner instead of GlobalSnackbar -554 lines, +43 lines (net -511 lines) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(RouterContainer): remove unnecessary useMemo and hoist constant - Replace useMemo with direct expression for animationClass - Move entryPointType to module scope (value is fixed after mount) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: remove unnecessary useMemo calls across components Replace over-cached simple computations with direct expressions: - popup/App.tsx: getEntryPointType() and routerConfig (values are stable) - Dashboard/index.tsx: visibleSet, visibleFeatures, recentFeatures (Set creation + array filter/map are cheaper than useMemo overhead) - useTextStatistics.ts: getTextStats(text) (simple string processing) - ThemeModeProvider.tsx: contextValue object (setMode is stable via useCallback) -39 lines, +28 lines (net -11 lines) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: unify formatBytes usage and simplify useContextMenuData 1. Remove formatSize/formatByteSize delegation functions: - storageCleaner.ts:formatSize (was just formatBytes wrapper) - textStatistics.ts:formatByteSize (was just formatBytes wrapper) - Update all callers to import formatBytes directly from @/utils/format 2. Simplify useContextMenuData hook: - Remove unnecessary useCallback wrapping - Inline checkAndConsumeData logic directly in useEffect - Eliminate callback->effect dependency cycle -69 lines, +67 lines (net -2 lines, but removes 2 indirection layers) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(storageCleaner): simplify architecture and improve type semantics - Remove RELOAD_TAB message chain, use chrome.tabs.reload() directly - Rename IndexedDB label to '站点存储' for accuracy - Introduce StorageSizeInfo type to distinguish bytes vs count - Rename totalSize to totalBytes for clarity - Merge runCleanScript into runScript to reduce duplication - Rename CleaningResult.success to overallSuccess to avoid confusion - Remove unused domain state and setDomain call Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: replace require with import for JSON module in vitest.setup.ts * refactor: simplify Timestamp and RightClickRestorer Timestamp: - Extract shared utilities (msToUnit, dayjsFromTimestamp, ModeType) - Remove unnecessary useCallback/useMemo/React.memo - Remove extra info section (relative time, ISO 8601, UTC) - Fix missing translation key timestamp_unitS RightClickRestorer: - Remove website badge (右键已解锁 overlay) - Remove mouse penetration logic for media elements - Remove unnecessary useCallback - Remove redundant setIsLoading call Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(StorageCleanerConfirm.test.tsx): update IndexedDB label to Chinese translation --------- Co-authored-by: Ubuntu <ubuntu@localhost.localdomain> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
/**
|
||
* Base64 转换器工具函数
|
||
*/
|
||
|
||
import { formatBytes } from './format';
|
||
|
||
/** 最大文件大小限制(10 MB) */
|
||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||
|
||
/** 支持的图像 MIME 类型 */
|
||
export const SUPPORTED_IMAGE_TYPES = [
|
||
'image/png',
|
||
'image/jpeg',
|
||
'image/jpg',
|
||
'image/webp',
|
||
'image/gif',
|
||
'image/bmp',
|
||
'image/svg+xml',
|
||
'image/x-icon',
|
||
] as const;
|
||
|
||
/** 支持的图像文件扩展名 */
|
||
export const SUPPORTED_IMAGE_EXTENSIONS = [
|
||
'.png',
|
||
'.jpg',
|
||
'.jpeg',
|
||
'.webp',
|
||
'.gif',
|
||
'.bmp',
|
||
'.svg',
|
||
'.ico',
|
||
] as const;
|
||
|
||
/**
|
||
* 文本转 Base64 编码结果
|
||
*/
|
||
export interface TextToBase64Result {
|
||
/** Base64 编码结果 */
|
||
output: string;
|
||
/** 原始字节数 */
|
||
originalBytes: number;
|
||
/** 编码后字节数 */
|
||
outputBytes: number;
|
||
}
|
||
|
||
/**
|
||
* 文件转 Base64 编码结果
|
||
*/
|
||
export interface FileToBase64Result {
|
||
/** Base64 编码结果(含 data URI 前缀) */
|
||
output: string;
|
||
/** 纯 Base64 字符串(不含前缀) */
|
||
rawBase64: string;
|
||
/** 原始字节数 */
|
||
originalBytes: number;
|
||
/** 编码后字节数 */
|
||
outputBytes: number;
|
||
/** 文件名 */
|
||
fileName: string;
|
||
/** 文件 MIME 类型 */
|
||
mimeType: string;
|
||
}
|
||
|
||
/**
|
||
* 将文本字符串编码为 Base64
|
||
*
|
||
* @param text 输入文本
|
||
* @returns 编码结果
|
||
*/
|
||
export function textToBase64(text: string): TextToBase64Result {
|
||
const encoded = btoa(unescape(encodeURIComponent(text)));
|
||
const originalBytes = new TextEncoder().encode(text).length;
|
||
const outputBytes = new TextEncoder().encode(encoded).length;
|
||
return {
|
||
output: encoded,
|
||
originalBytes,
|
||
outputBytes,
|
||
};
|
||
}
|
||
|
||
/** 匹配 data URI 的 base64 前缀,如 "data:image/png;base64,"(允许中间含参数) */
|
||
const DATA_URI_BASE64_PREFIX = /^data:[^,]+;base64,/i;
|
||
|
||
/**
|
||
* 将 Base64 字符串解码为文本
|
||
*
|
||
* 支持 data:<mime>;base64,<payload> 形式:会自动剥离前缀后再解码。
|
||
* 若解码出的字节不是合法 UTF-8(典型如图片等二进制数据),抛出更易懂的错误。
|
||
*
|
||
* @param base64 Base64 编码字符串
|
||
* @returns 解码后的文本
|
||
* @throws {Error} 输入不是合法的 Base64 字符串
|
||
* @throws {Error} 输入解码后是二进制数据,无法作为文本展示
|
||
*/
|
||
export function base64ToText(base64: string): string {
|
||
const trimmed = base64.trim();
|
||
const cleaned = trimmed.replace(DATA_URI_BASE64_PREFIX, '');
|
||
if (!isValidBase64(cleaned)) {
|
||
throw new Error('Invalid Base64 string');
|
||
}
|
||
try {
|
||
return decodeURIComponent(escape(atob(cleaned)));
|
||
} catch {
|
||
throw new Error(
|
||
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验字符串是否为合法的 Base64 编码
|
||
*
|
||
* @param str 待校验字符串
|
||
* @returns 是否合法
|
||
*/
|
||
export function isValidBase64(str: string): boolean {
|
||
if (!str || str.length === 0) return false;
|
||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||
if (!base64Regex.test(str)) return false;
|
||
if (str.length % 4 !== 0) return false;
|
||
try {
|
||
atob(str);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验文件大小是否在限制范围内
|
||
*
|
||
* @param fileSize 文件大小(字节)
|
||
* @returns 是否合法
|
||
*/
|
||
export function isFileSizeValid(fileSize: number): boolean {
|
||
return fileSize > 0 && fileSize <= MAX_FILE_SIZE;
|
||
}
|
||
|
||
/**
|
||
* 校验 MIME 类型是否为支持的图像类型
|
||
*
|
||
* @param mimeType MIME 类型
|
||
* @returns 是否为支持的图像类型
|
||
*/
|
||
export function isSupportedImageType(mimeType: string): boolean {
|
||
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
|
||
}
|
||
|
||
/**
|
||
* 校验文件扩展名是否为支持的图像格式
|
||
*
|
||
* @param fileName 文件名
|
||
* @returns 是否为支持的图像格式
|
||
*/
|
||
export function isSupportedImageExtension(fileName: string): boolean {
|
||
const lowerName = fileName.toLowerCase();
|
||
return (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).some((ext) => lowerName.endsWith(ext));
|
||
}
|
||
|
||
/**
|
||
* 将文件转换为 Base64 编码(使用 FileReader 异步读取)
|
||
*
|
||
* @param file 文件对象
|
||
* @returns Promise<FileToBase64Result> 编码结果
|
||
* @throws {Error} 如果文件为空或超出大小限制
|
||
*/
|
||
export function fileToBase64(file: File): Promise<FileToBase64Result> {
|
||
return new Promise((resolve, reject) => {
|
||
if (!file) {
|
||
reject(new Error('No file provided'));
|
||
return;
|
||
}
|
||
|
||
if (!isFileSizeValid(file.size)) {
|
||
reject(new Error(`File size exceeds the limit (${MAX_FILE_SIZE / 1024 / 1024} MB)`));
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
|
||
reader.onload = () => {
|
||
const dataUri = reader.result as string;
|
||
const commaIndex = dataUri.indexOf(',');
|
||
const rawBase64 = commaIndex >= 0 ? dataUri.substring(commaIndex + 1) : dataUri;
|
||
|
||
resolve({
|
||
output: dataUri,
|
||
rawBase64,
|
||
originalBytes: file.size,
|
||
outputBytes: rawBase64.length,
|
||
fileName: file.name,
|
||
mimeType: file.type || 'application/octet-stream',
|
||
});
|
||
};
|
||
|
||
reader.onerror = () => {
|
||
reject(new Error('Failed to read file'));
|
||
};
|
||
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 从 data URI 中提取 MIME 类型
|
||
*
|
||
* @param dataUri data URI 字符串
|
||
* @returns MIME 类型
|
||
*/
|
||
export function extractMimeTypeFromDataUri(dataUri: string): string {
|
||
const match = dataUri.match(/^data:([^;,]+)[^,]*;base64,/);
|
||
return match ? match[1] : 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小显示
|
||
*
|
||
* @deprecated 直接使用 {@link formatBytes} 代替
|
||
* @param bytes 字节数
|
||
* @returns 格式化后的字符串
|
||
*/
|
||
export function formatFileSize(bytes: number): string {
|
||
return formatBytes(bytes);
|
||
}
|
||
|
||
/**
|
||
* Base64 解码为二进制后的产物
|
||
*/
|
||
export interface Base64ToBlobResult {
|
||
/** 解码后的 Blob */
|
||
blob: Blob;
|
||
/** 推断出的 MIME 类型 */
|
||
mimeType: string;
|
||
/** 推荐的扩展名,含点(如 `.png`),无法识别时为 `.bin` */
|
||
suggestedExtension: string;
|
||
/** 已去除 data URI 前缀的纯 Base64 字符串 */
|
||
rawBase64: string;
|
||
}
|
||
|
||
/**
|
||
* 已知文件类型魔数签名表。注意:ZIP 头同样会匹配 .docx/.xlsx/.pptx/.apk
|
||
*
|
||
* 签名匹配规则:
|
||
* - `bytes` 必须匹配文件起始
|
||
* - 可选的 `tail` 用于多段签名(如 WebP:"RIFF" + 偏移 8 处的 "WEBP")
|
||
*/
|
||
const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
|
||
bytes: readonly number[];
|
||
tail?: { offset: number; bytes: readonly number[] };
|
||
mime: string;
|
||
ext: string;
|
||
}> = [
|
||
{ bytes: [0x89, 0x50, 0x4e, 0x47], mime: 'image/png', ext: '.png' },
|
||
{ bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg', ext: '.jpg' },
|
||
{ bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif', ext: '.gif' },
|
||
{ bytes: [0x42, 0x4d], mime: 'image/bmp', ext: '.bmp' },
|
||
{
|
||
bytes: [0x52, 0x49, 0x46, 0x46],
|
||
tail: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
||
mime: 'image/webp',
|
||
ext: '.webp',
|
||
},
|
||
{ bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf', ext: '.pdf' },
|
||
{ bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip', ext: '.zip' },
|
||
];
|
||
|
||
/**
|
||
* 将 Base64 字符串解码为 Uint8Array
|
||
*
|
||
* @param b64 纯 Base64 字符串(不含 data URI 前缀)
|
||
* @returns 字节序列
|
||
*/
|
||
export function base64ToBytes(b64: string): Uint8Array {
|
||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||
}
|
||
|
||
/**
|
||
* 根据字节序列前缀识别已知文件类型
|
||
*
|
||
* @param bytes 解码后的字节序列
|
||
* @returns 匹配到的 MIME + 扩展名;未匹配返回 null
|
||
*/
|
||
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
|
||
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
||
if (bytes.length < sig.bytes.length) continue;
|
||
if (!sig.bytes.every((b, i) => bytes[i] === b)) continue;
|
||
if (sig.tail) {
|
||
const { offset, bytes: tailBytes } = sig.tail;
|
||
if (bytes.length < offset + tailBytes.length) continue;
|
||
if (!tailBytes.every((b, i) => bytes[offset + i] === b)) continue;
|
||
}
|
||
return { mime: sig.mime, ext: sig.ext };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 将 Base64 / data URI 字符串解码为 Blob,自动推断 MIME 与扩展名
|
||
*
|
||
* MIME 推断优先级:data URI 前缀 → 字节魔数 → `application/octet-stream`
|
||
*
|
||
* @param input Base64 字符串或 data URI
|
||
* @returns 解码结果
|
||
* @throws {Error} 输入不是合法 Base64
|
||
*/
|
||
export function base64ToBlob(input: string): Base64ToBlobResult {
|
||
const trimmed = input.trim();
|
||
const prefixMatch = trimmed.match(DATA_URI_BASE64_PREFIX);
|
||
const cleaned = prefixMatch ? trimmed.slice(prefixMatch[0].length) : trimmed;
|
||
|
||
if (!isValidBase64(cleaned)) {
|
||
throw new Error('Invalid Base64 string');
|
||
}
|
||
|
||
const bytes = base64ToBytes(cleaned);
|
||
|
||
let mimeType: string;
|
||
let suggestedExtension: string;
|
||
if (prefixMatch) {
|
||
mimeType = extractMimeTypeFromDataUri(trimmed);
|
||
const sniffed = sniffMimeFromBytes(bytes);
|
||
suggestedExtension = sniffed?.ext ?? mimeTypeToExtension(mimeType);
|
||
} else {
|
||
const sniffed = sniffMimeFromBytes(bytes);
|
||
mimeType = sniffed?.mime ?? 'application/octet-stream';
|
||
suggestedExtension = sniffed?.ext ?? '.bin';
|
||
}
|
||
|
||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
||
return { blob, mimeType, suggestedExtension, rawBase64: cleaned };
|
||
}
|
||
|
||
/** 极小的 MIME -> 扩展名映射,仅用于带 data URI 前缀但魔数无法识别时 */
|
||
function mimeTypeToExtension(mime: string): string {
|
||
if (mime.startsWith('image/svg')) return '.svg';
|
||
if (mime === 'image/webp') return '.webp';
|
||
if (mime === 'image/bmp') return '.bmp';
|
||
if (mime === 'image/x-icon' || mime === 'image/vnd.microsoft.icon') return '.ico';
|
||
if (mime === 'text/plain') return '.txt';
|
||
if (mime === 'application/json') return '.json';
|
||
if (mime === 'text/html') return '.html';
|
||
if (mime === 'text/css') return '.css';
|
||
return '.bin';
|
||
}
|
||
|
||
/**
|
||
* 触发浏览器下载指定 Blob
|
||
*
|
||
* @param blob 要下载的 Blob
|
||
* @param filename 下载文件名
|
||
*/
|
||
export function downloadBlob(blob: Blob, filename: string): void {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|