chore: merge develop into main (#56)
* 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>
This commit is contained in:
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
{/* 受控方向切流中枢 */}
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { getFeatureByKey } from '@/config/features';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { useMemo } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function DashboardPage() {
|
||||
export default function Index() {
|
||||
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
|
||||
const { t } = useI18n(['features']);
|
||||
|
||||
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
||||
const visibleSet = new Set<string>(visiblePages);
|
||||
|
||||
const visibleFeatures = useMemo(() => {
|
||||
return pageOrder
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
}, [pageOrder, visibleSet]);
|
||||
const visibleFeatures = pageOrder
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
|
||||
const recentFeatures = useMemo(() => {
|
||||
return recentlyUsedTools
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
}, [recentlyUsedTools, visibleSet]);
|
||||
const recentFeatures = recentlyUsedTools
|
||||
.filter((key) => visibleSet.has(key))
|
||||
.map((key) => ({ key, feature: getFeatureByKey(key) }))
|
||||
.filter((item) => item.feature?.themeColorKey && item.feature.icon != null);
|
||||
|
||||
const showRecent = recentFeatures.length > 0;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { formatByteSize } from '@/utils/textStatistics';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { validateJson } from '@/utils/jsonFormatter';
|
||||
@@ -96,7 +96,7 @@ export default function JsonConvertSection({
|
||||
<span>
|
||||
{t('jsonFormat:originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatByteSize(result.originalBytes)}
|
||||
{formatBytes(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type JsonFormatResult,
|
||||
validateJson,
|
||||
} from '@/utils/jsonFormatter';
|
||||
import { formatByteSize } from '@/utils/textStatistics';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
@@ -127,7 +127,7 @@ export default function JsonFormatSection() {
|
||||
<span>
|
||||
{t('jsonFormat:originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatByteSize(result.originalBytes)}
|
||||
{formatBytes(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
|
||||
export const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = [
|
||||
'diff',
|
||||
'format',
|
||||
'yaml',
|
||||
'toml',
|
||||
'minify',
|
||||
];
|
||||
|
||||
export const isValidPageMode = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
||||
|
||||
export interface ParseState {
|
||||
value: unknown;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const tryParse = (raw: string, invalidMsg: string): ParseState => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { value: undefined, error: null };
|
||||
try {
|
||||
return { value: JSON.parse(trimmed), error: null };
|
||||
} catch {
|
||||
return { value: undefined, error: invalidMsg };
|
||||
}
|
||||
};
|
||||
+25
-105
@@ -1,113 +1,39 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import JsonDiffInput from './JsonDiffInput';
|
||||
import DiffResult from './DiffResult';
|
||||
import DiffNavigator from './DiffNavigator';
|
||||
import JsonFormatSection from './JsonFormatSection';
|
||||
import type { ConvertFunction } from './JsonConvertSection';
|
||||
import JsonConvertSection from './JsonConvertSection';
|
||||
import { diffJson } from './diffEngine';
|
||||
import { jsonToYaml } from '@/utils/jsonToYaml';
|
||||
import { jsonToToml } from '@/utils/jsonToToml';
|
||||
import { minifyJson } from '@/utils/jsonFormatter';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { useJsonTools } from './useJsonTools';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
import type { ViewMode } from './types';
|
||||
|
||||
interface ParseState {
|
||||
value: unknown;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const tryParse = (raw: string, invalidMsg: string): ParseState => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { value: undefined, error: null };
|
||||
try {
|
||||
return { value: JSON.parse(trimmed), error: null };
|
||||
} catch {
|
||||
return { value: undefined, error: invalidMsg };
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = ['diff', 'format', 'yaml', 'toml', 'minify'];
|
||||
const isValidPageMode = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
||||
|
||||
type PageMode = JsonToolsPageMode;
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
|
||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||
|
||||
// Debounce input
|
||||
const [leftInput, setLeftInput] = useState('');
|
||||
const [rightInput, setRightInput] = useState('');
|
||||
|
||||
// Debounced values
|
||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
||||
const [debouncedRight, setDebouncedRight] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedLeft(leftInput);
|
||||
setDebouncedRight(rightInput);
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [leftInput, rightInput]);
|
||||
|
||||
// Parse debounced inputs
|
||||
const parseState = useMemo(() => {
|
||||
const invalidMsg = t('jsonDiff:invalidJson');
|
||||
return {
|
||||
left: tryParse(debouncedLeft, invalidMsg),
|
||||
right: tryParse(debouncedRight, invalidMsg),
|
||||
};
|
||||
}, [debouncedLeft, debouncedRight, t]);
|
||||
|
||||
const leftError = parseState.left.error;
|
||||
const rightError = parseState.right.error;
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
|
||||
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
|
||||
|
||||
// Real-time diff computation
|
||||
const diffResult = useMemo(() => {
|
||||
const { left, right } = parseState;
|
||||
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return diffJson(left.value, right.value);
|
||||
}, [parseState, debouncedLeft, debouncedRight]);
|
||||
|
||||
const total = diffResult?.diffPaths.length ?? 0;
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
|
||||
}, [total]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx + 1) % total);
|
||||
}, [total]);
|
||||
|
||||
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
||||
|
||||
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = jsonToYaml(text);
|
||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||
}, []);
|
||||
|
||||
const tomlConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = jsonToToml(text);
|
||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||
}, []);
|
||||
|
||||
const minifyConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = minifyJson(text);
|
||||
return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes };
|
||||
}, []);
|
||||
const {
|
||||
pageMode,
|
||||
setPageMode,
|
||||
leftInput,
|
||||
rightInput,
|
||||
setLeftInput,
|
||||
setRightInput,
|
||||
leftError,
|
||||
rightError,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
diffResult,
|
||||
total,
|
||||
currentDiffIndex,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
activePath,
|
||||
yamlConvert,
|
||||
tomlConvert,
|
||||
minifyConvert,
|
||||
} = useJsonTools();
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||
@@ -144,10 +70,7 @@ export default function Index() {
|
||||
label={t('jsonDiff:leftLabel')}
|
||||
placeholder={t('jsonDiff:leftPlaceholder')}
|
||||
value={leftInput}
|
||||
onChange={(val) => {
|
||||
setLeftInput(val);
|
||||
setCurrentDiffIndex(0);
|
||||
}}
|
||||
onChange={setLeftInput}
|
||||
error={leftError}
|
||||
minRows={9}
|
||||
/>
|
||||
@@ -155,10 +78,7 @@ export default function Index() {
|
||||
label={t('jsonDiff:rightLabel')}
|
||||
placeholder={t('jsonDiff:rightPlaceholder')}
|
||||
value={rightInput}
|
||||
onChange={(val) => {
|
||||
setRightInput(val);
|
||||
setCurrentDiffIndex(0);
|
||||
}}
|
||||
onChange={setRightInput}
|
||||
error={rightError}
|
||||
minRows={9}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { diffJson } from './diffEngine';
|
||||
import { jsonToYaml } from '@/utils/jsonToYaml';
|
||||
import { jsonToToml } from '@/utils/jsonToToml';
|
||||
import { minifyJson } from '@/utils/jsonFormatter';
|
||||
import { isValidPageMode, tryParse } from './constants';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
import type { ConvertFunction } from './JsonConvertSection';
|
||||
import type { ViewMode } from './types';
|
||||
|
||||
export interface UseJsonToolsReturn {
|
||||
pageMode: JsonToolsPageMode;
|
||||
setPageMode: (mode: JsonToolsPageMode) => void;
|
||||
// Diff mode state
|
||||
leftInput: string;
|
||||
rightInput: string;
|
||||
setLeftInput: (val: string) => void;
|
||||
setRightInput: (val: string) => void;
|
||||
leftError: string | null;
|
||||
rightError: string | null;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
diffResult: ReturnType<typeof diffJson> | null;
|
||||
total: number;
|
||||
currentDiffIndex: number;
|
||||
handlePrev: () => void;
|
||||
handleNext: () => void;
|
||||
activePath: string | undefined;
|
||||
// Convert functions
|
||||
yamlConvert: ConvertFunction;
|
||||
tomlConvert: ConvertFunction;
|
||||
minifyConvert: ConvertFunction;
|
||||
}
|
||||
|
||||
export function useJsonTools(): UseJsonToolsReturn {
|
||||
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
|
||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||
|
||||
// Diff inputs
|
||||
const [leftInput, setLeftInput] = useState('');
|
||||
const [rightInput, setRightInput] = useState('');
|
||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
||||
const [debouncedRight, setDebouncedRight] = useState('');
|
||||
|
||||
// Debounce
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedLeft(leftInput);
|
||||
setDebouncedRight(rightInput);
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [leftInput, rightInput]);
|
||||
|
||||
// Parse debounced inputs
|
||||
const parseState = useMemo(() => {
|
||||
const invalidMsg = t('jsonDiff:invalidJson');
|
||||
return {
|
||||
left: tryParse(debouncedLeft, invalidMsg),
|
||||
right: tryParse(debouncedRight, invalidMsg),
|
||||
};
|
||||
}, [debouncedLeft, debouncedRight, t]);
|
||||
|
||||
const leftError = parseState.left.error;
|
||||
const rightError = parseState.right.error;
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
|
||||
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
|
||||
|
||||
// Real-time diff computation
|
||||
const diffResult = useMemo(() => {
|
||||
const { left, right } = parseState;
|
||||
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return diffJson(left.value, right.value);
|
||||
}, [parseState, debouncedLeft, debouncedRight]);
|
||||
|
||||
const total = diffResult?.diffPaths.length ?? 0;
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
|
||||
}, [total]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx + 1) % total);
|
||||
}, [total]);
|
||||
|
||||
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
||||
|
||||
// Convert functions
|
||||
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = jsonToYaml(text);
|
||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||
}, []);
|
||||
|
||||
const tomlConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = jsonToToml(text);
|
||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||
}, []);
|
||||
|
||||
const minifyConvert: ConvertFunction = useCallback((text: string) => {
|
||||
const r = minifyJson(text);
|
||||
return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pageMode,
|
||||
setPageMode,
|
||||
leftInput,
|
||||
rightInput,
|
||||
setLeftInput: (val: string) => {
|
||||
setLeftInput(val);
|
||||
setCurrentDiffIndex(0);
|
||||
},
|
||||
setRightInput: (val: string) => {
|
||||
setRightInput(val);
|
||||
setCurrentDiffIndex(0);
|
||||
},
|
||||
leftError,
|
||||
rightError,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
diffResult,
|
||||
total,
|
||||
currentDiffIndex,
|
||||
handlePrev,
|
||||
handleNext,
|
||||
activePath,
|
||||
yamlConvert,
|
||||
tomlConvert,
|
||||
minifyConvert,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { stringifyJson } from '@/utils/jwt';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface JwtSectionProps {
|
||||
title: string;
|
||||
content: unknown;
|
||||
colorClass: string;
|
||||
bgClass: string;
|
||||
borderClass: string;
|
||||
}
|
||||
|
||||
export default function JwtSection({
|
||||
title,
|
||||
content,
|
||||
colorClass,
|
||||
bgClass,
|
||||
borderClass,
|
||||
}: JwtSectionProps) {
|
||||
const { t } = useI18n('jwt');
|
||||
return (
|
||||
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
|
||||
<div className="flex justify-between items-center mb-2 select-none">
|
||||
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
|
||||
{title}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={JSON.stringify(content)}
|
||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
|
||||
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-72
@@ -1,91 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { parseJwt, stringifyJson } from '@/utils/jwt';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import JwtSection from './JwtSection';
|
||||
import { useJwt } from './useJwt';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
content: unknown;
|
||||
colorClass: string;
|
||||
bgClass: string;
|
||||
borderClass: string;
|
||||
}
|
||||
|
||||
const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => {
|
||||
const { t } = useI18n('jwt');
|
||||
return (
|
||||
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
|
||||
<div className="flex justify-between items-center mb-2 select-none">
|
||||
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
|
||||
{title}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={JSON.stringify(content)}
|
||||
className="h-6 w-6 rounded-md border text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
|
||||
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n(['jwt', 'jsonFormat']);
|
||||
const [jwtInput, setJwtInput] = useState('');
|
||||
|
||||
// 2. 防抖中转管道:切断高频键盘敲击时的红色语法闪烁
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedInput(jwtInput);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [jwtInput]);
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
const cleaned = payload.replace(/^Bearer\s*/i, '').trim();
|
||||
setJwtInput(cleaned);
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!debouncedInput.trim()) {
|
||||
return null;
|
||||
}
|
||||
return parseJwt(debouncedInput);
|
||||
}, [debouncedInput]);
|
||||
const { jwtInput, result, handleChange, handleClear } = useJwt();
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 输入终端 */}
|
||||
<TextInputArea
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
placeholder={t('jwt_placeholder')}
|
||||
value={jwtInput}
|
||||
onChange={(val) => {
|
||||
const cleaned = val.replace(/^Bearer\s*/i, '').trim();
|
||||
setJwtInput(cleaned);
|
||||
}}
|
||||
onChange={handleChange}
|
||||
allowCopy={true}
|
||||
showClear={true}
|
||||
externalError={result?.error || undefined}
|
||||
onClear={() => setJwtInput('')}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{/* 解码看板结果展现 */}
|
||||
{result && !result.error && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */}
|
||||
<Section
|
||||
<JwtSection
|
||||
title={t('jwt:headerTitle')}
|
||||
content={result.header}
|
||||
colorClass="text-[#fb015b] dark:text-rose-400"
|
||||
@@ -93,16 +33,14 @@ export default function Index() {
|
||||
bgClass="bg-[#fb015b]/5 dark:bg-rose-500/5"
|
||||
/>
|
||||
|
||||
{/* Payload 分区 */}
|
||||
<Section
|
||||
<JwtSection
|
||||
title={t('jwt:payloadTitle')}
|
||||
content={result.payload}
|
||||
colorClass="text-[#a03aff] dark:text-purple-400" // 针对暗黑模式略微调高对比度
|
||||
colorClass="text-[#a03aff] dark:text-purple-400"
|
||||
borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
|
||||
bgClass="bg-[#a03aff]/5 dark:bg-purple-500/5"
|
||||
/>
|
||||
|
||||
{/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
|
||||
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
|
||||
@@ -120,7 +58,6 @@ export default function Index() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当解析错误时的干净中性引导拦截 */}
|
||||
{result?.error && (
|
||||
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { parseJwt } from '@/utils/jwt';
|
||||
import type { JwtResult } from '@/utils/jwt';
|
||||
|
||||
export interface UseJwtReturn {
|
||||
jwtInput: string;
|
||||
result: JwtResult | null;
|
||||
handleChange: (val: string) => void;
|
||||
handleClear: () => void;
|
||||
}
|
||||
|
||||
export function useJwt(): UseJwtReturn {
|
||||
const [jwtInput, setJwtInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
|
||||
// 防抖管道
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedInput(jwtInput), 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [jwtInput]);
|
||||
|
||||
// 右键菜单数据
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
setJwtInput(payload.replace(/^Bearer\s*/i, '').trim());
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
|
||||
|
||||
// 响应式解析
|
||||
const result = useMemo(() => {
|
||||
if (!debouncedInput.trim()) return null;
|
||||
return parseJwt(debouncedInput);
|
||||
}, [debouncedInput]);
|
||||
|
||||
const handleChange = useCallback((val: string) => {
|
||||
setJwtInput(val.replace(/^Bearer\s*/i, '').trim());
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(() => setJwtInput(''), []);
|
||||
|
||||
return { jwtInput, result, handleChange, handleClear };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -9,7 +9,6 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export default function ParsePanel() {
|
||||
const { t } = useI18n('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
||||
|
||||
// 全局粘贴事件监听
|
||||
@@ -24,7 +23,7 @@ export default function ParsePanel() {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
toast.success(t('qrCode:imagePasted'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -38,14 +37,14 @@ export default function ParsePanel() {
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
toast.success(t('qrCode:imagePasted'));
|
||||
} catch (error) {
|
||||
console.error('处理 Base64 图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
|
||||
toast.error(t('qrCode:imagePasteError'));
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFileChange, showMessage, t],
|
||||
[handleFileChange, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import QRious from 'qrious';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { toast } from 'sonner';
|
||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
@@ -10,7 +10,6 @@ import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../typ
|
||||
|
||||
export function useQrCode(): QrCodeContextValue {
|
||||
const { t } = useI18n('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
|
||||
const [mode, setMode] = useState<QrCodeMode>('generate');
|
||||
|
||||
@@ -89,22 +88,22 @@ export function useQrCode(): QrCodeContextValue {
|
||||
|
||||
if (result.success && result.data) {
|
||||
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
toast.success(t('qrCode:parseSuccess'));
|
||||
} else {
|
||||
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析二维码失败:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||
}
|
||||
},
|
||||
[t, showMessage],
|
||||
[t],
|
||||
);
|
||||
|
||||
const downloadQrCode = useCallback(() => {
|
||||
@@ -114,8 +113,8 @@ export function useQrCode(): QrCodeContextValue {
|
||||
link.href = qrCodeDataUrl;
|
||||
link.download = 'qrcode.png';
|
||||
link.click();
|
||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
}, [qrCodeDataUrl, showMessage, t]);
|
||||
toast.success(t('qrCode:qrCodeDownloadSuccess'));
|
||||
}, [qrCodeDataUrl, t]);
|
||||
|
||||
const copyQrCode = useCallback(async () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
@@ -130,12 +129,12 @@ export function useQrCode(): QrCodeContextValue {
|
||||
}),
|
||||
]);
|
||||
|
||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
||||
toast.success(t('qrCode:qrCodeCopySuccess'));
|
||||
} catch (error) {
|
||||
console.error('复制二维码失败:', error);
|
||||
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
|
||||
toast.error(t('qrCode:copyError'));
|
||||
}
|
||||
}, [qrCodeDataUrl, showMessage, t]);
|
||||
}, [qrCodeDataUrl, t]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-re
|
||||
import { useRightClickRestorer } from './useRightClickRestorer';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
export default function RightClickRestorerPage() {
|
||||
export default function Index() {
|
||||
const { t } = useI18n('rightClickRestorer');
|
||||
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||
|
||||
const UNSUPPORTED_PROTOCOLS = new Set([
|
||||
@@ -39,17 +39,10 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const url = tab?.url;
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
setDomain(new URL(url).hostname);
|
||||
} catch {
|
||||
setDomain('');
|
||||
}
|
||||
}
|
||||
setDomain(url ? new URL(url).hostname : '');
|
||||
|
||||
if (isUnsupportedPage(url)) {
|
||||
setIsUnsupported(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +60,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const unlock = useCallback(async () => {
|
||||
async function unlock() {
|
||||
if (isUnsupported) return;
|
||||
|
||||
try {
|
||||
@@ -78,7 +71,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
||||
} catch (err) {
|
||||
console.error('[RightClickRestorer] Failed to unlock:', err);
|
||||
}
|
||||
}, [isUnsupported]);
|
||||
}
|
||||
|
||||
return {
|
||||
domain,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 自动刷新开关组件
|
||||
* 自动刷新开关行
|
||||
*
|
||||
* 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
|
||||
* 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
|
||||
* 用于 StorageCleaner 页面的操作区域内(非独立卡片),
|
||||
* 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -48,15 +48,14 @@ export default function AutoRefreshToggle({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex justify-between items-center',
|
||||
'w-full px-3.5 py-3 border-t border-border flex justify-between items-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
|
||||
<Label
|
||||
htmlFor="auto-refresh-switch"
|
||||
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
|
||||
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
|
||||
>
|
||||
{t('storageCleaner:autoRefresh')}
|
||||
</Label>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const isSuccess = result.success;
|
||||
const isSuccess = result.overallSuccess;
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)} {...props}>
|
||||
|
||||
@@ -1,59 +1,63 @@
|
||||
import React from 'react';
|
||||
import { formatSize } from '@/utils/storageCleaner';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
// 引入官方的 Checkbox 原子组件
|
||||
import type { StorageSizeInfo } from './useStorageCleaner';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
labelKey: string;
|
||||
checked: boolean;
|
||||
size?: number;
|
||||
isCount?: boolean;
|
||||
sizeInfo?: StorageSizeInfo;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function OptionItem({
|
||||
labelKey,
|
||||
checked,
|
||||
size,
|
||||
isCount = false,
|
||||
sizeInfo,
|
||||
onChange,
|
||||
className,
|
||||
...props
|
||||
}: OptionItemProps) {
|
||||
const { t } = useI18n('storageCleaner');
|
||||
|
||||
const sizeValue = sizeInfo?.value;
|
||||
const isCount = sizeInfo?.displayType === 'count';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onChange}
|
||||
className={cn(
|
||||
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none',
|
||||
'flex justify-between items-center py-2.5 px-3.5 rounded-lg border cursor-pointer select-none transition-colors',
|
||||
checked
|
||||
? 'bg-primary/5 border-primary/30 shadow-sm'
|
||||
: 'bg-transparent border-transparent hover:bg-muted/70',
|
||||
: 'bg-transparent border-transparent hover:bg-muted/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* 左侧数据区域 */}
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<span
|
||||
className={cn(
|
||||
'block text-xs font-semibold leading-tight truncate',
|
||||
checked ? 'text-foreground font-bold' : 'text-foreground/80',
|
||||
'block text-xs font-semibold leading-tight truncate transition-colors',
|
||||
checked ? 'text-foreground' : 'text-foreground/75',
|
||||
)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
|
||||
{/* 底部容量大小或计数标识 */}
|
||||
{size !== undefined && size > 0 ? (
|
||||
<span className="block text-[10px] font-mono font-medium text-muted-foreground/80 mt-0.5 tabular-nums">
|
||||
{isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)}
|
||||
{sizeValue !== undefined && sizeValue > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
'block text-[10px] font-mono font-medium mt-0.5 tabular-nums transition-colors',
|
||||
checked ? 'text-primary/70' : 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="block text-[10px] font-medium text-muted-foreground/60 mt-0.5 italic">
|
||||
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
|
||||
{t('storageCleaner:noData')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { StorageCleanerOptions } from '@/types/storage';
|
||||
import type { StorageSizeInfo } from './useStorageCleaner';
|
||||
import OptionItem from './OptionItem';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
|
||||
interface StorageOptionsGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
options: StorageCleanerOptions;
|
||||
sizes: Record<string, number>;
|
||||
sizes: Record<string, StorageSizeInfo>;
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
||||
@@ -27,13 +28,13 @@ export default function StorageOptionsGrid({
|
||||
}: StorageOptionsGridProps) {
|
||||
const { t } = useI18n('storageCleaner');
|
||||
|
||||
const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
|
||||
{ key: 'localStorage' },
|
||||
{ key: 'sessionStorage' },
|
||||
{ key: 'indexedDB' },
|
||||
{ key: 'cookies' },
|
||||
{ key: 'cacheStorage', isCount: true },
|
||||
{ key: 'serviceWorkers', isCount: true },
|
||||
const optionKeys: (keyof StorageCleanerOptions)[] = [
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'indexedDB',
|
||||
'cookies',
|
||||
'cacheStorage',
|
||||
'serviceWorkers',
|
||||
];
|
||||
|
||||
const handleToggleAll = () => {
|
||||
@@ -41,22 +42,15 @@ export default function StorageOptionsGrid({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-2 gap-2.5 items-stretch">
|
||||
{optionKeys.map(({ key, isCount }) => (
|
||||
<div className={cn('w-full overflow-hidden', className)} {...props}>
|
||||
<div className="px-3.5 pt-3.5 pb-2">
|
||||
<div className="grid grid-cols-2 gap-2 items-stretch">
|
||||
{optionKeys.map((key) => (
|
||||
<OptionItem
|
||||
key={key}
|
||||
labelKey={`storageCleaner:options.${key}`}
|
||||
checked={options[key]}
|
||||
size={sizes[key]}
|
||||
isCount={isCount}
|
||||
sizeInfo={sizes[key]}
|
||||
onChange={() => onOptionChange(key)}
|
||||
/>
|
||||
))}
|
||||
@@ -65,9 +59,9 @@ export default function StorageOptionsGrid({
|
||||
|
||||
<div
|
||||
onClick={handleToggleAll}
|
||||
className="border-t border-border flex justify-between items-center px-4 py-2.5 bg-muted/20 hover:bg-muted/50 cursor-pointer select-none"
|
||||
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
|
||||
>
|
||||
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer">
|
||||
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
|
||||
{t('storageCleaner:selectAll')}
|
||||
</Label>
|
||||
|
||||
@@ -75,7 +69,7 @@ export default function StorageOptionsGrid({
|
||||
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
||||
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=indeterminate]:bg-primary"
|
||||
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-muted-foreground/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm';
|
||||
import StorageCleanerConfirm from './StorageCleanerConfirm';
|
||||
import { useStorageCleaner } from './useStorageCleaner';
|
||||
import StorageOptionsGrid from './StorageOptionsGrid';
|
||||
import AutoRefreshToggle from './AutoRefreshToggle';
|
||||
@@ -47,37 +47,42 @@ export default function Index() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-3.5">
|
||||
<StorageOptionsGrid
|
||||
options={options}
|
||||
sizes={sizes}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
onOptionChange={handleOptionChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
{/* 操作区域卡片 */}
|
||||
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
||||
<StorageOptionsGrid
|
||||
options={options}
|
||||
sizes={sizes}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
onOptionChange={handleOptionChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
<AutoRefreshToggle
|
||||
reloadAfterClean={reloadAfterClean}
|
||||
onChange={handleReloadAfterCleanChange}
|
||||
/>
|
||||
<AutoRefreshToggle
|
||||
reloadAfterClean={reloadAfterClean}
|
||||
onChange={handleReloadAfterCleanChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="default"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isButtonDisabled}
|
||||
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('storageCleaner:cleaning')}
|
||||
</>
|
||||
) : (
|
||||
t('storageCleaner:cleanNow')
|
||||
)}
|
||||
</Button>
|
||||
<div className="px-3.5 pb-3.5 pt-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="default"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isButtonDisabled}
|
||||
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('storageCleaner:cleaning')}
|
||||
</>
|
||||
) : (
|
||||
t('storageCleaner:cleanNow')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CleaningResult result={result} />
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getSessionStorageSize,
|
||||
isRestrictedUrl,
|
||||
} from '@/utils/storageCleaner';
|
||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -34,18 +33,22 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||
selectedTypes: DEFAULT_OPTIONS,
|
||||
};
|
||||
|
||||
export interface StorageSizeInfo {
|
||||
value: number;
|
||||
displayType: 'bytes' | 'count';
|
||||
}
|
||||
|
||||
export interface UseStorageCleanerReturn {
|
||||
domain: string;
|
||||
error: string;
|
||||
isInitializing: boolean;
|
||||
options: StorageCleanerOptions;
|
||||
sizes: Record<string, number>;
|
||||
sizes: Record<string, StorageSizeInfo>;
|
||||
reloadAfterClean: boolean;
|
||||
loading: boolean;
|
||||
result: CleaningResult | null;
|
||||
showConfirm: boolean;
|
||||
setShowConfirm: (show: boolean) => void;
|
||||
totalSize: number;
|
||||
totalBytes: number;
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
|
||||
@@ -57,11 +60,10 @@ export interface UseStorageCleanerReturn {
|
||||
|
||||
export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
const { t } = useI18n(['storageCleaner', 'common']);
|
||||
const [domain, setDomain] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
|
||||
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
@@ -102,7 +104,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
setError('');
|
||||
const url = tab.url;
|
||||
const tabId = tab.id!;
|
||||
setDomain(new URL(url).hostname);
|
||||
|
||||
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
||||
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
||||
@@ -122,12 +123,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
}
|
||||
|
||||
setSizes({
|
||||
cookies: cSize,
|
||||
localStorage: lsSize,
|
||||
sessionStorage: ssSize,
|
||||
indexedDB: idbSize,
|
||||
cacheStorage: cacheCount,
|
||||
serviceWorkers: swCount,
|
||||
cookies: { value: cSize, displayType: 'bytes' },
|
||||
localStorage: { value: lsSize, displayType: 'bytes' },
|
||||
sessionStorage: { value: ssSize, displayType: 'bytes' },
|
||||
indexedDB: { value: idbSize, displayType: 'bytes' },
|
||||
cacheStorage: { value: cacheCount, displayType: 'count' },
|
||||
serviceWorkers: { value: swCount, displayType: 'count' },
|
||||
});
|
||||
} finally {
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
@@ -217,9 +218,9 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||
setResult(cleaningResult);
|
||||
|
||||
if (reloadAfterClean && cleaningResult.success) {
|
||||
if (reloadAfterClean && cleaningResult.overallSuccess) {
|
||||
toast.success(t('storageCleaner:cleanSuccessReload'));
|
||||
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
||||
await chrome.tabs.reload(tab.id);
|
||||
} else {
|
||||
await loadInfo();
|
||||
}
|
||||
@@ -231,13 +232,10 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
}
|
||||
}, [options, reloadAfterClean, loadInfo, t]);
|
||||
|
||||
const totalSize = useMemo(() => {
|
||||
return (
|
||||
(sizes.cookies || 0) +
|
||||
(sizes.localStorage || 0) +
|
||||
(sizes.sessionStorage || 0) +
|
||||
(sizes.indexedDB || 0)
|
||||
);
|
||||
const totalBytes = useMemo(() => {
|
||||
return Object.values(sizes).reduce((acc, s) => {
|
||||
return s.displayType === 'bytes' ? acc + (s.value || 0) : acc;
|
||||
}, 0);
|
||||
}, [sizes]);
|
||||
|
||||
const selectionMetrics = useMemo(() => {
|
||||
@@ -248,7 +246,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
domain,
|
||||
error,
|
||||
isInitializing,
|
||||
options,
|
||||
@@ -258,7 +255,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
totalSize,
|
||||
totalBytes,
|
||||
allSelected: selectionMetrics.all,
|
||||
someSelected: selectionMetrics.some,
|
||||
handleReloadAfterCleanChange,
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { useTextStatistics } from './useTextStatistics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n('textStatistics');
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
setText(payload);
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
||||
|
||||
const stats = useMemo(() => getTextStats(text), [text]);
|
||||
const { text, stats, setText } = useTextStatistics();
|
||||
|
||||
const statItems = [
|
||||
{ label: t('textStatistics:characters'), value: stats.characters },
|
||||
{ label: t('textStatistics:words'), value: stats.words },
|
||||
{ label: t('textStatistics:lines'), value: stats.lines },
|
||||
{ label: t('textStatistics:bytes'), value: formatByteSize(stats.bytes) },
|
||||
{ label: t('textStatistics:bytes'), value: formatBytes(stats.bytes) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full space-y-4">
|
||||
{/* 文本输入区域 */}
|
||||
<TextInputArea
|
||||
value={text}
|
||||
onChange={setText}
|
||||
@@ -37,7 +27,6 @@ export default function Index() {
|
||||
allowCopy={true}
|
||||
/>
|
||||
|
||||
{/* 统计结果展示区域 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{statItems.map((item) => (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { getTextStats, type TextStats } from '@/utils/textStatistics';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
|
||||
export interface UseTextStatisticsReturn {
|
||||
text: string;
|
||||
stats: TextStats;
|
||||
setText: (text: string) => void;
|
||||
}
|
||||
|
||||
export function useTextStatistics(): UseTextStatisticsReturn {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
setText(payload);
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
|
||||
|
||||
const stats = getTextStats(text);
|
||||
|
||||
return { text, stats, setText };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import type { UnitType } from './constants';
|
||||
import { msToUnit } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -11,42 +12,20 @@ interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onUseNow: (val: number) => void;
|
||||
}
|
||||
|
||||
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
||||
export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) {
|
||||
const { t } = useI18n('timestamp');
|
||||
const { showMessage } = useSnackbar();
|
||||
const onUseNowRef = useRef(onUseNow);
|
||||
|
||||
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
||||
const initNow = Date.now();
|
||||
return {
|
||||
rawTime: initNow,
|
||||
text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onUseNowRef.current = onUseNow;
|
||||
}, [onUseNow]);
|
||||
const [rawTime, setRawTime] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const rightNow = Date.now();
|
||||
const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000)));
|
||||
|
||||
setCurrentDisplay((prev) => {
|
||||
if (prev.text === nextText) return prev;
|
||||
return { rawTime: rightNow, text: nextText };
|
||||
});
|
||||
setRawTime(Date.now());
|
||||
};
|
||||
|
||||
const tickId = setInterval(tick, 200);
|
||||
return () => clearInterval(tickId);
|
||||
}, [unit]);
|
||||
|
||||
const handleUseNow = useCallback(() => {
|
||||
onUseNowRef.current(currentDisplay.rawTime);
|
||||
showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
|
||||
}, [currentDisplay.rawTime, showMessage, t]);
|
||||
const text = String(msToUnit(rawTime, unit));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -61,12 +40,15 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
||||
</span>
|
||||
|
||||
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
||||
{currentDisplay.text}
|
||||
{text}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseNow}
|
||||
onClick={() => {
|
||||
onUseNow(rawTime);
|
||||
toast.success(t('timestamp:usedSuccess'));
|
||||
}}
|
||||
title={t('timestamp:useNowTooltip')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -74,14 +56,10 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
||||
</button>
|
||||
|
||||
<CopyButton
|
||||
text={currentDisplay.text}
|
||||
text={text}
|
||||
tooltip={t('timestamp:copyTsTooltip')}
|
||||
className="h-7 w-7 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
|
||||
export default LiveClock;
|
||||
}
|
||||
|
||||
@@ -1,119 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import React from 'react';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import type { UnitType } from './constants';
|
||||
import { DATE_FORMAT } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
result: string;
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
unit: UnitType;
|
||||
zone: string;
|
||||
|
||||
showEmptyPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
const ResultView = React.memo(
|
||||
({
|
||||
result,
|
||||
mode,
|
||||
unit,
|
||||
zone,
|
||||
showEmptyPlaceholder = false,
|
||||
className,
|
||||
...props
|
||||
}: ResultViewProps) => {
|
||||
const { t } = useI18n('timestamp');
|
||||
|
||||
const extraInfo = useMemo(() => {
|
||||
if (!result) return null;
|
||||
const d =
|
||||
mode === 'ts2dt'
|
||||
? dayjs(result, DATE_FORMAT).tz(zone)
|
||||
: unit === 'ms'
|
||||
? dayjs(Number(result))
|
||||
: dayjs.unix(Number(result));
|
||||
|
||||
return {
|
||||
relative: d.fromNow(),
|
||||
iso: d.toISOString(),
|
||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
||||
};
|
||||
}, [result, mode, zone, unit]);
|
||||
|
||||
if (!result) {
|
||||
if (!showEmptyPlaceholder) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{t('timestamp:resultEmpty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function ResultView({
|
||||
result,
|
||||
showEmptyPlaceholder = false,
|
||||
className,
|
||||
...props
|
||||
}: ResultViewProps) {
|
||||
const { t } = useI18n('timestamp');
|
||||
|
||||
if (!result) {
|
||||
if (!showEmptyPlaceholder) return null;
|
||||
return (
|
||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
||||
{/* 顶部小标签 */}
|
||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{t('timestamp:resultLabel')}
|
||||
</span>
|
||||
|
||||
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
|
||||
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
||||
{result}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip={t('timestamp:copyResultTooltip')}
|
||||
className="h-8 w-8 rounded-md shrink-0 border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/40 p-4 rounded-xl border border-border/50 flex flex-col gap-3">
|
||||
{[
|
||||
{ label: t('timestamp:relativeTime'), value: extraInfo?.relative },
|
||||
{ label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true },
|
||||
{ label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1.5 py-0.5 border-b border-border/30 last:border-0 pb-2 sm:pb-0 last:pb-0"
|
||||
>
|
||||
<span className="text-muted-foreground font-semibold text-xs shrink-0 select-none">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs text-foreground/90 font-medium break-all text-left sm:text-right tabular-nums',
|
||||
item.isMono && 'font-mono text-[11px]',
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
{item.value && (
|
||||
<CopyButton
|
||||
text={item.value}
|
||||
tooltip={t('timestamp:copyTooltip')}
|
||||
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{t('timestamp:resultEmpty')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ResultView.displayName = 'ResultView';
|
||||
return (
|
||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{t('timestamp:resultLabel')}
|
||||
</span>
|
||||
|
||||
export default ResultView;
|
||||
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
|
||||
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
||||
{result}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip={t('timestamp:copyResultTooltip')}
|
||||
className="h-8 w-8 rounded-md shrink-0 border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
|
||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||
|
||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||
|
||||
export type ModeType = 'ts2dt' | 'dt2ts';
|
||||
export type UnitType = 'ms' | 's';
|
||||
export type ZoneType = (typeof ZONES)[number];
|
||||
|
||||
const DIVISORS: Record<UnitType, number> = { ms: 1, s: 1000 };
|
||||
|
||||
/** Convert milliseconds to display value based on unit. */
|
||||
export function msToUnit(ms: number, unit: UnitType): number {
|
||||
return Math.floor(ms / DIVISORS[unit]);
|
||||
}
|
||||
|
||||
/** Build a dayjs object from a numeric timestamp according to unit. */
|
||||
export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs {
|
||||
return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { ZONES } from './constants';
|
||||
import type { ModeType, UnitType, ZoneType } from './constants';
|
||||
import LiveClock from './LiveClock';
|
||||
import ResultView from './ResultView';
|
||||
import { useTimestampConverter } from './useTimestampConverter';
|
||||
@@ -15,6 +16,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const MODE_OPTIONS: { value: ModeType; label: string }[] = [
|
||||
{ value: 'ts2dt', label: 'timestamp:tsToDate' },
|
||||
{ value: 'dt2ts', label: 'timestamp:dateToTs' },
|
||||
];
|
||||
|
||||
const UNIT_OPTIONS: { value: UnitType; label: string }[] = [
|
||||
{ value: 'ms', label: 'timestamp:unitMs' },
|
||||
{ value: 's', label: 'timestamp:unitS' },
|
||||
];
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n('timestamp');
|
||||
|
||||
@@ -42,11 +53,8 @@ export default function Index() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<SwitchButtonGroup
|
||||
value={mode}
|
||||
options={[
|
||||
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
||||
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
||||
]}
|
||||
onChange={(newMode) => setMode(newMode as 'ts2dt' | 'dt2ts')}
|
||||
options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||
onChange={setMode}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -57,7 +65,7 @@ export default function Index() {
|
||||
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
||||
}
|
||||
value={input}
|
||||
onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className={cn(
|
||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
@@ -70,16 +78,13 @@ export default function Index() {
|
||||
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||
<SwitchButtonGroup
|
||||
value={unit}
|
||||
options={[
|
||||
{ value: 'ms', label: t('timestamp:unitMs') },
|
||||
{ value: 's', label: t('timestamp:unitS') },
|
||||
]}
|
||||
onChange={(v) => setUnit(v as 'ms' | 's')}
|
||||
options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||
onChange={setUnit}
|
||||
size="small"
|
||||
className="sm:w-auto shrink-0"
|
||||
/>
|
||||
|
||||
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
|
||||
<Select value={zone} onValueChange={(v: string) => setZone(v as ZoneType)}>
|
||||
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
||||
<SelectValue placeholder="选择时区" />
|
||||
</SelectTrigger>
|
||||
@@ -100,7 +105,7 @@ export default function Index() {
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
|
||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
|
||||
<ResultView result={result} showEmptyPlaceholder />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import type { UnitType, ZoneType } from './constants';
|
||||
import { DATE_FORMAT } from './constants';
|
||||
import type { UnitType, ZoneType, ModeType } from './constants';
|
||||
import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
|
||||
export interface UseTimestampConverterReturn {
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
mode: ModeType;
|
||||
input: string;
|
||||
unit: UnitType;
|
||||
zone: ZoneType;
|
||||
result: string;
|
||||
error: string;
|
||||
|
||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||
setMode: (mode: ModeType) => void;
|
||||
setInput: (value: string) => void;
|
||||
setUnit: (unit: UnitType) => void;
|
||||
setZone: (zone: ZoneType) => void;
|
||||
handleUseNow: (now: number) => void;
|
||||
}
|
||||
|
||||
const TIMESTAMP_REGEX = /^\d+$/;
|
||||
const MS_TIMESTAMP_MIN_LENGTH = 13;
|
||||
|
||||
function isTimestampLike(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return /^\d+$/.test(trimmed) && trimmed.length >= 10;
|
||||
return TIMESTAMP_REGEX.test(trimmed) && trimmed.length >= 10;
|
||||
}
|
||||
|
||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
const { t } = useI18n('timestamp');
|
||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||
const [mode, setMode] = useState<ModeType>('ts2dt');
|
||||
const [unit, setUnit] = useState<UnitType>('ms');
|
||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||
|
||||
const [input, setInput] = useState(() => String(Date.now()));
|
||||
|
||||
const conversionPipeline = useMemo(() => {
|
||||
const { result, error } = useMemo(() => {
|
||||
const rawInput = input.trim();
|
||||
if (!rawInput) return { result: '', error: '' };
|
||||
|
||||
@@ -42,7 +45,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
if (isNaN(num)) {
|
||||
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
||||
}
|
||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||
const d = dayjsFromTimestamp(num, unit);
|
||||
if (!d.isValid()) {
|
||||
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
||||
}
|
||||
@@ -53,19 +56,15 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
||||
}
|
||||
const ms = d.valueOf();
|
||||
const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000));
|
||||
return { result: outputTs, error: '' };
|
||||
return { result: String(msToUnit(ms, unit)), error: '' };
|
||||
}
|
||||
}, [input, mode, unit, zone, t]);
|
||||
|
||||
const { result, error } = conversionPipeline;
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
const handleContextMenuData = (payload: string) => {
|
||||
const trimmed = payload.trim();
|
||||
if (isTimestampLike(trimmed)) {
|
||||
setMode('ts2dt');
|
||||
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
||||
setUnit(detectedUnit);
|
||||
setUnit(trimmed.length >= MS_TIMESTAMP_MIN_LENGTH ? 'ms' : 's');
|
||||
setInput(trimmed);
|
||||
} else {
|
||||
const d = dayjs(trimmed);
|
||||
@@ -77,30 +76,24 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
setInput(trimmed);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||
|
||||
const handleUseNow = useCallback(
|
||||
(now: number) => {
|
||||
if (mode === 'ts2dt') {
|
||||
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||
} else {
|
||||
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||
}
|
||||
},
|
||||
[mode, unit, zone],
|
||||
);
|
||||
const handleUseNow = (now: number) => {
|
||||
if (mode === 'ts2dt') {
|
||||
setInput(String(msToUnit(now, unit)));
|
||||
} else {
|
||||
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetMode = useCallback(
|
||||
(newMode: 'ts2dt' | 'dt2ts') => {
|
||||
setMode(newMode);
|
||||
if (result && !error) {
|
||||
setInput(result);
|
||||
}
|
||||
},
|
||||
[result, error],
|
||||
);
|
||||
const handleSetMode = (newMode: ModeType) => {
|
||||
setMode(newMode);
|
||||
if (result && !error) {
|
||||
setInput(result);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
|
||||
Reference in New Issue
Block a user