refactor: 移除设置页面
- 删除 options 入口点目录及相关文件 - 移除 wxt.config.ts 中 options_ui 配置 - 移除 TopBar 组件中的设置按钮和 onOpenOptions prop - 移除 popup/sidepanel App.tsx 中的 handleOpenOptions - 移除相关测试中的设置页面引用 - 移除 messages.json 中设置相关翻译键 - 通过 lint、typecheck、test 验证
This commit is contained in:
@@ -7,10 +7,7 @@
|
|||||||
"message": "清理",
|
"message": "清理",
|
||||||
"description": "Translation key: buttons_clear"
|
"description": "Translation key: buttons_clear"
|
||||||
},
|
},
|
||||||
"buttons_settings": {
|
|
||||||
"message": "设置",
|
|
||||||
"description": "Translation key: buttons_settings"
|
|
||||||
},
|
|
||||||
"messages_copySuccess": {
|
"messages_copySuccess": {
|
||||||
"message": "已复制到剪贴板",
|
"message": "已复制到剪贴板",
|
||||||
"description": "Translation key: messages_copySuccess"
|
"description": "Translation key: messages_copySuccess"
|
||||||
@@ -851,10 +848,7 @@
|
|||||||
"message": "在标签页打开",
|
"message": "在标签页打开",
|
||||||
"description": "Translation key: buttons_openInTab"
|
"description": "Translation key: buttons_openInTab"
|
||||||
},
|
},
|
||||||
"common_buttons_settings": {
|
|
||||||
"message": "设置",
|
|
||||||
"description": "Translation key: buttons_settings"
|
|
||||||
},
|
|
||||||
"common_errorBoundary_title": {
|
"common_errorBoundary_title": {
|
||||||
"message": "糟糕,出了点问题",
|
"message": "糟糕,出了点问题",
|
||||||
"description": "Translation key: errorBoundary_title"
|
"description": "Translation key: errorBoundary_title"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Settings, Sun, X } from 'lucide-react';
|
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||||
@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
|
|||||||
const SEARCH_HISTORY_LIMIT = 10;
|
const SEARCH_HISTORY_LIMIT = 10;
|
||||||
const SEARCH_HISTORY_DISPLAY = 5;
|
const SEARCH_HISTORY_DISPLAY = 5;
|
||||||
|
|
||||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
export default function TopBar() {
|
||||||
const { currentPage, goBack, navigateTo } = useRouter();
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
const { mode, setMode } = useThemeMode();
|
const { mode, setMode } = useThemeMode();
|
||||||
const { t } = useI18n(['common', 'features']);
|
const { t } = useI18n(['common', 'features']);
|
||||||
@@ -284,9 +284,6 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={onOpenOptions} title={t('common_buttons_settings')}>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,34 +53,21 @@ describe('TopBar 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
expect(screen.getByLabelText('返回')).toBeInTheDocument();
|
expect(screen.getByLabelText('返回')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'dashboard';
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染设置按钮', () => {
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.getByLabelText('设置')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('交互测试', () => {
|
describe('交互测试', () => {
|
||||||
it('点击设置按钮时应调用 onOpenOptions', () => {
|
|
||||||
const handleOpenOptions = vi.fn();
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('设置'));
|
|
||||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击返回按钮时应调用 goBack', () => {
|
it('点击返回按钮时应调用 goBack', () => {
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('返回'));
|
fireEvent.click(screen.getByLabelText('返回'));
|
||||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { GripVertical, RefreshCw } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
type DragEndEvent,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import type { PageType, StorageSchema } from '@/types/storage';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { PaletteColorKey } from '@/config/features';
|
|
||||||
import {
|
|
||||||
getAllFeatureKeys,
|
|
||||||
getDefaultPageOrder,
|
|
||||||
getDefaultVisibleFeatureKeys,
|
|
||||||
getFeatureByKey,
|
|
||||||
} from '@/config/features';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
|
||||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
|
||||||
|
|
||||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
|
||||||
primary: '#1976d2',
|
|
||||||
success: '#2e7d32',
|
|
||||||
warning: '#e65100',
|
|
||||||
error: '#c62828',
|
|
||||||
secondary: '#9c27b0',
|
|
||||||
info: '#0288d1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
|
|
||||||
|
|
||||||
const isValidPage = (page: unknown): page is PageType => {
|
|
||||||
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
|
||||||
return Array.isArray(pages) && pages.every(isValidPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
type WindowType = 'popup' | 'sidepanel' | 'tab';
|
|
||||||
|
|
||||||
interface SortableFeatureRowProps {
|
|
||||||
pageKey: PageType;
|
|
||||||
isLast: boolean;
|
|
||||||
isChecked: boolean;
|
|
||||||
isDisabled: boolean;
|
|
||||||
onToggle: (key: PageType) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableFeatureRow({
|
|
||||||
pageKey,
|
|
||||||
isLast,
|
|
||||||
isChecked,
|
|
||||||
isDisabled,
|
|
||||||
onToggle,
|
|
||||||
}: SortableFeatureRowProps) {
|
|
||||||
const { t } = useI18n(['features']);
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: pageKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const feature = getFeatureByKey(pageKey);
|
|
||||||
if (!feature) return null;
|
|
||||||
|
|
||||||
const colorKey = feature.themeColorKey ?? 'primary';
|
|
||||||
const colorCode = getColorCode(colorKey);
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
zIndex: isDragging ? 1 : 'auto',
|
|
||||||
position: 'relative' as const,
|
|
||||||
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
|
|
||||||
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
|
|
||||||
isLast ? '' : 'border-b border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
|
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<div
|
|
||||||
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
|
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 功能图标 */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${colorCode}1a`,
|
|
||||||
color: colorCode,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{feature.icon && <feature.icon size={20} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文本信息 */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
|
|
||||||
{t(feature.labelKey)}
|
|
||||||
</div>
|
|
||||||
{feature.descriptionKey && (
|
|
||||||
<div
|
|
||||||
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
|
|
||||||
title={t(feature.descriptionKey)}
|
|
||||||
>
|
|
||||||
{t(feature.descriptionKey)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 开关 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={isChecked}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={() => onToggle(pageKey)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
|
||||||
isChecked ? 'bg-primary' : 'bg-muted'
|
|
||||||
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
|
|
||||||
isChecked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { t } = useI18n(['features', 'common']);
|
|
||||||
|
|
||||||
const initialWindowType = useMemo(() => {
|
|
||||||
if (typeof window === 'undefined') return 'popup';
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const tab = params.get('tab');
|
|
||||||
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
|
|
||||||
return tab as WindowType;
|
|
||||||
}
|
|
||||||
return 'popup';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
|
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
|
||||||
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const { snackbarProps, showMessage } = useSnackbarState();
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const configKeys = useMemo(() => {
|
|
||||||
switch (windowType) {
|
|
||||||
case 'sidepanel':
|
|
||||||
return {
|
|
||||||
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
|
|
||||||
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
|
|
||||||
};
|
|
||||||
case 'tab':
|
|
||||||
return {
|
|
||||||
visible: 'app/tabVisiblePages' as keyof StorageSchema,
|
|
||||||
order: 'app/tabPageOrder' as keyof StorageSchema,
|
|
||||||
};
|
|
||||||
case 'popup':
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
visible: 'app/popupVisiblePages' as keyof StorageSchema,
|
|
||||||
order: 'app/popupPageOrder' as keyof StorageSchema,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [windowType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set('tab', windowType);
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
}, [windowType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
setIsLoaded(false);
|
|
||||||
try {
|
|
||||||
const [savedVisible, savedOrder] = await Promise.all([
|
|
||||||
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
|
|
||||||
storageUtil.get(configKeys.order, getDefaultPageOrder()),
|
|
||||||
]);
|
|
||||||
setVisiblePages(
|
|
||||||
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
|
|
||||||
);
|
|
||||||
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load config:', error);
|
|
||||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
|
||||||
setPageOrder(getDefaultPageOrder());
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadConfig().catch(console.error);
|
|
||||||
}, [configKeys]);
|
|
||||||
|
|
||||||
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
|
||||||
showMessage(message, { severity });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageToggle = async (page: PageType) => {
|
|
||||||
const isCurrentlyVisible = visiblePages.includes(page);
|
|
||||||
let newPages: PageType[];
|
|
||||||
|
|
||||||
if (isCurrentlyVisible) {
|
|
||||||
if (visiblePages.length <= 1) {
|
|
||||||
showToast('至少需要保留一个可见页面', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newPages = visiblePages.filter((p) => p !== page);
|
|
||||||
} else {
|
|
||||||
newPages = [...visiblePages, page];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storageUtil.set(configKeys.visible, newPages);
|
|
||||||
setVisiblePages(newPages);
|
|
||||||
const feature = getFeatureByKey(page);
|
|
||||||
const label = feature ? t(feature.labelKey) : page;
|
|
||||||
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save config:', error);
|
|
||||||
showToast('保存失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const oldIndex = pageOrder.indexOf(active.id as PageType);
|
|
||||||
const newIndex = pageOrder.indexOf(over.id as PageType);
|
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
|
||||||
|
|
||||||
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
|
|
||||||
setPageOrder(newOrder);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storageUtil.set(configKeys.order, newOrder);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save order:', error);
|
|
||||||
showToast('排序保存失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestoreDefaults = async () => {
|
|
||||||
try {
|
|
||||||
const defaults = getDefaultVisibleFeatureKeys();
|
|
||||||
const defaultOrder = getDefaultPageOrder();
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
storageUtil.set(configKeys.visible, defaults),
|
|
||||||
storageUtil.set(configKeys.order, defaultOrder),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setVisiblePages(defaults);
|
|
||||||
setPageOrder(defaultOrder);
|
|
||||||
showToast('已恢复当前模式默认设置', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to restore defaults:', error);
|
|
||||||
showToast('恢复失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
|
|
||||||
if (newType !== null) {
|
|
||||||
setWindowType(newType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center min-h-screen">
|
|
||||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app min-h-screen bg-background flex flex-col">
|
|
||||||
<PageErrorBoundary>
|
|
||||||
{/* 顶部标题与导航栏 */}
|
|
||||||
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
{/* Tab 与恢复按钮同行 */}
|
|
||||||
<div className="flex items-end justify-between border-b-0">
|
|
||||||
<div className="flex-1 flex">
|
|
||||||
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
onClick={(e) => handleWindowTypeChange(e, type)}
|
|
||||||
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
|
|
||||||
windowType === type
|
|
||||||
? 'text-primary border-b-2 border-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleRestoreDefaults}
|
|
||||||
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
|
|
||||||
title="恢复当前模式默认"
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 主内容区域 */}
|
|
||||||
<div className="flex-1 p-4 sm:p-8">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{pageOrder.map((key, index, array) => {
|
|
||||||
const isChecked = visiblePages.includes(key);
|
|
||||||
const isDisabled = isChecked && visiblePages.length === 1;
|
|
||||||
return (
|
|
||||||
<SortableFeatureRow
|
|
||||||
key={key}
|
|
||||||
pageKey={key}
|
|
||||||
isLast={index === array.length - 1}
|
|
||||||
isChecked={isChecked}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onToggle={handlePageToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageErrorBoundary>
|
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import App from '../App';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import {
|
|
||||||
getDefaultVisibleFeatureKeys,
|
|
||||||
getDefaultPageOrder,
|
|
||||||
getFeatureByKey,
|
|
||||||
} from '@/config/features';
|
|
||||||
import { getMessage } from '@/utils/chromeI18n';
|
|
||||||
|
|
||||||
// Mock storageUtil
|
|
||||||
vi.mock('@/utils/chromeStorage', () => ({
|
|
||||||
storageUtil: {
|
|
||||||
get: vi.fn(),
|
|
||||||
set: vi.fn(() => Promise.resolve()),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Options App', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('合法数据应正常加载并显示正确的功能列表', async () => {
|
|
||||||
const defaultOrder = getDefaultPageOrder();
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
|
||||||
Promise.resolve(defaultValue),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证默认可见的功能都被渲染出来了
|
|
||||||
for (const key of defaultOrder) {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (feature) {
|
|
||||||
expect(screen.getByText(getMessage(feature.labelKey))).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('非法 visiblePages 数据应回退到默认值', async () => {
|
|
||||||
const defaultVisible = getDefaultVisibleFeatureKeys();
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
|
||||||
if (key.includes('VisiblePages')) {
|
|
||||||
return Promise.resolve(['invalidPage', 'anotherInvalid']);
|
|
||||||
}
|
|
||||||
return Promise.resolve(defaultValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证默认值的功能仍然被渲染(非法数据被回退)
|
|
||||||
for (const key of defaultVisible) {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (feature && key !== 'dashboard') {
|
|
||||||
expect(screen.getByText(getMessage(feature.labelKey))).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('非法 pageOrder 数据应回退到默认值', async () => {
|
|
||||||
const defaultOrder = getDefaultPageOrder();
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
|
||||||
if (key.includes('PageOrder')) {
|
|
||||||
return Promise.resolve(['notARealPage', 123, null]);
|
|
||||||
}
|
|
||||||
return Promise.resolve(defaultValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证默认顺序的功能都被渲染
|
|
||||||
for (const key of defaultOrder) {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (feature) {
|
|
||||||
expect(screen.getByText(getMessage(feature.labelKey))).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('非数组数据应回退到默认值', async () => {
|
|
||||||
const defaultOrder = getDefaultPageOrder();
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
|
||||||
if (key.includes('VisiblePages')) {
|
|
||||||
return Promise.resolve('not-an-array');
|
|
||||||
}
|
|
||||||
if (key.includes('PageOrder')) {
|
|
||||||
return Promise.resolve({ foo: 'bar' });
|
|
||||||
}
|
|
||||||
return Promise.resolve(defaultValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证默认功能都被渲染
|
|
||||||
for (const key of defaultOrder) {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (feature) {
|
|
||||||
expect(screen.getByText(getMessage(feature.labelKey))).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>扩展设置 - Testing Tools</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import AppRoot from '@/providers/AppRoot';
|
|
||||||
import '@/index.css';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<AppRoot>
|
|
||||||
<App />
|
|
||||||
</AppRoot>,
|
|
||||||
);
|
|
||||||
@@ -7,10 +7,6 @@ import { getEntryPointType } from '@/config/features';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const handleOpenOptions = () => {
|
|
||||||
chrome.runtime.openOptionsPage().catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const entryType = useMemo(() => getEntryPointType(), []);
|
const entryType = useMemo(() => getEntryPointType(), []);
|
||||||
|
|
||||||
const routerConfig = useMemo(() => {
|
const routerConfig = useMemo(() => {
|
||||||
@@ -36,7 +32,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
<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="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">
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
<TopBar />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterContainer />
|
<RouterContainer />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const handleOpenOptions = () => {
|
|
||||||
chrome.runtime.openOptionsPage().catch((err) => {
|
|
||||||
console.error('Failed to open options page:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||||
return () => {
|
return () => {
|
||||||
@@ -24,7 +18,7 @@ export default function App() {
|
|||||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
<TopBar />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterContainer />
|
<RouterContainer />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -106,14 +106,6 @@ describe('chromeTabs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该支持带查询参数的扩展页面', async () => {
|
|
||||||
await openExtensionPage('options.html', { tab: 'settings', id: '123' });
|
|
||||||
|
|
||||||
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
|
||||||
url: 'chrome-extension://test-extension-id/options.html?tab=settings&id=123',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当创建标签页失败时应记录错误', async () => {
|
it('当创建标签页失败时应记录错误', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
(chrome.tabs.create as any).mockRejectedValue(new Error('Tab creation failed'));
|
(chrome.tabs.create as any).mockRejectedValue(new Error('Tab creation failed'));
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ export default defineConfig({
|
|||||||
side_panel: {
|
side_panel: {
|
||||||
default_path: 'entrypoints/sidepanel/index.html',
|
default_path: 'entrypoints/sidepanel/index.html',
|
||||||
},
|
},
|
||||||
options_ui: {
|
|
||||||
page: 'entrypoints/options/index.html',
|
|
||||||
open_in_tab: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
vite: () => ({
|
vite: () => ({
|
||||||
plugins: [manualChunksForHtmlOnly()],
|
plugins: [manualChunksForHtmlOnly()],
|
||||||
|
|||||||
Reference in New Issue
Block a user