Procházet zdrojové kódy

feat: 导航菜单

BaiLuoYan před 1 měsícem
rodič
revize
321e4d7690

+ 38 - 87
.cursor/rules/home-web-coder.mdc

@@ -3,154 +3,105 @@ description:
 alwaysApply: true
 ---
 
-# 角色与原则
-
+# Nomo-home-web 项目专家指令集
 
+## 角色与原则
 
 你是 **Nomo-home-web 项目专家** (React, Tailwind)。
 
-
-
 **核心原则:**
 
-
-
 - **严格遵循目录结构与开发流程**。
-
 - **工具优先 (Utils First)**:编写代码前必须先检索现有的 `utils`, `components`, `common`, `defines`, `consts`。**优先复用,缺失时再补充通用函数**,禁止直接在业务逻辑中硬编码通用逻辑。
-
 - **Lint & Cleanup Mandatory**:每次代码修改完成后,必须进行 Lint 检查,并 **彻底删除无用代码**。禁止提交包含 Lint 错误、格式警告、或残留冗余逻辑的代码。
-
 - **类型安全**:全量使用 TS 接口 (Interface)。
+- **全量国际化 (i18n Mandatory)**:**禁止在页面或组件中硬编码任何文本内容**。所有展示给用户的字符串必须通过多语言方案实现,统一在 `locales` 中定义并使用翻译 Hook 调用。
 
+## 代码编写原则 (Code Construction Rules)
 
-
-# 代码编写原则 (Code Construction Rules)
-
-
-
-## 1. 通用与风格 (Zero Waste Policy)
-
-
+### 1. 通用与风格 (Zero Waste Policy)
 
 - **范式**:函数式/声明式编程,**严禁使用 Class**。
-
 - **模式**:采用 **RORO** (Receive Object, Return Object) 模式。
-
 - **自动清理 (Code Pruning)**:
-
   - **删除未使用的导入** (Unused Imports)。
-
   - **删除定义的但未使用的变量、常量或 Props**。
-
   - **删除所有被注释掉的代码块** (Commented-out code)。
-
-  - **精简逻辑**:如果一段逻辑不再被调用或已被新逻辑覆盖,必须立即物理删除,禁止保留。
+  - **精简逻辑**:如果一段逻辑不再被调用或已被新逻辑覆盖,必须立即物理删除。
 
 - **工具使用策略**:
-
+  - **React 优先**:优先使用 React 原生 Hooks 和工具(如 `useId`, `useMemo`, `useCallback`, `Fragment`)。
   - **库优先 (Library First)**:前端数据处理优先使用 **Ramda 和 Lodash**。
-
   - **先检索,后编写**:始终检查 `src/utils` 是否存在相关功能。
 
-  - **新增规范**:如果功能缺失且无法通过库解决,必须将其添加到合适的通用工具文件中(如 `stringUtils.ts`, `numberUtils.ts`),而不是在业务文件中写死。
-
 - **命名规范**:
-
   - **组件目录/文件**:**PascalCase** (如 `SysUserSelector.tsx`)。
-
   - **常规目录/文件**:**camelCase** (如 `authUtils.ts`)。
-
   - **变量**:描述性 + 助动词 (如 `isLoading`, `hasPermission`)。
-
-  - **导出**:优先使用 **Named Export**。
+  - **导出**:统一使用 **Named Export**。
 
 - **语法细节**:
-
   - 纯函数:使用 `function` 关键字。
-
   - 分号:**禁止省略分号**。
-
   - 条件语句:单行省略花括号 (如 `if (cond) return;`),**禁止使用 else** (改用 Early Return)。
 
+### 2. 组件逻辑拆分规范 (Logic Separation)
 
+为了保持代码的可读性与组织性,必须遵循以下结构规范:
 
-## 2. TypeScript & React
-
+- **强制目录化**:**禁止在 `components/` 根目录下直接添加包含多个逻辑文件的组件**。如果组件拆分了单独的 Hook 文件(如业务逻辑或响应逻辑),则**必须**为该组件建立独立的 PascalCase 目录。
+- **UI 与逻辑分离**:`index.tsx` 应当只关注 DOM 结构与样式,严禁在主文件中编写复杂的计算或状态处理逻辑。
+- **响应逻辑 Hooks**:涉及交互响应、事件处理、UI 状态同步的逻辑,必须编写为单独的 Hook(如 `useComponentAction.ts`)。
+- **业务逻辑 Hooks**:涉及 API 调用、数据转换、业务校验、全局状态管理的逻辑,必须拆分到单独的 Hook(如 `useComponentService.ts`)。
+- **目录内组织**:
+  ```text
+  /ComponentName
+    ├── index.tsx
+    ├── useComponentAction.ts
+    ├── useComponentService.ts
+    └── types.ts (可选)
+  ```
 
+### 3. TypeScript & React & 样式
 
 - **图标使用 (Icon Strategy)**:
-
-  1. 将 SVG 放入 `assets/iconify/` 对应目录 (multi-color 或 single-color)。
-
+  1. 将 SVG 放入 `assets/iconify/` 对应目录 (多色图标:multi-color,单色图标:single-color)。
   2. 代码导入:`import logo from '@/assets/iconify/...';`
-
   3. 使用:`<Icon icon={logo} />`。
-
-- **样式**:
-
+- **样式与布局规范**:
   - **Tailwind CSS 优先**:严禁内联样式 `style={{ ... }}`,严禁创建新的 CSS/Less 文件。
-
-  - **PC 优先**:针对管理后台优化大屏体验。
-
+  - **逻辑属性优先**:禁止使用 `left`, `right`, `ml-`, `pr-` 等硬编码方向。必须使用 **start** 和 **end**(如 `ms-`, `pe-`, `text-start`, `inset-inline-start`)以适配多语言/RTL 环境。。
+  - **Flex 布局优先**:除非 Flex 布局无法实现,否则必须优先使用 Flex 布局,禁止使用过时的浮动或复杂的绝对定位。
 - **性能优化 (Performance)**:
-
   - **避免无效重绘**:严禁因局部小数据变更导致大型父组件重新渲染。
-
   - **状态下沉**:保持 State 粒度最小化。
-
   - **稳定引用**:合理使用 `useMemo` 和 `useCallback`。
-
 - **React 组件**:
-
   - **必须使用 Function Components**。
+  - 最小化 `use client`, `useEffect`, `setState`。
 
-  - 静态内容/Helper 移至组件外或文件末尾。
-
-  - 最小化 `use client`, `useEffect`, `setState`,优先支持 RSC。
-
-
-
-## 3. 错误处理 (Error Handling)
-
-
+### 4. 错误处理 (Error Handling)
 
 - **Guard Clauses**:在函数开头处理错误/边缘情况并尽早返回。
-
-- **Happy Path**:核心成功逻辑放在函数最底部,避免嵌套。
-
+- **Happy Path**:核心成功逻辑放在函数最底部。
 - **反馈**:前端始终抛出用户友好的错误信息。
 
+## 前端开发流程 (nomo-home-web)
 
-
-# 前端开发流程 (nomo-home-web)
-
-
-
-## 核心规范
+### 核心规范
 
 - **常量**:必须定义在 `src/defines/`,禁止硬编码。
-
 - **请求**:使用 `utils/request.ts`。
+- **文本国际化**:所有文本必须进入 `src/locales/`,禁止在代码中直接书写中文或英文常量字符串。
+- **清理**:功能实现后,执行最后的“瘦身”扫描。
 
-- **清理**:在完成功能实现后,**执行最后的“瘦身”扫描**,确保没有因重构留下的废弃代码。
-
-
-
-## 新页面开发 4 步
+### 新页面开发 4 步
 
 1. **API 定义**:`src/services/pageName/` 创建 `typings.d.ts` 和 `index.ts`。
-
 2. **页面目录**:创建 `src/pages/PageName/` (PascalCase)。
-
-3. **页面入口**:创建 `index.tsx`。
-
-4. **配置**:`locales` 添加翻译,`routes.tsx` 添加路由。
-
-
+3. **页面入口**:创建 `index.tsx` (内部逻辑需抽离至 Hooks)。
+4. **配置**:**必须** 在 `locales` 添加多语言翻译,`routes.tsx` 添加路由。
 
 ---
 
-
-
-**注意:当你完成代码修改后,请主动自检:是否已经删除了所有无用的变量、导入和注释?**
+**注意:当你完成代码修改后,请主动执行清理:彻底删除无用的导入、变量、注释、冗余逻辑,确保文本已全部多语言化,逻辑已抽离至 Hooks,且样式符合逻辑属性规范。**

+ 6 - 8
eslint.config.js

@@ -6,6 +6,7 @@ import tseslint from 'typescript-eslint';
 import react from 'eslint-plugin-react';
 import importPlugin from 'eslint-plugin-import';
 import jsxA11y from 'eslint-plugin-jsx-a11y';
+import prettierConfig from 'eslint-config-prettier/flat';
 
 export default [
     { ignores: ['dist/**/*', 'node_modules/**/*', 'public/**/*'] },
@@ -89,14 +90,8 @@ export default [
                     distinctGroup: true,
                 },
             ],
-            indent: [
-                'error',
-                4,
-                {
-                    SwitchCase: 1,
-                    ignoredNodes: ['ConditionalExpression'],
-                },
-            ],
+            // 移除 indent 规则,让 Prettier 处理缩进
+            // indent 规则与 Prettier 冲突,已由 eslint-config-prettier 禁用
             'no-unused-vars': 'off',
             'no-redeclare': 'off',
             'prefer-const': 'error',
@@ -112,4 +107,7 @@ export default [
             },
         },
     },
+    // 禁用所有与 Prettier 冲突的 ESLint 规则
+    // eslint-config-prettier 必须在配置数组的最后,以覆盖其他配置
+    prettierConfig,
 ];

+ 10 - 5
src/assets/iconify/multi-color/logo.svg

@@ -1,5 +1,10 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-    <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" fill="none" stroke="#4F26E5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-    <path d="M12 2L2 7l10 5 10-5-10-5z" fill="#2F26E5" fill-opacity="0.1"/>
-</svg> 
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.90918 5.3948C3.90918 5.3948 11.0351 1.96498 15.9149 1.96623C20.7947 1.96749 27.9092 5.3948 27.9092 5.3948V14.0268C27.9092 26.3091 15.9109 29.2999 15.9109 29.2999C15.9109 29.2999 3.90918 26.3091 3.90918 14.0235V5.3948Z" fill="url(#paint0_linear_1570_15331)"/>
+<path d="M22.8803 7.45195V21.6234H20.1912L12.3277 11.9705H12.1892V21.6234H9.16602V7.45195H11.8714L19.7267 17.1118H19.8734V7.45195H22.8803Z" fill="white" style="fill:white;fill-opacity:1;"/>
+<defs>
+<linearGradient id="paint0_linear_1570_15331" x1="15.9092" y1="1.96623" x2="15.9092" y2="29.2999" gradientUnits="userSpaceOnUse">
+<stop stop-color="#0EA5E9" style="stop-color:#0EA5E9;stop-color:color(display-p3 0.0549 0.6471 0.9137);stop-opacity:1;"/>
+<stop offset="1" stop-color="#3B82F6" style="stop-color:#3B82F6;stop-color:color(display-p3 0.2314 0.5098 0.9647);stop-opacity:1;"/>
+</linearGradient>
+</defs>
+</svg>

+ 4 - 0
src/assets/iconify/single-color/chevron-down.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path d="M6 9l6 6 6-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 4 - 0
src/assets/iconify/single-color/close.svg

@@ -0,0 +1,4 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.33398 3.33334L16.6673 16.6667" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.33398 16.6667L16.6673 3.33334" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
src/assets/iconify/single-color/menu.svg

@@ -0,0 +1,5 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.3125 4.97904H16.6458" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.3125 9.97904H16.6458" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.3125 14.979H16.6458" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 171 - 0
src/components/Topbar/index.tsx

@@ -0,0 +1,171 @@
+import { Fragment, memo, useMemo } from 'react';
+
+import { Dropdown, type MenuProps } from 'antd';
+import { Icon } from '@iconify/react';
+import { useTranslation } from 'react-i18next';
+
+import logoIcon from '@/assets/iconify/multi-color/logo.svg';
+import menuIcon from '@/assets/iconify/single-color/menu.svg';
+import closeIcon from '@/assets/iconify/single-color/close.svg';
+import chevronDownIcon from '@/assets/iconify/single-color/chevron-down.svg';
+import type { NavMenuItem } from '@/utils/navUtils';
+import { useTopbarAction } from './useTopbarAction';
+import { useTopbarResponsive } from './useTopbarResponsive';
+import { useTopbarService } from './useTopbarService';
+
+const MAX_CONTAINER_WIDTH = 1440;
+
+const Topbar = memo(() => {
+    const { t } = useTranslation();
+    const { isMobile } = useTopbarResponsive();
+    const { menuItems, isActive, getMenuItemLabel } = useTopbarService();
+    const {
+        menuContainerRef,
+        isMobileMenuOpen,
+        isOverflowMenuOpen,
+        visibleMenuItems,
+        overflowMenuItems,
+        handleMenuClick,
+        toggleMobileMenu,
+        closeMobileMenu,
+        toggleOverflowMenu,
+        setMenuItemRef,
+    } = useTopbarAction({ menuItems, isMobile });
+
+    const overflowMenuProps: MenuProps = useMemo(
+        () => ({
+            items: overflowMenuItems.map((item: NavMenuItem) => ({
+                key: item.name,
+                label: getMenuItemLabel(item),
+                onClick: () => handleMenuClick(item.path),
+            })),
+        }),
+        [overflowMenuItems, getMenuItemLabel, handleMenuClick]
+    );
+
+    return (
+        <Fragment>
+            <header className="fixed top-0 start-0 end-0 z-50 bg-black/90 border-b border-white/10 backdrop-blur-sm">
+                <div
+                    className="h-[81px] px-5 sm:px-6 lg:px-8 flex items-center justify-between"
+                    style={{ maxWidth: MAX_CONTAINER_WIDTH, margin: '0 auto' }}
+                >
+                    {/* Logo */}
+                    <div className="flex-shrink-0 flex items-center gap-2">
+                        <Icon icon={logoIcon} className="w-8 h-8" />
+                        <h1 className="text-base font-normal text-white leading-6">
+                            {t('components.topbar.logo')}
+                        </h1>
+                    </div>
+
+                    {/* Desktop Menu */}
+                    {!isMobile && (
+                        <nav
+                            ref={menuContainerRef}
+                            className="flex-1 flex items-center justify-end gap-2.5 ms-8 min-w-0"
+                        >
+                            <div className="flex items-center gap-2.5 flex-1 justify-end min-w-0">
+                                {visibleMenuItems.map((item: NavMenuItem) => {
+                                    const active = isActive(item.path);
+                                    const baseClasses =
+                                        'px-5 py-2.5 rounded-[20px] transition-colors font-normal text-base leading-6 border-none whitespace-nowrap';
+                                    const activeClasses = 'bg-[#0FA4E9] text-white';
+                                    const inactiveClasses =
+                                        'bg-transparent text-white/80 hover:text-white';
+
+                                    return (
+                                        <button
+                                            key={item.name}
+                                            ref={setMenuItemRef(item.name)}
+                                            onClick={() => handleMenuClick(item.path)}
+                                            className={`${baseClasses} ${active ? activeClasses : inactiveClasses}`}
+                                        >
+                                            {getMenuItemLabel(item)}
+                                        </button>
+                                    );
+                                })}
+                                {overflowMenuItems.length > 0 && (
+                                    <Dropdown
+                                        menu={overflowMenuProps}
+                                        open={isOverflowMenuOpen}
+                                        onOpenChange={toggleOverflowMenu}
+                                        overlayClassName="topbar-overflow-menu"
+                                        trigger={['click']}
+                                        placement="bottomRight"
+                                    >
+                                        <button
+                                            className="px-5 py-2.5 rounded-[20px] transition-colors font-normal text-base leading-6 border-none bg-transparent text-white/80 hover:text-white flex items-center gap-1 whitespace-nowrap"
+                                            onClick={toggleOverflowMenu}
+                                        >
+                                            <span>...</span>
+                                            <Icon
+                                                icon={chevronDownIcon}
+                                                className={`w-4 h-4 transition-transform ${
+                                                    isOverflowMenuOpen ? 'rotate-180' : ''
+                                                }`}
+                                            />
+                                        </button>
+                                    </Dropdown>
+                                )}
+                            </div>
+                        </nav>
+                    )}
+
+                    {/* Mobile Menu Button */}
+                    {isMobile && (
+                        <button
+                            onClick={toggleMobileMenu}
+                            className="p-2 rounded-lg bg-transparent border-none text-white"
+                            aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
+                        >
+                            <Icon
+                                icon={isMobileMenuOpen ? closeIcon : menuIcon}
+                                className="w-6 h-6"
+                            />
+                        </button>
+                    )}
+                </div>
+            </header>
+
+            {/* Mobile Expanded Menu (Sidebar) - 移到 header 外部 */}
+            {isMobile && isMobileMenuOpen && (
+                <>
+                    {/* 遮罩层 */}
+                    <div
+                        className="fixed inset-0 bg-black/50 z-40"
+                        style={{ top: '81px' }}
+                        onClick={closeMobileMenu}
+                    />
+                    {/* 侧边栏菜单 */}
+                    <nav
+                        className="fixed end-0 w-[250px] bg-black/80 backdrop-blur-[4px] z-50"
+                        style={{ top: '81px', bottom: 0 }}
+                    >
+                        <div className="h-full px-[30px] pt-[30px] flex flex-col gap-[14px]">
+                            {menuItems.map((item: NavMenuItem) => {
+                                const active = isActive(item.path);
+                                return (
+                                    <button
+                                        key={item.name}
+                                        onClick={() => handleMenuClick(item.path)}
+                                        className={`w-full text-start text-xl font-medium leading-[1.4em] transition-colors border-none bg-transparent ${
+                                            active
+                                                ? 'text-[#0EA5E9]'
+                                                : 'text-white hover:text-white/80'
+                                        }`}
+                                    >
+                                        {getMenuItemLabel(item)}
+                                    </button>
+                                );
+                            })}
+                        </div>
+                    </nav>
+                </>
+            )}
+        </Fragment>
+    );
+});
+
+Topbar.displayName = 'Topbar';
+
+export default Topbar;

+ 137 - 0
src/components/Topbar/useTopbarAction.ts

@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useNavigate } from 'react-router-dom';
+
+import type { NavMenuItem } from '@/utils/navUtils';
+
+const MOBILE_BREAKPOINT = 768;
+const OVERFLOW_BUTTON_WIDTH = 80;
+const MENU_ITEM_GAP = 10;
+
+interface UseTopbarActionParams {
+    menuItems: NavMenuItem[];
+    isMobile: boolean;
+}
+
+/**
+ * Topbar UI 交互响应逻辑 Hook
+ * 处理 UI 状态、事件处理、DOM 交互等响应逻辑
+ */
+export function useTopbarAction({ menuItems, isMobile }: UseTopbarActionParams) {
+    const navigate = useNavigate();
+    const menuContainerRef = useRef<HTMLDivElement>(null);
+    const menuItemsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
+    const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+    const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
+    const [visibleMenuItems, setVisibleMenuItems] = useState<NavMenuItem[]>([]);
+    const [overflowMenuItems, setOverflowMenuItems] = useState<NavMenuItem[]>([]);
+
+    const calculateVisibleItems = useCallback(() => {
+        if (isMobile || !menuContainerRef.current || menuItems.length === 0) {
+            setVisibleMenuItems(menuItems);
+            setOverflowMenuItems([]);
+            return;
+        }
+
+        const container = menuContainerRef.current;
+        const containerWidth = container.offsetWidth;
+
+        let totalWidth = 0;
+        const visible: NavMenuItem[] = [];
+        const overflow: NavMenuItem[] = [];
+
+        for (let i = 0; i < menuItems.length; i++) {
+            const item = menuItems[i];
+            const itemElement = menuItemsRef.current.get(item.name);
+
+            if (itemElement) {
+                const itemWidth = itemElement.offsetWidth + MENU_ITEM_GAP;
+                const needsOverflowButton = i < menuItems.length - 1;
+
+                if (
+                    totalWidth + itemWidth + (needsOverflowButton ? OVERFLOW_BUTTON_WIDTH : 0) <=
+                    containerWidth
+                ) {
+                    visible.push(item);
+                    totalWidth += itemWidth;
+                } else {
+                    overflow.push(...menuItems.slice(i));
+                    break;
+                }
+            } else {
+                visible.push(item);
+            }
+        }
+
+        setVisibleMenuItems(visible);
+        setOverflowMenuItems(overflow);
+    }, [isMobile, menuItems]);
+
+    useEffect(() => {
+        if (!isMobile && menuItems.length > 0) {
+            const timer = setTimeout(() => {
+                calculateVisibleItems();
+            }, 0);
+
+            return () => clearTimeout(timer);
+        }
+    }, [isMobile, menuItems.length, calculateVisibleItems]);
+
+    useEffect(() => {
+        if (isMobile) {
+            return;
+        }
+
+        const handleResize = () => {
+            calculateVisibleItems();
+        };
+
+        window.addEventListener('resize', handleResize);
+        return () => window.removeEventListener('resize', handleResize);
+    }, [isMobile, calculateVisibleItems]);
+
+    const handleMenuClick = useCallback(
+        (path: string) => {
+            navigate(path);
+            setIsMobileMenuOpen(false);
+            setIsOverflowMenuOpen(false);
+        },
+        [navigate]
+    );
+
+    const toggleMobileMenu = useCallback(() => {
+        setIsMobileMenuOpen((prev) => !prev);
+    }, []);
+
+    const closeMobileMenu = useCallback(() => {
+        setIsMobileMenuOpen(false);
+    }, []);
+
+    const toggleOverflowMenu = useCallback(() => {
+        setIsOverflowMenuOpen((prev) => !prev);
+    }, []);
+
+    const setMenuItemRef = useCallback((itemName: string) => {
+        return (el: HTMLButtonElement | null) => {
+            if (el) {
+                menuItemsRef.current.set(itemName, el);
+            } else {
+                menuItemsRef.current.delete(itemName);
+            }
+        };
+    }, []);
+
+    return {
+        menuContainerRef,
+        menuItemsRef,
+        isMobileMenuOpen,
+        isOverflowMenuOpen,
+        visibleMenuItems,
+        overflowMenuItems,
+        handleMenuClick,
+        toggleMobileMenu,
+        closeMobileMenu,
+        toggleOverflowMenu,
+        setMenuItemRef,
+    };
+}

+ 23 - 0
src/components/Topbar/useTopbarResponsive.ts

@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+const MOBILE_BREAKPOINT = 768;
+
+/**
+ * Topbar 响应式检测 Hook
+ * 检测当前是否为移动端
+ */
+export function useTopbarResponsive() {
+    const [isMobile, setIsMobile] = useState(false);
+
+    useEffect(() => {
+        const checkMobile = () => {
+            setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+        };
+
+        checkMobile();
+        window.addEventListener('resize', checkMobile);
+        return () => window.removeEventListener('resize', checkMobile);
+    }, []);
+
+    return { isMobile };
+}

+ 39 - 0
src/components/Topbar/useTopbarService.ts

@@ -0,0 +1,39 @@
+import { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { matchPath, useLocation } from 'react-router-dom';
+
+import { getNavMenuItems, type NavMenuItem } from '@/utils/navUtils';
+
+/**
+ * Topbar 业务逻辑 Hook
+ * 处理菜单项获取、路由匹配、翻译等业务逻辑
+ */
+export function useTopbarService() {
+    const { t, i18n } = useTranslation();
+    const location = useLocation();
+
+    const menuItems = useMemo(() => getNavMenuItems(), []);
+
+    const isActive = useCallback(
+        (path: string): boolean => {
+            return matchPath({ path, end: false }, location.pathname) !== null;
+        },
+        [location.pathname]
+    );
+
+    const getMenuItemLabel = useCallback(
+        (item: NavMenuItem): string => {
+            return t(item.locale || `menus.${item.name}`, {
+                defaultValue: item.name,
+            });
+        },
+        [t, i18n.language]
+    );
+
+    return {
+        menuItems,
+        isActive,
+        getMenuItemLabel,
+    };
+}

+ 4 - 9
src/layouts/BasicLayout.tsx

@@ -1,17 +1,12 @@
 import { Outlet } from 'react-router-dom';
 
-import LanguageSwitch from '@/components/LanguageSwitch';
+import Topbar from '@/components/Topbar';
 
 const BasicLayout = () => {
     return (
-        <div className="min-h-screen bg-gray-50">
-            <header className="bg-white shadow-sm">
-                <div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
-                    <h1 className="text-xl font-bold text-gray-900">Visa Card H5</h1>
-                    <LanguageSwitch />
-                </div>
-            </header>
-            <main className="max-w-7xl mx-auto px-4 py-6">
+        <div className="min-h-screen bg-black">
+            <Topbar />
+            <main className="pt-[81px]">
                 <Outlet />
             </main>
         </div>

+ 5 - 1
src/locales/en-US/components.ts

@@ -1 +1,5 @@
-export default {};
+export default {
+    topbar: {
+        logo: 'NOMO',
+    },
+};

+ 3 - 0
src/locales/en-US/menus.ts

@@ -4,4 +4,7 @@ export default {
     ['404']: '404',
     ['home']: 'Home',
     ['routeDemo']: 'Route Demo',
+    ['test']: 'Test',
+    ['test.test1']: 'Test 1',
+    ['test.test2']: 'Test 2',
 };

+ 5 - 1
src/locales/fa-IR/components.ts

@@ -1 +1,5 @@
-export default {};
+export default {
+    topbar: {
+        logo: 'NOMO',
+    },
+};

+ 3 - 0
src/locales/fa-IR/menus.ts

@@ -4,4 +4,7 @@ export default {
     ['404']: '404',
     ['home']: 'صفحه اصلی',
     ['routeDemo']: 'نمایش مسیر',
+    ['test']: 'تست',
+    ['test.test1']: 'تست 1',
+    ['test.test2']: 'تست 2',
 };

+ 5 - 2
src/router/routes.tsx

@@ -35,12 +35,12 @@ const routes: AppRouteObject[] = [
                     {
                         name: 'test1',
                         path: '/test/test1',
-                        element: <div>test1</div>,
+                        element: <div className='text-white'>test1</div>,
                     },
                     {
                         name: 'test2',
                         path: '/test/test2',
-                        element: <div>test2</div>,
+                        element: <div className='text-white'>test2</div>,
                     },
                 ],
             },
@@ -50,16 +50,19 @@ const routes: AppRouteObject[] = [
         name: '403',
         path: '/403',
         element: <Forbidden />,
+        inMenu: false,
     },
     {
         name: '500',
         path: '/500',
         element: <ServerError />,
+        inMenu: false,
     },
     {
         name: '404',
         path: '*',
         element: <NotFound />,
+        inMenu: false,
     },
 ] as AppRouteObject[];
 

+ 1 - 0
src/router/types.ts

@@ -6,5 +6,6 @@ import type { RouteObject } from 'react-router-dom';
 export type AppRouteObject = RouteObject & {
     name: string;
     locale?: string;
+    inMenu?: boolean; // 是否在导航菜单中显示,默认为 true
     children?: AppRouteObject[];
 };

+ 1 - 1
src/utils/authUtils.ts

@@ -10,7 +10,7 @@ export const userKey = 'user-info';
 export const tokenKey = 'authorized-token';
 
 /** 获取`token` */
-export function getToken(): API.UserInfo {
+export function getToken(): API.UserInfo | null {
     return Cookies.get(tokenKey) ? JSON.parse(Cookies.get(tokenKey)!) : ls.getLocal(userKey);
 }
 

+ 88 - 0
src/utils/navUtils.ts

@@ -0,0 +1,88 @@
+import routerConfig from '@/router/routes';
+
+import type { AppRouteObject } from '@/router/types';
+
+export interface NavMenuItem {
+    name: string;
+    path: string;
+    locale?: string;
+}
+
+/**
+ * 从路由配置中提取菜单项
+ * 只包含 inMenu !== false 的路由,且只提取顶层路由(不包含子路由)
+ */
+function extractMenuItems(routes: AppRouteObject[]): NavMenuItem[] {
+    const items: NavMenuItem[] = [];
+
+    for (const route of routes) {
+        // 如果路由有 children,只处理第一层 children,不递归处理更深层的子路由
+        if (route.children) {
+            for (const childRoute of route.children) {
+                // 跳过 index 路由
+                if (childRoute.index) {
+                    continue;
+                }
+
+                // 检查是否应该在菜单中显示
+                if (childRoute.inMenu === false) {
+                    continue;
+                }
+
+                // 只有有 name 和 path 的路由才添加到菜单
+                // 不处理子路由的 children,子路由不应该出现在顶层菜单
+                if (childRoute.name && childRoute.path) {
+                    const currentPath = childRoute.path.startsWith('/')
+                        ? childRoute.path
+                        : `/${childRoute.path}`;
+
+                    // 构建 locale key
+                    const locale = `menus.${childRoute.name}`;
+
+                    items.push({
+                        name: childRoute.name,
+                        path: currentPath,
+                        locale,
+                    });
+                }
+            }
+            // 处理完 children 后继续下一个路由,不处理当前路由本身
+            continue;
+        }
+
+        // 如果没有 children,处理当前路由
+        // 跳过 index 路由
+        if (route.index) {
+            continue;
+        }
+
+        // 检查是否应该在菜单中显示
+        if (route.inMenu === false) {
+            continue;
+        }
+
+        // 只有有 name 和 path 的路由才添加到菜单
+        if (route.name && route.path) {
+            const currentPath = route.path.startsWith('/') ? route.path : `/${route.path}`;
+
+            // 构建 locale key
+            const locale = `menus.${route.name}`;
+
+            items.push({
+                name: route.name,
+                path: currentPath,
+                locale,
+            });
+        }
+    }
+
+    return items;
+}
+
+/**
+ * 获取导航菜单项
+ * @returns 菜单项数组
+ */
+export function getNavMenuItems(): NavMenuItem[] {
+    return extractMenuItems(routerConfig);
+}