diff --git a/src/components/README.md b/src/components/README.md index abd1286..2d594b8 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -6,7 +6,6 @@ | 组件 | 用途 | | ----------------------- | ------------------------------------------------------------------------------ | -| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 | | `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | | `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | | `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx deleted file mode 100644 index a943e48..0000000 --- a/src/components/TopBar.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react'; -import { useRouter } from '@/providers/RouterProvider'; -import { useThemeMode } from '@/providers/ThemeModeProvider'; -import { FeatureConfig, FEATURES } from '@/config/features'; -import { storageUtil } from '@/utils/chromeStorage'; -import { openExtensionPage } from '@/utils/chromeTabs'; -import { cn } from '@/lib/utils'; - -const SEARCH_HISTORY_LIMIT = 10; -const SEARCH_HISTORY_DISPLAY = 5; - -export default function TopBar() { - const { currentPage, goHome, navigateTo } = useRouter(); - const { mode, setMode } = useThemeMode(); - - const [searchQuery, setSearchQuery] = useState(''); - const [showResults, setShowResults] = useState(false); - const [searchHistory, setSearchHistory] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); - - const containerRef = useRef(null); - const inputRef = useRef(null); - - const handleOpenInTab = async () => { - await openExtensionPage('popup.html', { mode: 'tab' }); - window.close(); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setShowResults(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Cmd/Ctrl+K 快捷键聚焦搜索框 - useEffect(() => { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - inputRef.current?.focus(); - setShowResults(true); - } - }; - document.addEventListener('keydown', handleGlobalKeyDown); - return () => document.removeEventListener('keydown', handleGlobalKeyDown); - }, []); - - useEffect(() => { - storageUtil - .get('app/searchHistory', []) - .then((history) => { - if (history) setSearchHistory(history); - }) - .catch((err) => console.error('加载搜索历史失败:', err)); - }, []); - - const searchResults = useMemo(() => { - const query = searchQuery.trim().toLowerCase(); - if (!query) return []; - return FEATURES.filter((f) => { - if (f.key === 'dashboard') return false; - return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query); - }); - }, [searchQuery]); - - const displayedHistory = useMemo(() => { - if (searchQuery.trim()) return []; - return searchHistory - .slice(0, SEARCH_HISTORY_DISPLAY) - .map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) })) - .filter((item) => item.feature && item.feature.key !== 'dashboard'); - }, [searchHistory, searchQuery]); - - const saveToHistory = async (featureKey: string) => { - if (!featureKey.trim()) return; - const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( - 0, - SEARCH_HISTORY_LIMIT, - ); - setSearchHistory(nextHistory); - await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err)); - }; - - const handleSelectFeature = (feature: FeatureConfig) => { - navigateTo(feature.key); - saveToHistory(feature.key); - setSearchQuery(''); - setShowResults(false); - }; - - const cycleThemeMode = () => { - const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; - setMode(nextMap[mode]); - }; - - const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; - - const handleKeyDown = (e: React.KeyboardEvent) => { - const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (searchQuery.trim()) { - handleSelectFeature(searchResults[selectedIndex]); - } else { - const selected = displayedHistory[selectedIndex]; - if (selected?.feature) { - handleSelectFeature(selected.feature); - } - } - } else if (searchQuery.trim() && searchResults.length > 0) { - handleSelectFeature(searchResults[0]); - } - } else if (e.key === 'Escape') { - setShowResults(false); - inputRef.current?.blur(); - } - }; - - const isDashboard = currentPage === 'dashboard'; - - return ( -
- {/* 左侧:返回按钮区 */} -
- {!isDashboard && ( - - )} -
- - {/* 中间:搜索容器 */} -
-
- - { - setSearchQuery(e.target.value); - setShowResults(true); - setSelectedIndex(-1); - }} - onFocus={() => setShowResults(true)} - onKeyDown={handleKeyDown} - aria-label={'搜索工具...'} - className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" - /> - {!searchQuery && ( - - ⌘K - - )} - {searchQuery && ( - - )} -
- - {/* 动态联想结果卡片 */} - {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( -
-
    - {searchQuery.trim() ? ( - searchResults.length > 0 ? ( - searchResults.map((feature, index) => ( -
  • handleSelectFeature(feature)} - className={cn( - 'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors', - selectedIndex === index - ? 'bg-accent text-accent-foreground' - : 'hover:bg-muted/60', - )} - > -
    - {feature.icon && } -
    -
    -

    {feature.label}

    -

    - {feature.description} -

    -
    -
  • - )) - ) : ( -
  • - {'未找到相关工具'} -
  • - ) - ) : ( - <> -
    - {'最近搜索'} -
    - {displayedHistory.map((item, index) => ( -
  • item.feature && handleSelectFeature(item.feature)} - className={cn( - 'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors', - selectedIndex === index - ? 'bg-accent text-accent-foreground' - : 'hover:bg-muted/60', - )} - > -
    - {item.feature?.icon && } -
    -
    -

    - {item.feature?.label} -

    -

    - {item.feature?.description} -

    -
    -
  • - ))} - - )} -
-
- )} -
- - {/* 右侧:操作区 */} -
- - - - - - -
-
- ); -} - -// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格 -function IconButton({ - children, - onClick, - title, -}: { - children: React.ReactNode; - onClick: () => void; - title: string; -}) { - return ( - - ); -} diff --git a/src/entrypoints/README.md b/src/entrypoints/README.md index 47982f0..9349724 100644 --- a/src/entrypoints/README.md +++ b/src/entrypoints/README.md @@ -16,11 +16,11 @@ WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各 Popup 弹窗页面(点击扩展图标弹出)。 -| 文件 | 用途 | -| ------------ | ------------------------------------------------------------------------------ | -| `index.html` | HTML 入口 | -| `main.tsx` | React 挂载点 | -| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` | +| 文件 | 用途 | +| ------------ | ------------------------------------------------------------------------------------- | +| `index.html` | HTML 入口 | +| `main.tsx` | React 挂载点 | +| `App.tsx` | 根组件,组装 `RouterProvider` + `layout/TopBar` + `ErrorBoundary` + `RouterContainer` | ### sidepanel/ diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index 413568a..9bda4d4 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -1,5 +1,5 @@ import RouterProvider from '@/providers/RouterProvider'; -import TopBar from '@/components/TopBar'; +import TopBar from '@/layout/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; import { getEntryPointType } from '@/config/features'; diff --git a/src/entrypoints/sidepanel/App.tsx b/src/entrypoints/sidepanel/App.tsx index 80d156b..0475e7a 100644 --- a/src/entrypoints/sidepanel/App.tsx +++ b/src/entrypoints/sidepanel/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import RouterProvider from '@/providers/RouterProvider'; -import TopBar from '@/components/TopBar'; +import TopBar from '@/layout/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; import { MessageAction, sendMessage } from '@/utils/messages'; diff --git a/src/layout/README.md b/src/layout/README.md new file mode 100644 index 0000000..15be8a1 --- /dev/null +++ b/src/layout/README.md @@ -0,0 +1,22 @@ +# layout/ + +应用壳层布局组件目录,存放与扩展入口(popup / sidepanel / tab)绑定的布局 UI,与 `components/` 中的通用可复用组件区分。 + +## 组件列表 + +| 目录 | 用途 | +| --------- | -------------------------------------------------------------- | +| `TopBar/` | 顶部导航栏:搜索(含历史记录)、主题切换、返回导航、标签页打开 | + +## 目录结构 + +遵循与 `pages/` 相同的 UI + Hook 分离模式: + +``` +layout/TopBar/ +├── index.tsx # 布局 UI +├── useTopBar.ts # 业务逻辑 Hook +├── constants.ts # 常量 +└── __tests__/ + └── index.test.tsx +``` diff --git a/src/components/__tests__/TopBar.test.tsx b/src/layout/TopBar/__tests__/index.test.tsx similarity index 94% rename from src/components/__tests__/TopBar.test.tsx rename to src/layout/TopBar/__tests__/index.test.tsx index f1625b0..9f9955f 100644 --- a/src/components/__tests__/TopBar.test.tsx +++ b/src/layout/TopBar/__tests__/index.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import type { PageType } from '@/types/storage'; import React from 'react'; -import TopBar from '@/components/TopBar'; +import TopBar from '@/layout/TopBar'; import { RouterProvider } from '@/providers/RouterProvider'; import { ThemeModeProvider } from '@/providers/ThemeModeProvider'; @@ -60,7 +60,7 @@ describe('TopBar 组件', () => { it('在 dashboard 上不应渲染返回按钮', () => { mockRouterValue.currentPage = 'dashboard'; renderWithProvider(); - expect(screen.queryByLabelText('返回')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('返回首页')).not.toBeInTheDocument(); }); }); diff --git a/src/layout/TopBar/constants.ts b/src/layout/TopBar/constants.ts new file mode 100644 index 0000000..a297476 --- /dev/null +++ b/src/layout/TopBar/constants.ts @@ -0,0 +1,2 @@ +export const SEARCH_HISTORY_LIMIT = 10; +export const SEARCH_HISTORY_DISPLAY = 5; diff --git a/src/layout/TopBar/index.tsx b/src/layout/TopBar/index.tsx new file mode 100644 index 0000000..f555b91 --- /dev/null +++ b/src/layout/TopBar/index.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { ArrowLeft, ExternalLink, Search, X } from 'lucide-react'; +import { type FeatureConfig } from '@/config/features'; +import { cn } from '@/lib/utils'; +import { useTopBar } from './useTopBar'; + +export default function TopBar() { + const { + searchQuery, + showResults, + searchResults, + displayedHistory, + selectedIndex, + isDashboard, + ThemeIcon, + themeTitle, + containerRef, + inputRef, + setSearchQuery, + setShowResults, + setSelectedIndex, + handleSelectFeature, + handleKeyDown, + cycleThemeMode, + handleOpenInTab, + goHome, + clearSearch, + } = useTopBar(); + + return ( +
+
+ {!isDashboard && ( + + )} +
+ +
+
+ + { + setSearchQuery(e.target.value); + setShowResults(true); + setSelectedIndex(-1); + }} + onFocus={() => setShowResults(true)} + onKeyDown={handleKeyDown} + aria-label="搜索工具..." + className="h-9 w-full rounded-lg border border-border/60 bg-muted/40 pl-9 pr-16 text-sm transition-all placeholder:text-muted-foreground/50 focus:border-input focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring" + /> + {!searchQuery && ( + + ⌘K + + )} + {searchQuery && ( + + )} +
+ + {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( +
+
    + {searchQuery.trim() ? ( + searchResults.length > 0 ? ( + searchResults.map((feature, index) => ( + + )) + ) : ( +
  • + 未找到相关工具 +
  • + ) + ) : ( + <> +
    + 最近搜索 +
    + {displayedHistory.map((item, index) => + item.feature ? ( + + ) : null, + )} + + )} +
+
+ )} +
+ +
+ + + + + + +
+
+ ); +} + +interface SearchResultItemProps { + feature: FeatureConfig; + selected: boolean; + onSelect: (feature: FeatureConfig) => void; +} + +function SearchResultItem({ feature, selected, onSelect }: SearchResultItemProps) { + return ( +
  • onSelect(feature)} + className={cn( + 'flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors', + selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60', + )} + > +
    + {feature.icon && } +
    +
    +

    {feature.label}

    +

    {feature.description}

    +
    +
  • + ); +} + +function IconButton({ + children, + onClick, + title, +}: { + children: React.ReactNode; + onClick: () => void; + title: string; +}) { + return ( + + ); +} diff --git a/src/layout/TopBar/useTopBar.ts b/src/layout/TopBar/useTopBar.ts new file mode 100644 index 0000000..02f989a --- /dev/null +++ b/src/layout/TopBar/useTopBar.ts @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { Monitor, Moon, Sun } from 'lucide-react'; +import { useRouter } from '@/providers/RouterProvider'; +import { useThemeMode } from '@/providers/ThemeModeProvider'; +import { type FeatureConfig, FEATURES } from '@/config/features'; +import { storageUtil } from '@/utils/chromeStorage'; +import { openExtensionPage } from '@/utils/chromeTabs'; +import { SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; + +export interface HistoryItem { + key: string; + feature?: FeatureConfig; +} + +export interface UseTopBarReturn { + searchQuery: string; + showResults: boolean; + searchResults: FeatureConfig[]; + displayedHistory: HistoryItem[]; + selectedIndex: number; + isDashboard: boolean; + ThemeIcon: typeof Sun; + themeTitle: string; + containerRef: RefObject; + inputRef: RefObject; + setSearchQuery: (value: string) => void; + setShowResults: (value: boolean) => void; + setSelectedIndex: (value: number | ((prev: number) => number)) => void; + handleSelectFeature: (feature: FeatureConfig) => void; + handleKeyDown: (e: React.KeyboardEvent) => void; + cycleThemeMode: () => void; + handleOpenInTab: () => Promise; + goHome: () => void; + clearSearch: () => void; +} + +export function useTopBar(): UseTopBarReturn { + const { currentPage, goHome, navigateTo } = useRouter(); + const { mode, setMode } = useThemeMode(); + + const [searchQuery, setSearchQuery] = useState(''); + const [showResults, setShowResults] = useState(false); + const [searchHistory, setSearchHistory] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const containerRef = useRef(null); + const inputRef = useRef(null); + + const handleOpenInTab = async () => { + await openExtensionPage('popup.html', { mode: 'tab' }); + window.close(); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowResults(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + setShowResults(true); + } + }; + document.addEventListener('keydown', handleGlobalKeyDown); + return () => document.removeEventListener('keydown', handleGlobalKeyDown); + }, []); + + useEffect(() => { + let cancelled = false; + + storageUtil + .get('app/searchHistory', []) + .then((history) => { + if (cancelled || !history) return; + setSearchHistory(history); + }) + .catch((err) => console.error('加载搜索历史失败:', err)); + + return () => { + cancelled = true; + }; + }, []); + + const searchResults = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return []; + return FEATURES.filter((f) => { + if (f.key === 'dashboard') return false; + return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query); + }); + }, [searchQuery]); + + const displayedHistory = useMemo(() => { + if (searchQuery.trim()) return []; + return searchHistory + .slice(0, SEARCH_HISTORY_DISPLAY) + .map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) })) + .filter((item) => item.feature && item.feature.key !== 'dashboard'); + }, [searchHistory, searchQuery]); + + const saveToHistory = async (featureKey: string) => { + if (!featureKey.trim()) return; + const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( + 0, + SEARCH_HISTORY_LIMIT, + ); + setSearchHistory(nextHistory); + await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err)); + }; + + const handleSelectFeature = (feature: FeatureConfig) => { + navigateTo(feature.key); + saveToHistory(feature.key); + setSearchQuery(''); + setShowResults(false); + }; + + const cycleThemeMode = () => { + const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; + setMode(nextMap[mode]); + }; + + const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; + + const themeTitle = + mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式'; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < totalItems) { + if (searchQuery.trim()) { + handleSelectFeature(searchResults[selectedIndex]); + } else { + const selected = displayedHistory[selectedIndex]; + if (selected?.feature) { + handleSelectFeature(selected.feature); + } + } + } else if (searchQuery.trim() && searchResults.length > 0) { + handleSelectFeature(searchResults[0]); + } + } else if (e.key === 'Escape') { + setShowResults(false); + inputRef.current?.blur(); + } + }; + + const clearSearch = () => { + setSearchQuery(''); + setSelectedIndex(-1); + }; + + return { + searchQuery, + showResults, + searchResults, + displayedHistory, + selectedIndex, + isDashboard: currentPage === 'dashboard', + ThemeIcon, + themeTitle, + containerRef, + inputRef, + setSearchQuery, + setShowResults, + setSelectedIndex, + handleSelectFeature, + handleKeyDown, + cycleThemeMode, + handleOpenInTab, + goHome, + clearSearch, + }; +}