fix(i18n): improve language path handling and prevent unnecessary redirects

- Fix edge cases in path processing for language switching
- Add checks to prevent redirects to the same page
- Update documentation to reflect current project structure
This commit is contained in:
joyzhao
2025-06-19 10:13:49 +08:00
parent 7bb617a5c5
commit 4621223d26
3 changed files with 60 additions and 45 deletions

View File

@@ -11,7 +11,7 @@
- **主要框架**: [Astro](https://astro.build/) - 一个用于构建快速、内容驱动的网站的现代前端框架。它允许使用多种 UI 框架(如 React, Vue, Svelte 等)并支持服务器端渲染 (SSR) 和静态站点生成 (SSG)。 - **主要框架**: [Astro](https://astro.build/) - 一个用于构建快速、内容驱动的网站的现代前端框架。它允许使用多种 UI 框架(如 React, Vue, Svelte 等)并支持服务器端渲染 (SSR) 和静态站点生成 (SSG)。
- **UI 框架**: [React](https://react.dev/) - 用于构建用户界面的 JavaScript 库。在本项目中,部分组件(如头部、各个内容区域)是使用 React 实现的,并通过 Astro 的集成功能嵌入到页面中。 - **UI 框架**: [React](https://react.dev/) - 用于构建用户界面的 JavaScript 库。在本项目中,部分组件(如头部、各个内容区域)是使用 React 实现的,并通过 Astro 的集成功能嵌入到页面中。
- **样式**: [Tailwind CSS](https://tailwindcss.com/) - 一个实用工具优先的 CSS 框架,用于快速构建自定义用户界面。通过 `@tailwindcss/vite` 集成到 Astro 项目中。 - **样式**: [Tailwind CSS](https://tailwindcss.com/) - 一个实用工具优先的 CSS 框架,用于快速构建自定义用户界面。通过 `@tailwindcss/vite` 集成到 Astro 项目中。
- **动画**: [Framer Motion](https://www.framer.com/motion/) (`framer-motion`) - 一个用于 React 的生产级动画库,用于实现声明式的、基于物理的动画以及复杂的交互动效。在项目中,它被用于增强用户体验,例如在 <mcsymbol name="HeroSection" path="src/components/HeroSection.tsx" filename="HeroSection.tsx" type="function" startline="5"></mcsymbol> 等组件中实现元素的入场动画、悬停效果等。具体使用方式可以参考 <mcfile path="src/components/MotionWrapper.tsx" name="MotionWrapper.tsx"></mcfile> 组件,它可能是一个统一处理动画逻辑的封装。 - **动画**: [Framer Motion](https://www.framer.com/motion/) (`framer-motion`) - 一个用于 React 的生产级动画库,用于实现声明式的、基于物理的动画以及复杂的交互动效。在项目中,它被用于增强用户体验,例如在 <mcsymbol name="HeroSection" path="src/components/HeroSection.tsx" filename="HeroSection.tsx" type="function" startline="5"></mcsymbol> 等组件中实现元素的入场动画、悬停效果等。具体使用方式可以参考 <mcfile path="src/components/MotionWrapper.tsx" name="MotionWrapper.tsx"></mcfile> 组件,它是一个统一处理动画逻辑的封装。
- **图标**: [Lucide React](https://lucide.dev/) - 一个简单、美观的 SVG 图标库。 - **图标**: [Lucide React](https://lucide.dev/) - 一个简单、美观的 SVG 图标库。
- **包管理器**: [pnpm](https://pnpm.io/) - 一个快速、磁盘空间高效的包管理器。 - **包管理器**: [pnpm](https://pnpm.io/) - 一个快速、磁盘空间高效的包管理器。
- **TypeScript**: 用于类型检查和提高代码质量。 - **TypeScript**: 用于类型检查和提高代码质量。
@@ -35,8 +35,8 @@
- `class-variance-authority`: 用于创建可变样式的组件。 - `class-variance-authority`: 用于创建可变样式的组件。
- `clsx`: 用于有条件地组合类名。 - `clsx`: 用于有条件地组合类名。
- `tailwind-merge`: 用于合并 Tailwind CSS 类名,避免冲突。 - `tailwind-merge`: 用于合并 Tailwind CSS 类名,避免冲突。
- `tw-animate-css`: 可能是用于集成 Animate.css 的 Tailwind CSS 插件(虽然 `package.json` 中列出,但具体用法需进一步查看代码) - `tw-animate-css`: 用于集成 Animate.css 的 Tailwind CSS 插件。
- **Radix UI**: `@radix-ui/react-slot` 被用作依赖,这表明项目可能使用 Radix UI 的某些底层实用程序或组件,通常用于构建可访问和可组合的 UI 组件。 - **Radix UI**: `@radix-ui/react-slot` 被用作依赖,用于构建可访问和可组合的 UI 组件。
## 4. 目录结构 ## 4. 目录结构
@@ -51,7 +51,7 @@
├── LICENSE # 项目许可证 ├── LICENSE # 项目许可证
├── README.md # 项目自述文件 ├── README.md # 项目自述文件
├── astro.config.mjs # Astro 配置文件 ├── astro.config.mjs # Astro 配置文件
├── components.json # 可能是 Shadcn UI 或类似组件库的配置文件 ├── components.json # Shadcn UI 组件库的配置文件
├── package.json # 项目依赖和脚本配置 ├── package.json # 项目依赖和脚本配置
├── pnpm-lock.yaml # pnpm 锁文件 ├── pnpm-lock.yaml # pnpm 锁文件
├── public/ # 静态资源目录 (如图片、favicon) ├── public/ # 静态资源目录 (如图片、favicon)
@@ -80,7 +80,7 @@
│ │ └── Layout.astro # 全局页面布局 │ │ └── Layout.astro # 全局页面布局
│ ├── lib/ # 辅助函数和数据 │ ├── lib/ # 辅助函数和数据
│ │ ├── data.ts # 存储个人信息、经历、技能等静态数据 │ │ ├── data.ts # 存储个人信息、经历、技能等静态数据
│ │ └── utils.ts # 通用工具函数 (当前可能为空或包含少量工具) │ │ └── utils.ts # 通用工具函数
│ ├── pages/ # Astro 页面组件 │ ├── pages/ # Astro 页面组件
│ │ ├── index.astro # 网站主页 (英文) │ │ ├── index.astro # 网站主页 (英文)
│ │ └── zh/ # 中文语言目录 │ │ └── zh/ # 中文语言目录
@@ -95,8 +95,8 @@
- **`astro.config.mjs`**: 配置 Astro 项目,包括 Vite 插件(如 Tailwind CSS和 Astro 集成(如 React - **`astro.config.mjs`**: 配置 Astro 项目,包括 Vite 插件(如 Tailwind CSS和 Astro 集成(如 React
- **`package.json`**: 定义项目元数据、依赖项和 npm 脚本(如 `dev`, `build`, `preview`)。 - **`package.json`**: 定义项目元数据、依赖项和 npm 脚本(如 `dev`, `build`, `preview`)。
- **`tsconfig.json`**: 配置 TypeScript 编译器选项,包括路径别名(`@/*` 指向 `./src/*`)和 JSX 设置。 - **`tsconfig.json`**: 配置 TypeScript 编译器选项,包括路径别名(`@/*` 指向 `./src/*`)和 JSX 设置。
- **`src/layouts/Layout.astro`**: 定义网站的全局 HTML 结构、头部信息(`<head>`)、全局样式和一些客户端脚本(如主题切换逻辑)。所有页面都将使用此布局。 - **`src/layouts/Layout.astro`**: 定义网站的全局 HTML 结构、头部信息(`<head>`)、全局样式和客户端脚本。所有页面都将使用此布局。
- **`src/pages/index.astro`**: 网站的英文版入口页面。它导入并使用 `Layout.astro` 作为布局,并按顺序引入各个 React 内容区域组件(如个人简介、工作经历、技能、项目)。通过 `getLangFromUrl` 从 URL 判断当前语言并传递给布局和组件。注意 `client:only="react"`指令表示这些 React 组件仅在客户端渲染。 - **`src/pages/index.astro`**: 网站的英文版入口页面。它导入并使用 `Layout.astro` 作为布局,并按顺序引入各个 React 内容区域组件。通过 `getLangFromUrl` 从 URL 判断当前语言并传递给布局和组件。`client:only="react"`指令表示这些 React 组件仅在客户端渲染。
- **`src/pages/zh/index.astro`**: 网站的中文版入口页面,结构与 `index.astro` 类似,但为中文内容服务,展示个人简介、工作经历、技能和项目。 - **`src/pages/zh/index.astro`**: 网站的中文版入口页面,结构与 `index.astro` 类似,但为中文内容服务,展示个人简介、工作经历、技能和项目。
- **`src/lib/data.ts`**: 存储网站展示的静态数据,如个人信息、工作经历、技能列表和项目信息。文件中的许多键值对应于 `src/i18n/ui.ts` 中的翻译键,以支持多语言。这使得内容易于更新和管理,而无需直接修改组件代码。 - **`src/lib/data.ts`**: 存储网站展示的静态数据,如个人信息、工作经历、技能列表和项目信息。文件中的许多键值对应于 `src/i18n/ui.ts` 中的翻译键,以支持多语言。这使得内容易于更新和管理,而无需直接修改组件代码。
- **`src/i18n/ui.ts`**: 定义了支持的语言 (英语和简体中文) 以及所有需要翻译的文本内容的键值对。是实现网站国际化的核心文件。 - **`src/i18n/ui.ts`**: 定义了支持的语言 (英语和简体中文) 以及所有需要翻译的文本内容的键值对。是实现网站国际化的核心文件。
@@ -106,30 +106,38 @@
## 6. 数据流和状态管理 ## 6. 数据流和状态管理
- **静态数据与国际化**: 大部分内容数据(如个人信息、工作经历、技能、项目等)的键名存储在 `src/lib/data.ts` 中。这些键名随后被用于从 `src/i18n/ui.ts` 中获取对应语言的实际文本。React 组件接收 `lang` prop并使用 `useTranslations` 工具函数来获取翻译后的文本进行渲染 - **静态数据驱动**:项目主要依赖 `src/lib/data.ts` 中的静态数据来渲染内容。这意味着大部分内容在构建时就已经确定,无需复杂的运行时数据获取或状态管理
- **主题切换**: `src/layouts/Layout.astro` 中包含一个内联脚本,用于处理暗黑/明亮模式的主题切换,并将用户的偏好存储在 `localStorage` 中。此外,`src/components/ui/ThemeToggle.tsx` 组件提供了用户手动切换主题的交互界面 - **无复杂状态管理**:由于项目性质(个人简历/作品集),没有引入 Redux、Zustand 或 React Context 等复杂的状态管理库。组件的状态管理主要通过 React 自身的 `useState``useEffects` Hook 在组件内部完成,或者通过 props 从父组件传递
- **语言切换**: 语言切换通过 `src/components/LanguageSwitcher.tsx` 组件实现,它可能通过改变 URL 路径 (例如 `/en/``/zh/`) 来加载不同语言版本的页面。`src/i18n/utils.ts` 中的 `getLangFromUrl``getLocalizedPath` 辅助这一过程 - **国际化数据流**`src/i18n/ui.ts` 提供翻译文本,`src/i18n/utils.ts` 提供辅助函数来处理语言切换和文本获取,确保多语言内容正确显示
## 7. 构建和部署 ## 7. 构建和部署
- **开发**: 使用 `pnpm run dev` 命令启动 Astro 开发服务器 - **构建命令**:项目使用 `pnpm run build` 命令进行构建。此命令会触发 Astro 的构建过程,将项目编译为静态文件,输出到 `dist/` 目录
- **构建**: 使用 `pnpm run build` 命令将项目构建为静态文件,输出到 `dist/` 目录(默认情况下,可由 `astro.config.mjs` 配置) - **部署**:由于是静态网站,构建产物可以直接部署到任何静态文件托管服务,如 Netlify, Vercel, GitHub Pages 等
- **预览**: 使用 `pnpm run preview` 命令在本地预览构建后的站点。
## 8. AI 代码重构和功能开发指南 ## 8. AI 代码重构和功能开发指南
- **遵循现有模式**: 在添加新组件或修改现有组件时,请参考 `src/components/``src/pages/` 中的现有代码风格和结构。 在进行代码重构或新功能开发时,请遵循以下原则:
- **数据驱动**: 如果添加新的内容区域,考虑是否可以将相关数据添加到 `src/lib/data.ts` 中,以保持内容与表示的分离。
- **组件化**: 优先创建可复用的 React 组件。 - **保持 Astro 核心优势**:优先使用 Astro 的 Islands 架构,确保大部分内容以静态 HTML 形式提供仅在必要时水合hydrate交互式组件。
- **Astro 与 React 集成**: 理解 Astro 如何与 React 组件集成,特别是 `client:*` 指令的使用 - **React 组件化**:对于需要交互的 UI 部分,使用 React 进行组件化开发,并确保组件是可复用和可测试的
- **Tailwind CSS**: 使用 Tailwind CSS 实用工具类进行样式设置 - **Tailwind CSS 优先**:样式应通过 Tailwind CSS 实用程序类实现。只有在 Tailwind 无法满足需求时,才考虑使用自定义 CSS
- **TypeScript**: 确保新代码符合 TypeScript 的类型要求 - **Framer Motion 动画**:所有动画效果通过 Framer Motion 实现,保持动画逻辑的统一性
- **动画**: 如果需要添加动画,应优先使用 Framer Motion。可以参考 <mcfile path="src/components/MotionWrapper.tsx" name="MotionWrapper.tsx"></mcfile> 的实现方式,或者直接在组件中使用 `motion` 组件和 `variants` 来定义动画。确保动画效果流畅且符合项目整体风格。查阅 <mcurl name="Framer Motion 文档" url="https://www.framer.com/motion/"></mcurl> 获取更详细的 API 和示例 - **国际化支持**:所有用户可见的文本通过 `src/i18n/ui.ts` 进行管理,确保新功能也支持多语言
- **可访问性**: 在开发新功能时,请注意可访问性最佳实践 - **数据驱动**:新功能所需的数据集中管理,例如添加到 `src/lib/data.ts` 中,而不是硬编码在组件内部
- **性能优化**:关注 Lighthouse 等工具的性能指标,确保代码更改不会引入性能瓶颈。
- **可访问性**:确保所有 UI 元素都符合 WCAG 标准,特别是对于交互式组件。
- **代码质量**:遵循 TypeScript 最佳实践,编写清晰、可读、有注释的代码。
- **测试**:对于复杂逻辑或关键组件,考虑编写单元测试或集成测试。
## 9. 潜在的改进和扩展方向 ## 9. 潜在的改进和扩展方向
- **CMS 集成**: 对于更动态的内容管理,可以考虑集成一个 Headless CMS (如 Sanity, Contentful)。`package.json` 中提到了 `Sanity (CMS)`,这可能是一个未来的方向或之前的尝试 - **CMS 集成**:将静态数据(如项目、博客文章)迁移到 CMS如 Contentful, Sanity, Strapi实现更灵活的内容管理
- **国际化 (i18n)**: 项目已实现基本的国际化功能,支持英语和简体中文。主要通过 `src/i18n/ui.ts` 管理翻译文本,`src/i18n/utils.ts` 提供辅助函数,并在 Astro 页面和 React 组件中应用。URL 结构 (如 `/zh/`) 用于区分不同语言版本。可以进一步完善,例如支持更多的语言或更复杂的复数、日期格式化等 - **博客功能**:添加一个简单的博客模块,用于发布文章
- **更复杂的交互**: 对于需要更复杂客户端逻辑的功能,可能需要引入更全面的状态管理方案 - **国际化完善**:除了文本,考虑图片、日期格式等内容的国际化
- **API 路由**: Astro 支持 API 路由,可以用于处理表单提交、与后端服务交互等 - **性能监控**:集成 Web Vitals 监控,持续跟踪网站性能
- **SEO 优化**:进一步优化元标签、结构化数据等,提升搜索引擎排名。
- **无障碍优化**:深入研究 ARIA 属性和键盘导航,提升无障碍体验。
- **动画库扩展**:探索 Framer Motion 更多高级特性,或集成其他动画库以实现更丰富的视觉效果。
- **组件库扩展**:基于 Radix UI 或其他无头 UI 库构建更多可复用的组件。
- **测试框架引入**:引入 Vitest 或 React Testing Library 等测试框架,提高代码质量和可维护性。

View File

@@ -40,8 +40,10 @@ export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcher
// Check if the first part of the path is a known language code // Check if the first part of the path is a known language code
if (currentPathParts.length > 0 && Object.keys(i18nLanguages).includes(currentPathParts[0])) { if (currentPathParts.length > 0 && Object.keys(i18nLanguages).includes(currentPathParts[0])) {
// If the first part is a language code, remove it to get the base path
basePath = '/' + currentPathParts.slice(1).join('/'); basePath = '/' + currentPathParts.slice(1).join('/');
} else { } else {
// If no language code in path, use the current path as base
basePath = currentPathname; basePath = currentPathname;
} }
@@ -49,7 +51,9 @@ export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcher
if (!basePath.startsWith('/')) { if (!basePath.startsWith('/')) {
basePath = '/' + basePath; basePath = '/' + basePath;
} }
// Fix for empty paths
if (basePath === '//') basePath = '/'; if (basePath === '//') basePath = '/';
if (basePath === '') basePath = '/';
let newPath; let newPath;
// If the target language is the default language and prefixDefaultLocale is false (as per our astro.config.mjs) // If the target language is the default language and prefixDefaultLocale is false (as per our astro.config.mjs)
@@ -63,9 +67,13 @@ export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcher
// Clean up double slashes, just in case // Clean up double slashes, just in case
newPath = newPath.replace(/\/\/+/g, '/'); newPath = newPath.replace(/\/\/+/g, '/');
if (newPath === '') newPath = '/'; // Handle case where basePath might be empty resulting in just /zh or /en // Handle case where basePath might be empty resulting in just /zh or /en
if (newPath === '') newPath = '/';
window.location.href = newPath; // Prevent unnecessary redirects to the same page
if (newPath !== currentPathname) {
window.location.href = newPath;
}
} }
}; };

View File

@@ -23,30 +23,29 @@ export function getLocalizedPath(path: string, lang: Lang | undefined): string {
const currentLang = lang || defaultLang; const currentLang = lang || defaultLang;
const basePath = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL; const basePath = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL;
// Astro's i18n routing handles prefixing automatically based on astro.config.mjs settings.
// We just need to ensure the path is correctly formed relative to the base.
// If a language is explicitly provided and it's not the default,
// and prefixDefaultLocale is false (meaning default lang has no prefix),
// we might need to add it. However, Astro typically handles this.
// For now, let's assume Astro's routing takes care of the prefix.
// This function might become simpler or even unnecessary depending on how LanguageSwitcher is used.
let newPath = path; let newPath = path;
// Ensure path starts with a slash if it's not an external URL // Ensure path starts with a slash if it's not an external URL
if (!newPath.startsWith('/') && !newPath.match(/^https?:\/\//)) { if (!newPath.startsWith('/') && !newPath.match(/^https?:\/\//)) {
newPath = `/${newPath}`; newPath = `/${newPath}`;
} }
// If prefixDefaultLocale is false (default in our config) and currentLang is not defaultLang, // Remove any existing language prefix from the path
// Astro will expect /zh/path. If currentLang is defaultLang, it expects /path. const pathParts = newPath.split('/').filter(p => p);
// If prefixDefaultLocale is true, all locales get a prefix: /en/path, /zh/path. if (pathParts.length > 0 && Object.keys(languages).includes(pathParts[0])) {
newPath = '/' + pathParts.slice(1).join('/');
}
// Given our astro.config.mjs: prefixDefaultLocale: false // If the path is empty after processing, make it root
// If lang is 'zh', the path should be /zh/your-path if (newPath === '') newPath = '/';
// If lang is 'en' (default), the path should be /your-path
// Astro's <a href> or Astro.redirect should handle this correctly when given a root-relative path.
// This function's main job is to ensure the base path and the target path are combined correctly.
// Add language prefix if needed
// If currentLang is not the default language, add the language prefix
if (currentLang !== defaultLang) {
newPath = `/${currentLang}${newPath}`;
}
// No prefix needed for default language as per astro.config.mjs (prefixDefaultLocale: false)
// Combine with base path and clean up any double slashes
const fullPath = `${basePath}${newPath}`; const fullPath = `${basePath}${newPath}`;
return fullPath.replace(/\/\/+/g, '/'); // Clean up any double slashes return fullPath.replace(/\/\/+/g, '/');
} }