From 6589ea3858032e27b9c626980d864493f01ee30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= <2712495353@qq.com> Date: Sun, 5 Jul 2026 21:47:35 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E5=92=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/featureMeta.ts | 11 -------- src/config/features.ts | 1 - src/entrypoints/background.ts | 25 +---------------- src/entrypoints/popup/App.tsx | 25 ++++------------- src/entrypoints/popup/index.html | 9 +------ src/entrypoints/sidepanel/App.tsx | 26 ------------------ src/entrypoints/sidepanel/index.html | 25 ----------------- src/entrypoints/sidepanel/main.tsx | 10 ------- src/layout/TopBar/index.tsx | 13 ++------- src/layout/TopBar/useTopBar.ts | 8 ------ .../Base64Converter/__tests__/index.test.tsx | 9 ------- src/pages/QrCode/__tests__/index.test.tsx | 8 ------ .../__tests__/RouterProvider.test.tsx | 27 ++++--------------- src/types/storage.d.ts | 14 +--------- src/utils/__tests__/chromeTabs.test.ts | 25 ----------------- src/utils/__tests__/messages.test.ts | 11 +++++--- src/utils/chromeTabs.ts | 23 ---------------- src/utils/messages.ts | 2 -- wxt.config.ts | 13 +-------- 19 files changed, 23 insertions(+), 262 deletions(-) delete mode 100644 src/entrypoints/sidepanel/App.tsx delete mode 100644 src/entrypoints/sidepanel/index.html delete mode 100644 src/entrypoints/sidepanel/main.tsx delete mode 100644 src/utils/__tests__/chromeTabs.test.ts delete mode 100644 src/utils/chromeTabs.ts diff --git a/src/config/featureMeta.ts b/src/config/featureMeta.ts index c2a274e..e2e2444 100644 --- a/src/config/featureMeta.ts +++ b/src/config/featureMeta.ts @@ -120,14 +120,3 @@ export function getAllFeatureKeys(): PageType[] { export function getDefaultPageOrder(): PageType[] { return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key); } - -export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' { - const pathname = window.location.pathname; - if (pathname.includes('sidepanel')) { - return 'sidepanel'; - } - if (new URLSearchParams(window.location.search).get('mode') === 'tab') { - return 'tab'; - } - return 'popup'; -} diff --git a/src/config/features.ts b/src/config/features.ts index 63648e3..ab2ce5b 100644 --- a/src/config/features.ts +++ b/src/config/features.ts @@ -4,6 +4,5 @@ export { getAllFeatureKeys, getDefaultPageOrder, getDefaultVisibleFeatureKeys, - getEntryPointType, getFeatureByKey, } from '@/config/featureMeta'; diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 7c56c95..3bad69c 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,6 +1,6 @@ import '../../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; -import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; +import { MessageAction, onMessage } from '@/utils/messages'; import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; import { saveContextMenuData } from '@/utils/useContextMenuData'; import { mainWorldInjectionScript } from '@/utils/rightClickInjection'; @@ -24,18 +24,6 @@ export default defineBackground(() => { const { featureKey, payload } = result.data; - try { - const sidePanelState = await browser.storage.local.get('sidePanelOpen'); - const isSidePanelOpen = sidePanelState.sidePanelOpen === true; - - if (isSidePanelOpen) { - await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); - return; - } - } catch (err) { - console.debug('[Context Menu] Side panel pipeline is not available:', err); - } - await saveContextMenuData({ featureKey, payload }); try { @@ -45,17 +33,6 @@ export default defineBackground(() => { } }); - // 扩展图标点击时打开侧边栏 - browser.action.onClicked.addListener(async (tab) => { - if (tab.id) { - try { - await browser.sidePanel.open({ tabId: tab.id }); - } catch (err) { - console.error('Failed to open side panel via extension action clicked:', err); - } - } - }); - // 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行) onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => { const sender = message.sender as chrome.runtime.MessageSender | undefined; diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index e7a33ba..9086530 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -2,30 +2,15 @@ import RouterProvider from '@/providers/RouterProvider'; import TopBar from '@/layout/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; -import { getEntryPointType } from '@/config/features'; + export default function App() { - const entryType = getEntryPointType(); - - const routerConfig = - entryType === 'tab' - ? { - syncKey: 'app/tabRoute' as const, - visiblePagesKey: 'app/tabVisiblePages' as const, - pageOrderKey: 'app/tabPageOrder' as const, - } - : { - syncKey: 'app/popupRoute' as const, - visiblePagesKey: 'app/popupVisiblePages' as const, - pageOrderKey: 'app/popupPageOrder' as const, - }; - return ( -
+
diff --git a/src/entrypoints/popup/index.html b/src/entrypoints/popup/index.html index 8a48568..98c4a48 100644 --- a/src/entrypoints/popup/index.html +++ b/src/entrypoints/popup/index.html @@ -3,7 +3,7 @@ - Testing Tools - 标签页 + Testing Tools - - -
- - - diff --git a/src/entrypoints/sidepanel/main.tsx b/src/entrypoints/sidepanel/main.tsx deleted file mode 100644 index 7f2f97c..0000000 --- a/src/entrypoints/sidepanel/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ReactDOM from 'react-dom/client'; -import AppRoot from '@/providers/AppRoot'; -import '@/styles/shell.css'; -import App from './App.tsx'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/src/layout/TopBar/index.tsx b/src/layout/TopBar/index.tsx index 2ad7d57..9f4651a 100644 --- a/src/layout/TopBar/index.tsx +++ b/src/layout/TopBar/index.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ExternalLink } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; import SearchDropdown from './SearchDropdown'; import SearchInput from './SearchInput'; @@ -22,20 +22,11 @@ export default function TopBar() { handleSelectFeature, handleKeyDown, cycleThemeMode, - handleOpenInTab, goHome, clearSearch, } = useTopBar(); - const actions = [ - { id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode }, - { - id: 'open-in-tab', - icon: ExternalLink, - title: '在标签页打开', - onClick: () => void handleOpenInTab(), - }, - ]; + const actions = [{ id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode }]; return (
diff --git a/src/layout/TopBar/useTopBar.ts b/src/layout/TopBar/useTopBar.ts index 290ac17..8010816 100644 --- a/src/layout/TopBar/useTopBar.ts +++ b/src/layout/TopBar/useTopBar.ts @@ -11,7 +11,6 @@ import { useRouter } from '@/providers/RouterProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider'; import { type FeatureConfig, FEATURES } from '@/config/features'; import { useStorageState } from '@/utils/useStorageState'; -import { openExtensionPage } from '@/utils/chromeTabs'; import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; export interface UseTopBarReturn { @@ -30,7 +29,6 @@ export interface UseTopBarReturn { handleSelectFeature: (feature: FeatureConfig) => void; handleKeyDown: (e: ReactKeyboardEvent) => void; cycleThemeMode: () => void; - handleOpenInTab: () => Promise; goHome: () => void; clearSearch: () => void; } @@ -51,11 +49,6 @@ export function useTopBar(): UseTopBarReturn { 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)) { @@ -175,7 +168,6 @@ export function useTopBar(): UseTopBarReturn { handleSelectFeature, handleKeyDown, cycleThemeMode, - handleOpenInTab, goHome, clearSearch, }; diff --git a/src/pages/Base64Converter/__tests__/index.test.tsx b/src/pages/Base64Converter/__tests__/index.test.tsx index bdc9308..a080c1a 100644 --- a/src/pages/Base64Converter/__tests__/index.test.tsx +++ b/src/pages/Base64Converter/__tests__/index.test.tsx @@ -2,15 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import Base64ConverterPage from '../index'; -// Mock getEntryPointType -vi.mock('@/config/features', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getEntryPointType: () => 'sidepanel', - }; -}); - // Mock 子组件 vi.mock('../components/TextMode', () => ({ default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => ( diff --git a/src/pages/QrCode/__tests__/index.test.tsx b/src/pages/QrCode/__tests__/index.test.tsx index 00cefed..832b764 100644 --- a/src/pages/QrCode/__tests__/index.test.tsx +++ b/src/pages/QrCode/__tests__/index.test.tsx @@ -18,14 +18,6 @@ vi.mock('@/components/GlobalSnackbar', () => ({ }), })); -vi.mock('@/config/features', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getEntryPointType: () => 'sidepanel', - }; -}); - vi.mock('../components/QrCodePreview', () => ({ default: () =>
QrCodePreview
, })); diff --git a/src/providers/__tests__/RouterProvider.test.tsx b/src/providers/__tests__/RouterProvider.test.tsx index f22e93c..1272e5a 100644 --- a/src/providers/__tests__/RouterProvider.test.tsx +++ b/src/providers/__tests__/RouterProvider.test.tsx @@ -82,10 +82,10 @@ describe('RouterProvider', () => { }); it('应该从指定的 syncKey 加载路由', async () => { - mockStorageBatch({ 'app/sidepanelRoute': 'timestamp' }); + mockStorageBatch({ 'app/popupRoute': 'timestamp' }); render( - + , ); @@ -118,31 +118,14 @@ describe('RouterProvider', () => { expect(storageUtil.set).toHaveBeenCalledWith('app/popupRoute', 'timestamp'); }); - it('应该支持独立的标签页路由同步', async () => { - mockStorageBatch({ 'app/tabRoute': 'qrCode' }); - - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('current-page')).toHaveTextContent('qrCode'); - }); - }); - it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => { mockStorageBatch({ - 'app/sidepanelVisiblePages': ['timestamp', 'storageCleaner'], - 'app/sidepanelPageOrder': ['storageCleaner', 'timestamp'], + 'app/popupVisiblePages': ['timestamp', 'storageCleaner'], + 'app/popupPageOrder': ['storageCleaner', 'timestamp'], }); render( - + , ); diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index bd1e2c3..7244d8a 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -66,14 +66,10 @@ export interface FormMapEntry { * 定义了所有持久化在客户端的数据结构 */ export interface StorageSchema { - /** RouterProvider 的默认路由键,仅当未显式传入 syncKey 时使用(popup/sidepanel/tab 入口已各自覆盖) */ + /** RouterProvider 的默认路由键,仅当未显式传入 syncKey 时使用(popup 入口已覆盖) */ 'app/currentRoute': PageType; /** Popup 窗口的当前路由 */ 'app/popupRoute': PageType; - /** 侧边栏的当前路由 */ - 'app/sidepanelRoute': PageType; - /** 标签页的当前路由 */ - 'app/tabRoute': PageType; /** RouterProvider 的默认可见页面列表键,仅当未显式传入 visiblePagesKey 时使用 */ 'app/visiblePages': PageType[]; /** RouterProvider 的默认页面排序键,仅当未显式传入 pageOrderKey 时使用 */ @@ -82,14 +78,6 @@ export interface StorageSchema { 'app/popupVisiblePages': PageType[]; /** Popup 窗口页面的显示顺序 */ 'app/popupPageOrder': PageType[]; - /** 侧边栏可见的页面列表 */ - 'app/sidepanelVisiblePages': PageType[]; - /** 侧边栏页面的显示顺序 */ - 'app/sidepanelPageOrder': PageType[]; - /** 标签页可见的页面列表 */ - 'app/tabVisiblePages': PageType[]; - /** 标签页页面的显示顺序 */ - 'app/tabPageOrder': PageType[]; /** 应用主题配置 */ 'app/theme': string; /** 主题模式偏好(light/dark/system) */ diff --git a/src/utils/__tests__/chromeTabs.test.ts b/src/utils/__tests__/chromeTabs.test.ts deleted file mode 100644 index b9aabd6..0000000 --- a/src/utils/__tests__/chromeTabs.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { openExtensionPage } from '@/utils/chromeTabs'; - -describe('chromeTabs', () => { - describe('openExtensionPage', () => { - it('应该在新标签页中打开扩展页面', async () => { - await openExtensionPage('popup.html'); - - expect(chrome.runtime.getURL).toHaveBeenCalledWith('popup.html'); - expect(chrome.tabs.create).toHaveBeenCalledWith({ - url: 'chrome-extension://test-extension-id/popup.html', - }); - }); - - it('当创建标签页失败时应记录错误', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - (chrome.tabs.create as any).mockRejectedValue(new Error('Tab creation failed')); - - await openExtensionPage('popup.html'); - - expect(consoleSpy).toHaveBeenCalledWith('打开扩展页面失败:', expect.any(Error)); - consoleSpy.mockRestore(); - }); - }); -}); diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index 434361c..a53e0c8 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -44,18 +44,21 @@ describe('messages', () => { ); }); - it('应该支持不带数据的消息发送', async () => { + it('应该支持带数据的消息发送', async () => { const mockSendMessage = await getMockSendMessage(); mockSendMessage.mockResolvedValue(undefined); const mockTab = { id: 456, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - await sendMessageToContent(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true }); + await sendMessageToContent(MessageAction.CONTEXT_MENU_CLICKED, { + featureKey: 'timestamp', + payload: '1234567890', + }); expect(mockSendMessage).toHaveBeenCalledWith( - MessageAction.SIDE_PANEL_STATE_CHANGED, - { isOpen: true }, + MessageAction.CONTEXT_MENU_CLICKED, + { featureKey: 'timestamp', payload: '1234567890' }, 456, ); }); diff --git a/src/utils/chromeTabs.ts b/src/utils/chromeTabs.ts deleted file mode 100644 index 451a085..0000000 --- a/src/utils/chromeTabs.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Chrome 标签页相关工具函数 - */ - -/** - * 在新标签页中打开扩展页面 - * @param page - 扩展页面路径(如 'popup.html') - * @param params - 可选的查询参数 - */ -export async function openExtensionPage( - page: string, - params?: Record, -): Promise { - try { - const url = new URL(chrome.runtime.getURL(page)); - if (params) { - Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); - } - await chrome.tabs.create({ url: url.toString() }); - } catch (error) { - console.error('打开扩展页面失败:', error); - } -} diff --git a/src/utils/messages.ts b/src/utils/messages.ts index b1f9bcb..a67ee2c 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,7 +1,6 @@ import { defineExtensionMessaging } from '@webext-core/messaging'; export enum MessageAction { - SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', CONTEXT_MENU_CLICKED = 'contextMenuClicked', RESTORE_RIGHT_CLICK = 'restoreRightClick', QUERY_RIGHT_CLICK_STATUS = 'queryRightClickStatus', @@ -20,7 +19,6 @@ export interface ContextMenuClickedPayload { } export interface ProtocolMap { - [MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void; [MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void; [MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean }; [MessageAction.QUERY_RIGHT_CLICK_STATUS]( diff --git a/wxt.config.ts b/wxt.config.ts index d8747fe..64671e2 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -41,15 +41,7 @@ export default defineConfig({ name: 'Testing Tool', description: 'A tool for testing web applications.', version_name: undefined, - permissions: [ - 'storage', - 'clipboardWrite', - 'scripting', - 'tabs', - 'cookies', - 'sidePanel', - 'contextMenus', - ], + permissions: ['storage', 'clipboardWrite', 'scripting', 'tabs', 'cookies', 'contextMenus'], host_permissions: [''], icons: { '16': 'icon/16.png', @@ -66,9 +58,6 @@ export default defineConfig({ '48': 'icon/48.png', }, }, - side_panel: { - default_path: 'entrypoints/sidepanel/index.html', - }, }, vite: () => ({ plugins: [manualChunksForHtmlOnly()], From d21e970f56b96b387d12c7adf0d13f17913a4d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= <2712495353@qq.com> Date: Sun, 5 Jul 2026 21:55:22 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20Popup=20?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E7=A7=BB=E9=99=A4=20Dashboard=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B9=B6=E5=BC=95=E5=85=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 README.md,添加 Popup 功能导航文档链接。 - 新建 feature-nav-layout.md,详细描述 Popup 布局重构的背景、目标及设计。 - 移除 Dashboard 相关代码,更新 RouterProvider 和 TopBar 以适应新布局。 - 修改默认路由策略,确保新用户直接访问 timestamp 页面。 --- docs/spec/README.md | 1 + docs/spec/popup-layout/feature-nav-layout.md | 356 +++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 docs/spec/popup-layout/feature-nav-layout.md diff --git a/docs/spec/README.md b/docs/spec/README.md index 2a417f6..814fec6 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -5,3 +5,4 @@ | 文档 | 状态 | 说明 | | -------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------- | | [storage-cleaner/indexeddb-fix-plan.md](./storage-cleaner/indexeddb-fix-plan.md) | ✅ Phase 3 已完成 | Storage Cleaner IndexedDB 清理逻辑修复方案与验收标准 | +| [popup-layout/feature-nav-layout.md](./popup-layout/feature-nav-layout.md) | 待实施 | Popup 右侧图标导航、移除 Dashboard、默认 timestamp | diff --git a/docs/spec/popup-layout/feature-nav-layout.md b/docs/spec/popup-layout/feature-nav-layout.md new file mode 100644 index 0000000..02a74a5 --- /dev/null +++ b/docs/spec/popup-layout/feature-nav-layout.md @@ -0,0 +1,356 @@ +# Popup 布局重构 — 功能导航与 Dashboard 移除 + +> 创建时间: 2026-07-05 +> 状态: 待实施 +> 关联模块: `src/entrypoints/popup/`、`src/layout/FeatureNav/`、`src/providers/RouterProvider.tsx` +> 影响范围: 仅 popup 入口(tab / sidepanel 不在本次布局范围) + +## 背景与目标 + +当前 popup 采用 **Dashboard 首页 + TopBar 搜索跳转** 模式:用户打开扩展后先看到仪表盘卡片网格,再点击进入具体工具。布局为固定 400×600(见 `src/entrypoints/popup/App.tsx` 与 `src/entrypoints/popup/index.html`),TopBar 在非 dashboard 页面显示「返回首页」按钮。 + +该模式增加了一步选工具操作。本次重构将 popup 改为 **工具直达 + 右侧常驻导航**,并移除 Dashboard 页面。 + +| 维度 | 现状 | 目标 | +| ---------- | ----------------------------- | ----------------------------------------- | +| 默认页 | `dashboard` | 新用户默认 `timestamp` | +| 导航 | Dashboard 卡片网格 + 搜索跳转 | 右侧常驻纯图标导航栏 | +| 首页 | Dashboard 独立页 | 移除 | +| Popup 宽度 | 400px | 450px(内容区 ~402px + 导航栏 48px) | +| 作用范围 | — | 仅 popup 改布局;tab / sidepanel 不加导航 | + +--- + +## 布局设计 + +### 结构示意 + +```mermaid +flowchart LR + subgraph popup ["Popup 450x600"] + subgraph mainCol ["主列 flex-1"] + TopBar["TopBar"] + Content["RouterContainer"] + TopBar --> Content + end + NavCol["FeatureNav 48px"] + end + mainCol --- NavCol +``` + +### 尺寸约束 + +| 属性 | 值 | 说明 | +| -------- | ------ | ---------------------------------------------- | +| 总宽度 | 450px | `App.tsx` 容器 + `index.html` 内联样式同步修改 | +| 总高度 | 600px | 不变 | +| 主内容列 | flex-1 | 约 402px,需 `min-w-0` 防止 flex 子项溢出 | +| 导航栏 | 48px | Tailwind `w-12`,固定宽度,不参与 flex 收缩 | + +### 视觉规范 + +- 导航栏:左侧 `border-l border-border`,垂直排列图标按钮 +- 图标按钮:`h-9 w-9` 或 `h-10 w-10`,纯图标 +- Tooltip:使用原生 `title` 属性 + `aria-label={feature.label}`,不引入 Tooltip 组件 +- Active 态:背景 `bg-muted` + 左侧 `border-l-2 border-primary` +- 导航列表超出高度时:`overflow-y-auto`(600px 高度下 9 个工具通常无需滚动) + +--- + +## FeatureNav 组件规格 + +新建 `src/layout/FeatureNav/`,遵循 UI + Hook 分离模式(见 `.github/CODING_STANDARDS.md` §11)。 + +### 文件结构 + +``` +src/layout/FeatureNav/ +├── index.tsx # 垂直图标按钮列表、active 高亮、无障碍属性 +├── useFeatureNav.ts # 读路由状态,暴露 navItems / navigateTo / currentPage +├── resolveNavFeatures.ts # 从 Dashboard 迁出的功能列表解析逻辑 +└── __tests__/ + ├── index.test.tsx + └── resolveNavFeatures.test.ts +``` + +### resolveNavFeatures.ts + +从 `src/pages/Dashboard/dashboardFeatures.ts` 迁出并重命名: + +```typescript +export interface NavFeatureItem { + key: PageType; + feature: FeatureConfig & { icon: NonNullable }; +} + +export function resolveNavFeatures(keys: PageType[], visiblePages: PageType[]): NavFeatureItem[]; +``` + +逻辑与旧 `resolveDashboardFeatures` 一致: + +- 按 `keys`(即 `pageOrder`)顺序遍历 +- 跳过不在 `visiblePages` 中的项 +- 跳过无 `icon` 的 feature(dashboard 移除后不应出现) + +### useFeatureNav.ts + +```typescript +export interface UseFeatureNavReturn { + navItems: NavFeatureItem[]; + currentPage: PageType; + navigateTo: (page: PageType) => void; +} +``` + +- 从 `useRouter()` 读取 `currentPage`、`pageOrder`、`visiblePages`、`navigateTo` +- 用 `useMemo` 调用 `resolveNavFeatures(pageOrder, visiblePages)` 生成 `navItems` + +### index.tsx + +- 渲染 `