Compare commits

..

4 Commits

Author SHA1 Message Date
rsgltzyd 6975e38f79 refactor: 重构 Tailwind 配置以支持模块化和共享主题
- 更新 components.json,修改 CSS 文件路径为 src/styles/shell.css。
- 移除 tailwind.config.js 中的冗余配置,改为导入 tailwind.shell.config.js。
- 新增 tailwind.pages.config.js 和 tailwind.shared.js,集中管理主题和样式配置。
- 更新多个组件以使用新的样式类和配置,提升代码一致性和可维护性。
2026-06-30 23:15:35 +08:00
rsgltzyd 17a4dbd8a7 refactor: 优化 RouterProvider 组件的数据加载逻辑
- 使用批量获取方法 `getMany` 替代多个单独的 `get` 调用,简化数据加载过程。
- 更新 `loadInitialData` 函数,确保从存储中获取的默认值更为一致。
- 修改测试用例以验证批量获取的功能,确保组件在不同情况下的正确性。
2026-06-30 22:56:31 +08:00
rsgltzyd 27e03ae038 refactor: 移除 RouterContainer 组件中的 isLoaded 状态检查
- 简化 RouterContainer 组件逻辑,直接渲染页面内容,无需等待加载状态。
- 更新测试用例,确保在组件挂载后直接渲染页面结构。
2026-06-30 22:51:38 +08:00
rsgltzyd c7f4305fc3 refactor: 重构 RouterContainer 组件以支持懒加载页面模块
- 移除不必要的 Suspense 组件,改为使用 LoadedPage 组件进行懒加载。
- 更新特性获取逻辑,使用 getFeatureByKey 函数替代直接查找。
- 新增 featureMeta.ts 文件,集中管理功能配置和相关方法。
- 实现 pageLoaders 以支持按需加载页面模块,提升性能和用户体验。
2026-06-30 22:45:32 +08:00
50 changed files with 511 additions and 204 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.js", "config": "tailwind.config.js",
"css": "src/index.css", "css": "src/styles/shell.css",
"baseColor": "slate", "baseColor": "slate",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
+1 -5
View File
@@ -57,11 +57,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className={cn(className, copied && 'text-emerald-500')} className={cn(className, copied && 'text-emerald-500')}
{...props} {...props}
> >
{copied ? ( {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<Check className="h-[1.2em] w-[1.2em]" />
) : (
<Copy className="h-[1.2em] w-[1.2em]" />
)}
</Button> </Button>
); );
}; };
+1 -3
View File
@@ -30,9 +30,7 @@ export function ErrorFallback({
<div <div
className={cn( className={cn(
'flex flex-col items-center justify-center', 'flex flex-col items-center justify-center',
isApp isApp ? 'mt-16 mx-auto max-w-md' : 'flex-1 p-6 min-h-[300px] fade-in-zoom-95',
? 'mt-16 mx-auto max-w-md'
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
className, className,
)} )}
> >
+1 -1
View File
@@ -46,7 +46,7 @@ function ToolPageSkeleton() {
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
<div className="flex-1" /> <div className="flex-1" />
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
</div> </div>
{/* 结果区域 */} {/* 结果区域 */}
+38 -17
View File
@@ -1,41 +1,63 @@
import { FEATURES } from '@/config/features'; import { getFeatureByKey } from '@/config/features';
import { loadPage } from '@/config/pageLoaders/index';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react'; import type { PageType } from '@/types/storage';
import { type ComponentType, useEffect, useState } from 'react';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
function LoadedPage({ pageKey }: { pageKey: PageType }) {
const [Page, setPage] = useState<ComponentType | null>(null);
useEffect(() => {
let cancelled = false;
loadPage(pageKey)
.then((mod) => {
if (!cancelled) {
setPage(() => mod.default);
}
})
.catch((err) => {
console.error('[Router Page Load Error]', err);
});
return () => {
cancelled = true;
};
}, [pageKey]);
if (!Page) {
return <PageSkeleton variant={pageKey === 'dashboard' ? 'dashboard' : 'tool'} />;
}
return <Page />;
}
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage } = useRouter();
const animationClass = const animationClass =
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
if (!isLoaded) { const currentFeature = getFeatureByKey(currentPage);
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
}
const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.component;
return ( return (
<div <div
key={currentPage} key={currentPage}
className={cn( className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto', 'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'scrollbar-gutter-stable motion-reduce:transition-none', 'motion-reduce:transition-none',
animationClass, animationClass,
)} )}
>
<Suspense
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
> >
<PageErrorBoundary resetKey={currentPage}> <PageErrorBoundary resetKey={currentPage}>
{MatchedComponent ? ( {currentFeature ? (
<MatchedComponent /> <LoadedPage key={currentPage} pageKey={currentPage} />
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300"> <div className="flex-1 flex flex-col items-center justify-center p-8 text-center fade-in-300">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" /> <AlertTriangle className="h-6 w-6" />
</div> </div>
@@ -46,7 +68,6 @@ export default function RouterContainer() {
</div> </div>
)} )}
</PageErrorBoundary> </PageErrorBoundary>
</Suspense>
</div> </div>
); );
} }
+1 -2
View File
@@ -24,8 +24,7 @@ const SIZE_CLASSES = {
large: 'text-base h-11 px-4 py-2 rounded-lg', large: 'text-base h-11 px-4 py-2 rounded-lg',
} as const; } as const;
const SELECTED_CLASSES = const SELECTED_CLASSES = 'bg-background text-foreground shadow-sm font-semibold fade-in-zoom-95';
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80'; const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
export default function SwitchButtonGroup<T extends string | number = string>({ export default function SwitchButtonGroup<T extends string | number = string>({
+10 -4
View File
@@ -267,7 +267,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<CopyButton text={value} tooltip="复制内容" size="sm" className="h-7 w-7 p-1" /> <CopyButton
text={value}
tooltip="复制内容"
variant="ghost"
size="iconSm"
className="text-muted-foreground hover:text-foreground"
/>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<Button <Button
@@ -275,8 +281,8 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onClick={handleClear} onClick={handleClear}
aria-label="清空" aria-label="清空"
variant="ghost" variant="ghost"
size="icon" size="iconSm"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -287,7 +293,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
</div> </div>
{displayError && ( {displayError && (
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150"> <p className="text-xs font-medium text-destructive px-0.5 fade-in-slide-top-1">
{displayError} {displayError}
</p> </p>
)} )}
@@ -9,12 +9,13 @@ const mockRouterValue = {
currentPage: 'dashboard' as PageType, currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[], visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[], pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(), navigateTo: vi.fn(),
syncNavigation: vi.fn(), syncNavigation: vi.fn(),
goHome: vi.fn(), goHome: vi.fn(),
setVisiblePages: vi.fn(), setVisiblePages: vi.fn(),
setPageOrder: vi.fn(), setPageOrder: vi.fn(),
recentlyUsedTools: [] as PageType[],
isLoaded: true,
}; };
vi.mock('@/providers/RouterProvider', () => ({ vi.mock('@/providers/RouterProvider', () => ({
@@ -32,16 +33,7 @@ describe('RouterContainer 组件', () => {
}; };
describe('渲染测试', () => { describe('渲染测试', () => {
it('isLoaded 为 false 时应渲染骨架屏', () => { it('mount 后应直接渲染页面结构(不等待 storage 加载)', () => {
mockRouterValue.isLoaded = false;
const { container } = renderWithProvider(<RouterContainer />);
// 骨架屏使用 animate-pulse 类
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
it('isLoaded 为 true 时应渲染页面内容', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />); const { container } = renderWithProvider(<RouterContainer />);
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument(); expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
@@ -77,8 +69,7 @@ describe('RouterContainer 组件', () => {
}); });
describe('页面级错误隔离', () => { describe('页面级错误隔离', () => {
it('PageErrorBoundary 应包裹在 Suspense 内层', () => { it('PageErrorBoundary 应包裹页面内容', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />); const { container } = renderWithProvider(<RouterContainer />);
+1
View File
@@ -21,6 +21,7 @@ const buttonVariants = cva(
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10',
iconSm: 'h-7 w-7',
}, },
}, },
defaultVariants: { defaultVariants: {
+2 -2
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 transition-opacity duration-200 data-[state=closed]:opacity-0 data-[state=open]:opacity-100',
className, className,
)} )}
{...props} {...props}
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100 sm:rounded-lg',
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
+12 -10
View File
@@ -67,19 +67,21 @@ describe('features 懒加载', () => {
expect(pageLoadTracker.loaded).toEqual([]); expect(pageLoadTracker.loaded).toEqual([]);
}); });
it('渲染 lazy 组件时才应加载对应页面模块', async () => { it('loadPage 应仅加载对应页面模块', async () => {
const React = await import('react'); const { loadPage } = await import('@/config/pageLoaders/index');
const { render, waitFor } = await import('@testing-library/react');
const { FEATURES } = await import('@/config/features');
const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.component; await loadPage('dashboard');
render(
React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)),
);
await waitFor(() => {
expect(pageLoadTracker.loaded).toEqual(['Dashboard']); expect(pageLoadTracker.loaded).toEqual(['Dashboard']);
}); });
it('loadPage 切换页面时不应加载无关页面模块', async () => {
const { loadPage } = await import('@/config/pageLoaders/index');
await loadPage('timestamp');
expect(pageLoadTracker.loaded).toEqual(['Timestamp']);
expect(pageLoadTracker.loaded).not.toContain('QrCode');
expect(pageLoadTracker.loaded).not.toContain('TestDataGenerator');
}); });
}); });
+1 -3
View File
@@ -19,13 +19,11 @@ describe('features', () => {
expect(feature).toHaveProperty('label'); expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('description'); expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('component'); expect(feature).not.toHaveProperty('component');
expect(typeof feature.key).toBe('string'); expect(typeof feature.key).toBe('string');
expect(typeof feature.label).toBe('string'); expect(typeof feature.label).toBe('string');
expect(typeof feature.description).toBe('string'); expect(typeof feature.description).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.defaultVisible).toBe('boolean');
expect(feature.component).toBeDefined();
expect(['function', 'object']).toContain(typeof feature.component);
if (feature.key !== 'dashboard') { if (feature.key !== 'dashboard') {
expect(feature).toHaveProperty('icon'); expect(feature).toHaveProperty('icon');
@@ -1,4 +1,4 @@
import { type ComponentType, lazy } from 'react'; import type { ComponentType } from 'react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { import {
@@ -15,18 +15,6 @@ import {
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
// 懒加载页面组件
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
const QrCodePage = lazy(() => import('@/pages/QrCode'));
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
label: string; label: string;
@@ -34,7 +22,6 @@ export interface FeatureConfig {
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>; icon?: ComponentType<LucideProps>;
defaultVisible: boolean; defaultVisible: boolean;
component: ComponentType;
} }
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
@@ -43,7 +30,6 @@ export const FEATURES: FeatureConfig[] = [
label: '仪表盘', label: '仪表盘',
description: '', description: '',
defaultVisible: true, defaultVisible: true,
component: DashboardPage,
}, },
{ {
key: 'timestamp', key: 'timestamp',
@@ -52,7 +38,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
component: TimestampPage,
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
@@ -61,7 +46,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
component: StorageCleanerPage,
}, },
{ {
key: 'qrCode', key: 'qrCode',
@@ -70,7 +54,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
component: QrCodePage,
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
@@ -79,7 +62,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
component: TextStatisticsPage,
}, },
{ {
key: 'jwt', key: 'jwt',
@@ -88,7 +70,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
component: JwtPage,
}, },
{ {
key: 'jsonTools', key: 'jsonTools',
@@ -97,7 +78,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
component: JsonToolsPage,
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
@@ -106,7 +86,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
component: Base64ConverterPage,
}, },
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
@@ -115,7 +94,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
component: RightClickRestorerPage,
}, },
{ {
key: 'testDataGenerator', key: 'testDataGenerator',
@@ -124,7 +102,6 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: FileSpreadsheet, icon: FileSpreadsheet,
defaultVisible: true, defaultVisible: true,
component: TestDataGeneratorPage,
}, },
]; ];
+9
View File
@@ -0,0 +1,9 @@
export type { FeatureConfig, PaletteColorKey } from '@/config/featureMeta';
export {
FEATURES,
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getEntryPointType,
getFeatureByKey,
} from '@/config/featureMeta';
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadBase64Converter(): Promise<{ default: ComponentType }> {
return import('@/pages/Base64Converter');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadDashboard(): Promise<{ default: ComponentType }> {
return import('@/pages/Dashboard');
}
+45
View File
@@ -0,0 +1,45 @@
import type { ComponentType } from 'react';
import type { PageType } from '@/types/storage';
type PageModule = { default: ComponentType };
let pagesStylesLoaded = false;
function ensurePagesStyles(): Promise<unknown> {
if (pagesStylesLoaded) {
return Promise.resolve();
}
pagesStylesLoaded = true;
return import('@/styles/pages.css');
}
export function loadPage(key: PageType): Promise<PageModule> {
return ensurePagesStyles().then(() => {
switch (key) {
case 'dashboard':
return import('@/config/pageLoaders/dashboard').then((m) => m.default());
case 'timestamp':
return import('@/config/pageLoaders/timestamp').then((m) => m.default());
case 'storageCleaner':
return import('@/config/pageLoaders/storageCleaner').then((m) => m.default());
case 'qrCode':
return import('@/config/pageLoaders/qrCode').then((m) => m.default());
case 'textStatistics':
return import('@/config/pageLoaders/textStatistics').then((m) => m.default());
case 'jwt':
return import('@/config/pageLoaders/jwt').then((m) => m.default());
case 'jsonTools':
return import('@/config/pageLoaders/jsonTools').then((m) => m.default());
case 'base64Converter':
return import('@/config/pageLoaders/base64Converter').then((m) => m.default());
case 'rightClickRestorer':
return import('@/config/pageLoaders/rightClickRestorer').then((m) => m.default());
case 'testDataGenerator':
return import('@/config/pageLoaders/testDataGenerator').then((m) => m.default());
default: {
const _exhaustive: never = key;
return Promise.reject(new Error(`Unknown page: ${String(_exhaustive)}`));
}
}
});
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadJsonTools(): Promise<{ default: ComponentType }> {
return import('@/pages/JsonTools');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadJwt(): Promise<{ default: ComponentType }> {
return import('@/pages/Jwt');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadQrCode(): Promise<{ default: ComponentType }> {
return import('@/pages/QrCode');
}
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadRightClickRestorer(): Promise<{ default: ComponentType }> {
return import('@/pages/RightClickRestorer');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadStorageCleaner(): Promise<{ default: ComponentType }> {
return import('@/pages/StorageCleaner');
}
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTestDataGenerator(): Promise<{ default: ComponentType }> {
return import('@/pages/TestDataGenerator');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTextStatistics(): Promise<{ default: ComponentType }> {
return import('@/pages/TextStatistics');
}
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from 'react';
export default function loadTimestamp(): Promise<{ default: ComponentType }> {
return import('@/pages/Timestamp');
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function App() {
visiblePagesKey={routerConfig.visiblePagesKey} visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey} pageOrderKey={routerConfig.pageOrderKey}
> >
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0"> <div className="flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar /> <TopBar />
<ErrorBoundary> <ErrorBoundary>
<RouterContainer /> <RouterContainer />
+1 -1
View File
@@ -1,6 +1,6 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/styles/shell.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+1 -1
View File
@@ -15,7 +15,7 @@ export default function App() {
return ( return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute"> <RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app flex flex-col h-screen w-full overflow-hidden"> <div className="flex flex-col h-screen w-full overflow-hidden">
<TopBar /> <TopBar />
<ErrorBoundary> <ErrorBoundary>
<RouterContainer /> <RouterContainer />
+1 -1
View File
@@ -1,6 +1,6 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/styles/shell.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+1 -1
View File
@@ -20,7 +20,7 @@ export default function SearchDropdown({
const items = isSearching ? searchResults : recentFeatures; const items = isSearching ? searchResults : recentFeatures;
return ( return (
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in slide-in-from-top-2 duration-150"> <div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg fade-in-slide-top-2">
<ul role="listbox" className="p-1.5"> <ul role="listbox" className="p-1.5">
{!isSearching && items.length > 0 && ( {!isSearching && items.length > 0 && (
<li className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60"> <li className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
@@ -44,7 +44,7 @@ const QrCodePreview = ({
<img <img
src={qrCodeDataUrl} src={qrCodeDataUrl}
alt="QR Code Preview" alt="QR Code Preview"
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300" className="w-48 h-48 max-w-full object-contain block fade-in-300"
/> />
</div> </div>
+16 -12
View File
@@ -110,13 +110,19 @@ export function RouterProvider({
const loadInitialData = useCallback(async () => { const loadInitialData = useCallback(async () => {
try { try {
const savedRoute = await storageUtil.get(syncKey, defaultRoute); const stored = await storageUtil.getMany([
const savedVisiblePages = await storageUtil.get( syncKey,
visiblePagesKey, visiblePagesKey,
getDefaultVisibleFeatureKeys(), pageOrderKey,
); 'app/recentlyUsedTools',
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder()); 'contextMenu/pendingData',
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); ]);
const savedRoute = (stored[syncKey] ?? defaultRoute) as PageType;
const savedVisiblePages = (stored[visiblePagesKey] ??
getDefaultVisibleFeatureKeys()) as PageType[];
const savedPageOrder = (stored[pageOrderKey] ?? getDefaultPageOrder()) as PageType[];
const savedRecentTools = (stored['app/recentlyUsedTools'] ?? []) as PageType[];
if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) { if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
setCurrentPage(savedRoute); setCurrentPage(savedRoute);
@@ -131,8 +137,11 @@ export function RouterProvider({
setRecentlyUsedTools(savedRecentTools); setRecentlyUsedTools(savedRecentTools);
} }
canPersistRef.current = true; canPersistRef.current = true;
return stored['contextMenu/pendingData'];
} catch (error) { } catch (error) {
console.error('[Router Init Error] Core data fetch failed:', error); console.error('[Router Init Error] Core data fetch failed:', error);
return undefined;
} finally { } finally {
setIsLoaded(true); setIsLoaded(true);
} }
@@ -143,7 +152,7 @@ export function RouterProvider({
// eslint-disable-next-line react-hooks/set-state-in-effect -- Valid async data loading pattern on mount // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid async data loading pattern on mount
loadInitialData() loadInitialData()
.then(() => { .then((pendingData) => {
if (cancelled) return; if (cancelled) return;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -165,9 +174,6 @@ export function RouterProvider({
} }
} }
storageUtil
.get('contextMenu/pendingData')
.then((pendingData) => {
if ( if (
pendingData && pendingData &&
isValidPage(pendingData.featureKey) && isValidPage(pendingData.featureKey) &&
@@ -177,8 +183,6 @@ export function RouterProvider({
} }
}) })
.catch(console.error); .catch(console.error);
})
.catch(console.error);
return () => { return () => {
cancelled = true; cancelled = true;
+5
View File
@@ -13,6 +13,7 @@ import { storageUtil } from '@/utils/chromeStorage';
import { import {
applyResolvedThemeClass, applyResolvedThemeClass,
getThemeSyncSnapshot, getThemeSyncSnapshot,
hasThemeSyncSnapshot,
isValidThemeMode, isValidThemeMode,
persistThemeModeSnapshot, persistThemeModeSnapshot,
resolveThemeMode, resolveThemeMode,
@@ -68,6 +69,10 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
); );
useEffect(() => { useEffect(() => {
if (hasThemeSyncSnapshot()) {
return;
}
let cancelled = false; let cancelled = false;
storageUtil storageUtil
+43 -37
View File
@@ -7,10 +7,24 @@ import { storageUtil } from '@/utils/chromeStorage';
vi.mock('@/utils/chromeStorage', () => ({ vi.mock('@/utils/chromeStorage', () => ({
storageUtil: { storageUtil: {
get: vi.fn(), get: vi.fn(),
getMany: vi.fn(),
set: vi.fn(() => Promise.resolve()), set: vi.fn(() => Promise.resolve()),
}, },
})); }));
/** 模拟批量 storage 读取,未指定的键由 Router 侧使用默认值 */
function mockStorageBatch(map: Record<string, unknown> = {}) {
(storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation((keys: string[]) => {
const result: Record<string, unknown> = {};
for (const key of keys) {
if (key in map) {
result[key] = map[key];
}
}
return Promise.resolve(result);
});
}
const TestComponent = () => { const TestComponent = () => {
const { currentPage, navigateTo, visiblePages, pageOrder } = useRouter(); const { currentPage, navigateTo, visiblePages, pageOrder } = useRouter();
return ( return (
@@ -46,9 +60,7 @@ describe('RouterProvider', () => {
}); });
it('应该使用默认值初始化路由', async () => { it('应该使用默认值初始化路由', async () => {
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => mockStorageBatch();
Promise.resolve(defaultValue),
);
render( render(
<RouterProvider defaultRoute="dashboard"> <RouterProvider defaultRoute="dashboard">
@@ -59,13 +71,18 @@ describe('RouterProvider', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard'); expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
}); });
expect(storageUtil.getMany).toHaveBeenCalledWith([
'app/currentRoute',
'app/visiblePages',
'app/pageOrder',
'app/recentlyUsedTools',
'contextMenu/pendingData',
]);
}); });
it('应该从指定的 syncKey 加载路由', async () => { it('应该从指定的 syncKey 加载路由', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { mockStorageBatch({ 'app/sidepanelRoute': 'timestamp' });
if (key === 'app/sidepanelRoute') return Promise.resolve('timestamp');
return Promise.resolve(defaultValue);
});
render( render(
<RouterProvider syncKey="app/sidepanelRoute"> <RouterProvider syncKey="app/sidepanelRoute">
@@ -79,9 +96,7 @@ describe('RouterProvider', () => {
}); });
it('导航时应该更新指定的 syncKey', async () => { it('导航时应该更新指定的 syncKey', async () => {
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => mockStorageBatch();
Promise.resolve(defaultValue),
);
render( render(
<RouterProvider syncKey="app/popupRoute"> <RouterProvider syncKey="app/popupRoute">
@@ -104,10 +119,7 @@ describe('RouterProvider', () => {
}); });
it('应该支持独立的标签页路由同步', async () => { it('应该支持独立的标签页路由同步', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { mockStorageBatch({ 'app/tabRoute': 'qrCode' });
if (key === 'app/tabRoute') return Promise.resolve('qrCode');
return Promise.resolve(defaultValue);
});
render( render(
<RouterProvider syncKey="app/tabRoute"> <RouterProvider syncKey="app/tabRoute">
@@ -121,11 +133,9 @@ describe('RouterProvider', () => {
}); });
it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => { it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { mockStorageBatch({
if (key === 'app/sidepanelVisiblePages') 'app/sidepanelVisiblePages': ['timestamp', 'storageCleaner'],
return Promise.resolve(['timestamp', 'storageCleaner']); 'app/sidepanelPageOrder': ['storageCleaner', 'timestamp'],
if (key === 'app/sidepanelPageOrder') return Promise.resolve(['storageCleaner', 'timestamp']);
return Promise.resolve(defaultValue);
}); });
render( render(
@@ -167,10 +177,9 @@ describe('RouterProvider', () => {
'jsonTools', 'jsonTools',
]; ];
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { mockStorageBatch({
if (key === 'app/popupVisiblePages') return Promise.resolve(oldVisiblePages); 'app/popupVisiblePages': oldVisiblePages,
if (key === 'app/popupPageOrder') return Promise.resolve(oldPageOrder); 'app/popupPageOrder': oldPageOrder,
return Promise.resolve(defaultValue);
}); });
render( render(
@@ -209,9 +218,7 @@ describe('RouterProvider', () => {
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages)); localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages));
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder)); localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder));
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => mockStorageBatch();
Promise.resolve(defaultValue),
);
render( render(
<RouterProvider> <RouterProvider>
@@ -226,9 +233,7 @@ describe('RouterProvider', () => {
}); });
it('storage.onChanged 同步 visiblePages 时应合并缺失的新功能', async () => { it('storage.onChanged 同步 visiblePages 时应合并缺失的新功能', async () => {
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: unknown) => mockStorageBatch();
Promise.resolve(defaultValue),
);
render( render(
<RouterProvider visiblePagesKey="app/popupVisiblePages" pageOrderKey="app/popupPageOrder"> <RouterProvider visiblePagesKey="app/popupVisiblePages" pageOrderKey="app/popupPageOrder">
@@ -299,9 +304,9 @@ describe('RouterProvider', () => {
resolveGet = resolve; resolveGet = resolve;
}); });
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { (storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(async () => {
await getBlocked; await getBlocked;
return storage.get(key) ?? defaultValue; return Object.fromEntries(storage.entries());
}); });
(storageUtil.set as any).mockImplementation(async (key: string, value: unknown) => { (storageUtil.set as any).mockImplementation(async (key: string, value: unknown) => {
storage.set(key, value); storage.set(key, value);
@@ -324,7 +329,9 @@ describe('RouterProvider', () => {
}); });
it('初始化失败时仍应解除加载状态以便渲染页面', async () => { it('初始化失败时仍应解除加载状态以便渲染页面', async () => {
(storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable')); (storageUtil.getMany as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Storage unavailable'),
);
render( render(
<RouterProvider> <RouterProvider>
@@ -344,10 +351,9 @@ describe('RouterProvider', () => {
resolveGet = resolve; resolveGet = resolve;
}); });
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { (storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(async () => {
await getBlocked; await getBlocked;
if (key === 'app/currentRoute') return 'dashboard'; return { 'app/currentRoute': 'dashboard' };
return defaultValue;
}); });
render( render(
@@ -374,7 +380,7 @@ describe('RouterProvider', () => {
resolveStorage = resolve; resolveStorage = resolve;
}); });
(storageUtil.get as any).mockImplementation(() => storagePromise); (storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(() => storagePromise);
const { unmount } = render( const { unmount } = render(
<RouterProvider> <RouterProvider>
@@ -383,7 +389,7 @@ describe('RouterProvider', () => {
); );
unmount(); unmount();
resolveStorage!('dashboard'); resolveStorage!({});
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -46,6 +46,7 @@ describe('ThemeModeProvider', () => {
expect(screen.getByTestId('mode')).toHaveTextContent('dark'); expect(screen.getByTestId('mode')).toHaveTextContent('dark');
expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark'); expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(storageUtil.get).not.toHaveBeenCalled();
}); });
it('异步恢复 storage 后应写回 snapshot', async () => { it('异步恢复 storage 后应写回 snapshot', async () => {
+3
View File
@@ -0,0 +1,3 @@
@config "../../tailwind.pages.config.js";
@tailwind utilities;
+90
View File
@@ -1,3 +1,5 @@
@config "../../tailwind.shell.config.js";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -90,3 +92,91 @@
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
} }
@layer components {
.page-transition-dashboard {
@apply motion-reduce:transition-none;
}
.page-transition-enter {
@apply motion-reduce:transition-none;
animation: tt-page-enter 200ms ease-out;
}
}
@layer utilities {
.fade-in-150 {
animation: tt-fade-in 150ms ease-out forwards;
}
.fade-in-200 {
animation: tt-fade-in 200ms ease-out forwards;
}
.fade-in-300 {
animation: tt-fade-in 300ms ease-out forwards;
}
.fade-in-zoom-95 {
animation: tt-fade-in-zoom-95 150ms ease-out forwards;
}
.fade-in-slide-top-1 {
animation: tt-fade-in-slide-top-1 150ms ease-out forwards;
}
.fade-in-slide-top-2 {
animation: tt-fade-in-slide-top-2 150ms ease-out forwards;
}
}
@keyframes tt-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes tt-fade-in-zoom-95 {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tt-fade-in-slide-top-1 {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tt-fade-in-slide-top-2 {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tt-page-enter {
from {
opacity: 0.92;
}
to {
opacity: 1;
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.css' {
const css: string;
export default css;
}
+21
View File
@@ -75,6 +75,27 @@ describe('chromeStorage', () => {
}); });
}); });
describe('getMany', () => {
it('应该一次读取多个键', async () => {
(chrome.storage.local.get as any).mockResolvedValue({
'app/theme': 'dark',
'app/popupRoute': 'timestamp',
});
const result = await storageUtil.getMany(['app/theme', 'app/popupRoute']);
expect(result).toEqual({ 'app/theme': 'dark', 'app/popupRoute': 'timestamp' });
expect(chrome.storage.local.get).toHaveBeenCalledWith(['app/theme', 'app/popupRoute']);
});
it('空键数组应直接返回空对象', async () => {
const result = await storageUtil.getMany([]);
expect(result).toEqual({});
expect(chrome.storage.local.get).not.toHaveBeenCalled();
});
});
describe('get 类型签名', () => { describe('get 类型签名', () => {
it('无默认值时应推断为可选返回类型', () => { it('无默认值时应推断为可选返回类型', () => {
const _getWithoutDefault = () => storageUtil.get('app/theme'); const _getWithoutDefault = () => storageUtil.get('app/theme');
@@ -40,6 +40,7 @@ describe('useStorageState', () => {
// 初始值应从快照恢复,而非默认值 // 初始值应从快照恢复,而非默认值
expect(result.current[0]).toBe(false); expect(result.current[0]).toBe(false);
expect(storageUtil.get).not.toHaveBeenCalled();
}); });
it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => { it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => {
@@ -179,5 +180,6 @@ describe('useStorageState', () => {
await waitFor(() => { await waitFor(() => {
expect(storageUtil.set).not.toHaveBeenCalled(); expect(storageUtil.set).not.toHaveBeenCalled();
}); });
expect(storageUtil.get).not.toHaveBeenCalled();
}); });
}); });
+14 -1
View File
@@ -18,13 +18,26 @@ class StorageUtils {
key: K, key: K,
defaultValue?: StorageSchema[K], defaultValue?: StorageSchema[K],
): Promise<StorageSchema[K] | undefined> { ): Promise<StorageSchema[K] | undefined> {
const result = await chrome.storage.local.get([key]); const result = await this.getMany([key]);
if (defaultValue !== undefined) { if (defaultValue !== undefined) {
return (result[key] ?? defaultValue) as StorageSchema[K]; return (result[key] ?? defaultValue) as StorageSchema[K];
} }
return result[key] as StorageSchema[K] | undefined; return result[key] as StorageSchema[K] | undefined;
} }
/**
* 批量获取多个键(单次 IPC)
*/
async getMany<K extends keyof StorageSchema>(
keys: readonly K[],
): Promise<Partial<Pick<StorageSchema, K>>> {
if (keys.length === 0) {
return {};
}
const result = await chrome.storage.local.get([...keys]);
return result as Partial<Pick<StorageSchema, K>>;
}
/** /**
* 设置值 * 设置值
* @param key * @param key
+11
View File
@@ -1,3 +1,14 @@
/**
* 检查 localStorage 中是否存在指定键的快照
*/
export function hasSyncSnapshot(key: string): boolean {
try {
return localStorage.getItem(`snapshot/${key}`) !== null;
} catch {
return false;
}
}
/** /**
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁) * 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
*/ */
+9
View File
@@ -9,6 +9,15 @@ const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system'];
export const isValidThemeMode = (value: unknown): value is ThemeMode => export const isValidThemeMode = (value: unknown): value is ThemeMode =>
VALID_MODES.includes(value as ThemeMode); VALID_MODES.includes(value as ThemeMode);
/** 检查 localStorage 中是否存在主题快照 */
export const hasThemeSyncSnapshot = (): boolean => {
try {
return localStorage.getItem(THEME_MODE_SNAPSHOT_KEY) !== null;
} catch {
return false;
}
};
/** /**
* 同步读取 localStorage 快照(首屏防闪烁) * 同步读取 localStorage 快照(首屏防闪烁)
*/ */
+9 -1
View File
@@ -7,7 +7,7 @@ import {
type SetStateAction, type SetStateAction,
} from 'react'; } from 'react';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { getSyncSnapshot } from '@/utils/syncSnapshot'; import { getSyncSnapshot, hasSyncSnapshot } from '@/utils/syncSnapshot';
import type { StorageSchema } from '@/types/storage'; import type { StorageSchema } from '@/types/storage';
export const useStorageState = <K extends keyof StorageSchema>( export const useStorageState = <K extends keyof StorageSchema>(
@@ -34,6 +34,14 @@ export const useStorageState = <K extends keyof StorageSchema>(
let cancelled = false; let cancelled = false;
const loadState = async () => { const loadState = async () => {
if (hasSyncSnapshot(key as string)) {
if (!cancelled) {
setIsInitialized(true);
hasLoadedFromStorage.current = true;
}
return;
}
try { try {
const savedValue = await storageUtil.get(key, defaultValue); const savedValue = await storageUtil.get(key, defaultValue);
if (cancelled) return; if (cancelled) return;
+1 -34
View File
@@ -1,34 +1 @@
/** @type {import('tailwindcss').Config} */ export { default } from './tailwind.shell.config.js';
export default {
darkMode: 'class',
content: ['./entrypoints/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))',
'card-foreground': 'hsl(var(--card-foreground))',
popover: 'hsl(var(--popover))',
'popover-foreground': 'hsl(var(--popover-foreground))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
secondary: 'hsl(var(--secondary))',
'secondary-foreground': 'hsl(var(--secondary-foreground))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
destructive: 'hsl(var(--destructive))',
'destructive-foreground': 'hsl(var(--destructive-foreground))',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
},
borderRadius: {
DEFAULT: 'var(--radius)',
},
},
},
plugins: [],
};
+22
View File
@@ -0,0 +1,22 @@
import { sharedTheme } from './tailwind.shared.js';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx}',
'./src/components/CopyButton.tsx',
'./src/components/TextInputArea.tsx',
'./src/components/SwitchButtonGroup.tsx',
'./src/components/EmptyPlaceholder.tsx',
'./src/components/ui/badge.tsx',
'./src/components/ui/checkbox.tsx',
'./src/components/ui/dialog.tsx',
'./src/components/ui/label.tsx',
'./src/components/ui/select.tsx',
'./src/components/ui/switch.tsx',
'./src/styles/pages.css',
],
theme: sharedTheme,
plugins: [],
};
+29
View File
@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config['theme']} */
export const sharedTheme = {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))',
'card-foreground': 'hsl(var(--card-foreground))',
popover: 'hsl(var(--popover))',
'popover-foreground': 'hsl(var(--popover-foreground))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
secondary: 'hsl(var(--secondary))',
'secondary-foreground': 'hsl(var(--secondary-foreground))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
destructive: 'hsl(var(--destructive))',
'destructive-foreground': 'hsl(var(--destructive-foreground))',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
},
borderRadius: {
DEFAULT: 'var(--radius)',
},
},
};
+22
View File
@@ -0,0 +1,22 @@
import { sharedTheme } from './tailwind.shared.js';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./src/entrypoints/**/*.{js,ts,jsx,tsx}',
'./src/layout/**/*.{js,ts,jsx,tsx}',
'./src/providers/**/*.{js,ts,jsx,tsx}',
'./src/components/RouterContainer.tsx',
'./src/components/ErrorBoundary.tsx',
'./src/components/ErrorFallback.tsx',
'./src/components/PageErrorBoundary.tsx',
'./src/components/PageSkeleton.tsx',
'./src/components/ui/button.tsx',
'./src/components/ui/input.tsx',
'./src/components/ui/sonner.tsx',
'./src/styles/shell.css',
],
theme: sharedTheme,
plugins: [],
};
+5 -8
View File
@@ -22,14 +22,10 @@ function manualChunksForHtmlOnly(): Plugin {
) { ) {
return 'vendor-react'; return 'vendor-react';
} }
// 二维码活态感知依赖分流
if (id.includes('qr-scanner') || id.includes('qrious')) { // qrious / qr-scanner / @dnd-kit 不单独拆 vendor chunk
return 'vendor-qr'; // 独立 vendor 会与 Vite preload 辅助函数共 chunk,被 App Shell 静态拉取。
} // 这些依赖随各自页面 async chunk 加载即可。
// 拖拽排序高级组件分流
if (id.includes('@dnd-kit')) {
return 'vendor-dnd';
}
return undefined; return undefined;
}, },
@@ -77,6 +73,7 @@ export default defineConfig({
vite: () => ({ vite: () => ({
plugins: [manualChunksForHtmlOnly()], plugins: [manualChunksForHtmlOnly()],
build: { build: {
modulePreload: false,
minify: 'terser', minify: 'terser',
terserOptions: { terserOptions: {
format: { ascii_only: true, comments: false }, format: { ascii_only: true, comments: false },