Compare commits
4 Commits
d18ae5d41c
...
6975e38f79
| Author | SHA1 | Date | |
|---|---|---|---|
| 6975e38f79 | |||
| 17a4dbd8a7 | |||
| 27e03ae038 | |||
| c7f4305fc3 |
+1
-1
@@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"css": "src/styles/shell.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
|
||||
@@ -57,11 +57,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
className={cn(className, copied && 'text-emerald-500')}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em]" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,9 +30,7 @@ export function ErrorFallback({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center',
|
||||
isApp
|
||||
? 'mt-16 mx-auto max-w-md'
|
||||
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
|
||||
isApp ? 'mt-16 mx-auto max-w-md' : 'flex-1 p-6 min-h-[300px] fade-in-zoom-95',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@ function ToolPageSkeleton() {
|
||||
<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="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>
|
||||
|
||||
{/* 结果区域 */}
|
||||
|
||||
@@ -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 { Suspense } from 'react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { type ComponentType, useEffect, useState } from 'react';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
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() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
const { currentPage } = useRouter();
|
||||
|
||||
const animationClass =
|
||||
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
|
||||
if (!isLoaded) {
|
||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
const MatchedComponent = currentFeature?.component;
|
||||
const currentFeature = getFeatureByKey(currentPage);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currentPage}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||
'scrollbar-gutter-stable motion-reduce:transition-none',
|
||||
'motion-reduce:transition-none',
|
||||
animationClass,
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||
>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{MatchedComponent ? (
|
||||
<MatchedComponent />
|
||||
{currentFeature ? (
|
||||
<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">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
@@ -46,7 +68,6 @@ export default function RouterContainer() {
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ const SIZE_CLASSES = {
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
} as const;
|
||||
|
||||
const SELECTED_CLASSES =
|
||||
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
|
||||
const SELECTED_CLASSES = 'bg-background text-foreground shadow-sm font-semibold fade-in-zoom-95';
|
||||
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
|
||||
|
||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
|
||||
@@ -267,7 +267,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
{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 && (
|
||||
<Button
|
||||
@@ -275,8 +281,8 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
onClick={handleClear}
|
||||
aria-label="清空"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
size="iconSm"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -287,7 +293,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -9,12 +9,13 @@ const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goHome: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
recentlyUsedTools: [] as PageType[],
|
||||
isLoaded: true,
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
@@ -32,16 +33,7 @@ describe('RouterContainer 组件', () => {
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||
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;
|
||||
it('mount 后应直接渲染页面结构(不等待 storage 加载)', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||
@@ -77,8 +69,7 @@ describe('RouterContainer 组件', () => {
|
||||
});
|
||||
|
||||
describe('页面级错误隔离', () => {
|
||||
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
it('PageErrorBoundary 应包裹页面内容', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const buttonVariants = cva(
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
iconSm: 'h-7 w-7',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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' &&
|
||||
'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,
|
||||
|
||||
@@ -67,19 +67,21 @@ describe('features 懒加载', () => {
|
||||
expect(pageLoadTracker.loaded).toEqual([]);
|
||||
});
|
||||
|
||||
it('渲染 lazy 组件时才应加载对应页面模块', async () => {
|
||||
const React = await import('react');
|
||||
const { render, waitFor } = await import('@testing-library/react');
|
||||
const { FEATURES } = await import('@/config/features');
|
||||
it('loadPage 应仅加载对应页面模块', async () => {
|
||||
const { loadPage } = await import('@/config/pageLoaders/index');
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,13 +19,11 @@ describe('features', () => {
|
||||
expect(feature).toHaveProperty('label');
|
||||
expect(feature).toHaveProperty('description');
|
||||
expect(feature).toHaveProperty('defaultVisible');
|
||||
expect(feature).toHaveProperty('component');
|
||||
expect(feature).not.toHaveProperty('component');
|
||||
expect(typeof feature.key).toBe('string');
|
||||
expect(typeof feature.label).toBe('string');
|
||||
expect(typeof feature.description).toBe('string');
|
||||
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||
expect(feature.component).toBeDefined();
|
||||
expect(['function', 'object']).toContain(typeof feature.component);
|
||||
|
||||
if (feature.key !== 'dashboard') {
|
||||
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 { PageType } from '@/types/storage';
|
||||
import {
|
||||
@@ -15,18 +15,6 @@ import {
|
||||
|
||||
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 {
|
||||
key: PageType;
|
||||
label: string;
|
||||
@@ -34,7 +22,6 @@ export interface FeatureConfig {
|
||||
themeColorKey?: PaletteColorKey;
|
||||
icon?: ComponentType<LucideProps>;
|
||||
defaultVisible: boolean;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
export const FEATURES: FeatureConfig[] = [
|
||||
@@ -43,7 +30,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
label: '仪表盘',
|
||||
description: '',
|
||||
defaultVisible: true,
|
||||
component: DashboardPage,
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
@@ -52,7 +38,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'primary',
|
||||
icon: Clock,
|
||||
defaultVisible: true,
|
||||
component: TimestampPage,
|
||||
},
|
||||
{
|
||||
key: 'storageCleaner',
|
||||
@@ -61,7 +46,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'warning',
|
||||
icon: Database,
|
||||
defaultVisible: true,
|
||||
component: StorageCleanerPage,
|
||||
},
|
||||
{
|
||||
key: 'qrCode',
|
||||
@@ -70,7 +54,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'success',
|
||||
icon: QrCode,
|
||||
defaultVisible: true,
|
||||
component: QrCodePage,
|
||||
},
|
||||
{
|
||||
key: 'textStatistics',
|
||||
@@ -79,7 +62,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'secondary',
|
||||
icon: FileText,
|
||||
defaultVisible: true,
|
||||
component: TextStatisticsPage,
|
||||
},
|
||||
{
|
||||
key: 'jwt',
|
||||
@@ -88,7 +70,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'info',
|
||||
icon: Key,
|
||||
defaultVisible: true,
|
||||
component: JwtPage,
|
||||
},
|
||||
{
|
||||
key: 'jsonTools',
|
||||
@@ -97,7 +78,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'primary',
|
||||
icon: GitCompareArrows,
|
||||
defaultVisible: true,
|
||||
component: JsonToolsPage,
|
||||
},
|
||||
{
|
||||
key: 'base64Converter',
|
||||
@@ -106,7 +86,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'info',
|
||||
icon: ArrowLeftRight,
|
||||
defaultVisible: true,
|
||||
component: Base64ConverterPage,
|
||||
},
|
||||
{
|
||||
key: 'rightClickRestorer',
|
||||
@@ -115,7 +94,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'success',
|
||||
icon: MousePointerClick,
|
||||
defaultVisible: true,
|
||||
component: RightClickRestorerPage,
|
||||
},
|
||||
{
|
||||
key: 'testDataGenerator',
|
||||
@@ -124,7 +102,6 @@ export const FEATURES: FeatureConfig[] = [
|
||||
themeColorKey: 'warning',
|
||||
icon: FileSpreadsheet,
|
||||
defaultVisible: true,
|
||||
component: TestDataGeneratorPage,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export default function loadDashboard(): Promise<{ default: ComponentType }> {
|
||||
return import('@/pages/Dashboard');
|
||||
}
|
||||
@@ -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)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export default function loadJsonTools(): Promise<{ default: ComponentType }> {
|
||||
return import('@/pages/JsonTools');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export default function loadJwt(): Promise<{ default: ComponentType }> {
|
||||
return import('@/pages/Jwt');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export default function loadTextStatistics(): Promise<{ default: ComponentType }> {
|
||||
return import('@/pages/TextStatistics');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export default function loadTimestamp(): Promise<{ default: ComponentType }> {
|
||||
return import('@/pages/Timestamp');
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function App() {
|
||||
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||
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 />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppRoot from '@/providers/AppRoot';
|
||||
import '@/index.css';
|
||||
import '@/styles/shell.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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 />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppRoot from '@/providers/AppRoot';
|
||||
import '@/index.css';
|
||||
import '@/styles/shell.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function SearchDropdown({
|
||||
const items = isSearching ? searchResults : recentFeatures;
|
||||
|
||||
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">
|
||||
{!isSearching && items.length > 0 && (
|
||||
<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
|
||||
src={qrCodeDataUrl}
|
||||
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>
|
||||
|
||||
|
||||
@@ -110,13 +110,19 @@ export function RouterProvider({
|
||||
|
||||
const loadInitialData = useCallback(async () => {
|
||||
try {
|
||||
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
|
||||
const savedVisiblePages = await storageUtil.get(
|
||||
const stored = await storageUtil.getMany([
|
||||
syncKey,
|
||||
visiblePagesKey,
|
||||
getDefaultVisibleFeatureKeys(),
|
||||
);
|
||||
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
|
||||
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
|
||||
pageOrderKey,
|
||||
'app/recentlyUsedTools',
|
||||
'contextMenu/pendingData',
|
||||
]);
|
||||
|
||||
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) {
|
||||
setCurrentPage(savedRoute);
|
||||
@@ -131,8 +137,11 @@ export function RouterProvider({
|
||||
setRecentlyUsedTools(savedRecentTools);
|
||||
}
|
||||
canPersistRef.current = true;
|
||||
|
||||
return stored['contextMenu/pendingData'];
|
||||
} catch (error) {
|
||||
console.error('[Router Init Error] Core data fetch failed:', error);
|
||||
return undefined;
|
||||
} finally {
|
||||
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
|
||||
loadInitialData()
|
||||
.then(() => {
|
||||
.then((pendingData) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -165,9 +174,6 @@ export function RouterProvider({
|
||||
}
|
||||
}
|
||||
|
||||
storageUtil
|
||||
.get('contextMenu/pendingData')
|
||||
.then((pendingData) => {
|
||||
if (
|
||||
pendingData &&
|
||||
isValidPage(pendingData.featureKey) &&
|
||||
@@ -177,8 +183,6 @@ export function RouterProvider({
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { storageUtil } from '@/utils/chromeStorage';
|
||||
import {
|
||||
applyResolvedThemeClass,
|
||||
getThemeSyncSnapshot,
|
||||
hasThemeSyncSnapshot,
|
||||
isValidThemeMode,
|
||||
persistThemeModeSnapshot,
|
||||
resolveThemeMode,
|
||||
@@ -68,6 +69,10 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasThemeSyncSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
storageUtil
|
||||
|
||||
@@ -7,10 +7,24 @@ import { storageUtil } from '@/utils/chromeStorage';
|
||||
vi.mock('@/utils/chromeStorage', () => ({
|
||||
storageUtil: {
|
||||
get: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
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 { currentPage, navigateTo, visiblePages, pageOrder } = useRouter();
|
||||
return (
|
||||
@@ -46,9 +60,7 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('应该使用默认值初始化路由', async () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
mockStorageBatch();
|
||||
|
||||
render(
|
||||
<RouterProvider defaultRoute="dashboard">
|
||||
@@ -59,13 +71,18 @@ describe('RouterProvider', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
|
||||
});
|
||||
|
||||
expect(storageUtil.getMany).toHaveBeenCalledWith([
|
||||
'app/currentRoute',
|
||||
'app/visiblePages',
|
||||
'app/pageOrder',
|
||||
'app/recentlyUsedTools',
|
||||
'contextMenu/pendingData',
|
||||
]);
|
||||
});
|
||||
|
||||
it('应该从指定的 syncKey 加载路由', async () => {
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'app/sidepanelRoute') return Promise.resolve('timestamp');
|
||||
return Promise.resolve(defaultValue);
|
||||
});
|
||||
mockStorageBatch({ 'app/sidepanelRoute': 'timestamp' });
|
||||
|
||||
render(
|
||||
<RouterProvider syncKey="app/sidepanelRoute">
|
||||
@@ -79,9 +96,7 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('导航时应该更新指定的 syncKey', async () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
mockStorageBatch();
|
||||
|
||||
render(
|
||||
<RouterProvider syncKey="app/popupRoute">
|
||||
@@ -104,10 +119,7 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('应该支持独立的标签页路由同步', async () => {
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'app/tabRoute') return Promise.resolve('qrCode');
|
||||
return Promise.resolve(defaultValue);
|
||||
});
|
||||
mockStorageBatch({ 'app/tabRoute': 'qrCode' });
|
||||
|
||||
render(
|
||||
<RouterProvider syncKey="app/tabRoute">
|
||||
@@ -121,11 +133,9 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => {
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'app/sidepanelVisiblePages')
|
||||
return Promise.resolve(['timestamp', 'storageCleaner']);
|
||||
if (key === 'app/sidepanelPageOrder') return Promise.resolve(['storageCleaner', 'timestamp']);
|
||||
return Promise.resolve(defaultValue);
|
||||
mockStorageBatch({
|
||||
'app/sidepanelVisiblePages': ['timestamp', 'storageCleaner'],
|
||||
'app/sidepanelPageOrder': ['storageCleaner', 'timestamp'],
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -167,10 +177,9 @@ describe('RouterProvider', () => {
|
||||
'jsonTools',
|
||||
];
|
||||
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'app/popupVisiblePages') return Promise.resolve(oldVisiblePages);
|
||||
if (key === 'app/popupPageOrder') return Promise.resolve(oldPageOrder);
|
||||
return Promise.resolve(defaultValue);
|
||||
mockStorageBatch({
|
||||
'app/popupVisiblePages': oldVisiblePages,
|
||||
'app/popupPageOrder': oldPageOrder,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -209,9 +218,7 @@ describe('RouterProvider', () => {
|
||||
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages));
|
||||
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder));
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
mockStorageBatch();
|
||||
|
||||
render(
|
||||
<RouterProvider>
|
||||
@@ -226,9 +233,7 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('storage.onChanged 同步 visiblePages 时应合并缺失的新功能', async () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: unknown) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
mockStorageBatch();
|
||||
|
||||
render(
|
||||
<RouterProvider visiblePagesKey="app/popupVisiblePages" pageOrderKey="app/popupPageOrder">
|
||||
@@ -299,9 +304,9 @@ describe('RouterProvider', () => {
|
||||
resolveGet = resolve;
|
||||
});
|
||||
|
||||
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => {
|
||||
(storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
await getBlocked;
|
||||
return storage.get(key) ?? defaultValue;
|
||||
return Object.fromEntries(storage.entries());
|
||||
});
|
||||
(storageUtil.set as any).mockImplementation(async (key: string, value: unknown) => {
|
||||
storage.set(key, value);
|
||||
@@ -324,7 +329,9 @@ describe('RouterProvider', () => {
|
||||
});
|
||||
|
||||
it('初始化失败时仍应解除加载状态以便渲染页面', async () => {
|
||||
(storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable'));
|
||||
(storageUtil.getMany as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Storage unavailable'),
|
||||
);
|
||||
|
||||
render(
|
||||
<RouterProvider>
|
||||
@@ -344,10 +351,9 @@ describe('RouterProvider', () => {
|
||||
resolveGet = resolve;
|
||||
});
|
||||
|
||||
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => {
|
||||
(storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
await getBlocked;
|
||||
if (key === 'app/currentRoute') return 'dashboard';
|
||||
return defaultValue;
|
||||
return { 'app/currentRoute': 'dashboard' };
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -374,7 +380,7 @@ describe('RouterProvider', () => {
|
||||
resolveStorage = resolve;
|
||||
});
|
||||
|
||||
(storageUtil.get as any).mockImplementation(() => storagePromise);
|
||||
(storageUtil.getMany as ReturnType<typeof vi.fn>).mockImplementation(() => storagePromise);
|
||||
|
||||
const { unmount } = render(
|
||||
<RouterProvider>
|
||||
@@ -383,7 +389,7 @@ describe('RouterProvider', () => {
|
||||
);
|
||||
|
||||
unmount();
|
||||
resolveStorage!('dashboard');
|
||||
resolveStorage!({});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('ThemeModeProvider', () => {
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
|
||||
expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(storageUtil.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('异步恢复 storage 后应写回 snapshot', async () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@config "../../tailwind.pages.config.js";
|
||||
|
||||
@tailwind utilities;
|
||||
@@ -1,3 +1,5 @@
|
||||
@config "../../tailwind.shell.config.js";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -90,3 +92,91 @@
|
||||
::-webkit-scrollbar {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.css' {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
@@ -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 类型签名', () => {
|
||||
it('无默认值时应推断为可选返回类型', () => {
|
||||
const _getWithoutDefault = () => storageUtil.get('app/theme');
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('useStorageState', () => {
|
||||
|
||||
// 初始值应从快照恢复,而非默认值
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(storageUtil.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => {
|
||||
@@ -179,5 +180,6 @@ describe('useStorageState', () => {
|
||||
await waitFor(() => {
|
||||
expect(storageUtil.set).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(storageUtil.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,13 +18,26 @@ class StorageUtils {
|
||||
key: K,
|
||||
defaultValue?: StorageSchema[K],
|
||||
): Promise<StorageSchema[K] | undefined> {
|
||||
const result = await chrome.storage.local.get([key]);
|
||||
const result = await this.getMany([key]);
|
||||
if (defaultValue !== undefined) {
|
||||
return (result[key] ?? defaultValue) as StorageSchema[K];
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* 检查 localStorage 中是否存在指定键的快照
|
||||
*/
|
||||
export function hasSyncSnapshot(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(`snapshot/${key}`) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,15 @@ const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system'];
|
||||
export const isValidThemeMode = (value: unknown): value is 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 快照(首屏防闪烁)
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { getSyncSnapshot } from '@/utils/syncSnapshot';
|
||||
import { getSyncSnapshot, hasSyncSnapshot } from '@/utils/syncSnapshot';
|
||||
import type { StorageSchema } from '@/types/storage';
|
||||
|
||||
export const useStorageState = <K extends keyof StorageSchema>(
|
||||
@@ -34,6 +34,14 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
||||
let cancelled = false;
|
||||
|
||||
const loadState = async () => {
|
||||
if (hasSyncSnapshot(key as string)) {
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
hasLoadedFromStorage.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
if (cancelled) return;
|
||||
|
||||
+1
-34
@@ -1,34 +1 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
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: [],
|
||||
};
|
||||
export { default } from './tailwind.shell.config.js';
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -22,14 +22,10 @@ function manualChunksForHtmlOnly(): Plugin {
|
||||
) {
|
||||
return 'vendor-react';
|
||||
}
|
||||
// 二维码活态感知依赖分流
|
||||
if (id.includes('qr-scanner') || id.includes('qrious')) {
|
||||
return 'vendor-qr';
|
||||
}
|
||||
// 拖拽排序高级组件分流
|
||||
if (id.includes('@dnd-kit')) {
|
||||
return 'vendor-dnd';
|
||||
}
|
||||
|
||||
// qrious / qr-scanner / @dnd-kit 不单独拆 vendor chunk:
|
||||
// 独立 vendor 会与 Vite preload 辅助函数共 chunk,被 App Shell 静态拉取。
|
||||
// 这些依赖随各自页面 async chunk 加载即可。
|
||||
|
||||
return undefined;
|
||||
},
|
||||
@@ -77,6 +73,7 @@ export default defineConfig({
|
||||
vite: () => ({
|
||||
plugins: [manualChunksForHtmlOnly()],
|
||||
build: {
|
||||
modulePreload: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
format: { ascii_only: true, comments: false },
|
||||
|
||||
Reference in New Issue
Block a user