From c00600b9a7c864de55d2038176eb3a317661ff1b Mon Sep 17 00:00:00 2001
From: AI Directory Platform
Date: Thu, 10 Sep 2026 04:35:57 +0000
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E7=AB=99=20229=20=E4=B8=AA=20/?=
=?UTF-8?q?en/=20=E9=87=8D=E5=AE=9A=E5=90=91=E9=93=BE=E6=8E=A5=E6=B8=85?=
=?UTF-8?q?=E9=9B=B6=EF=BC=88SEO=20=E9=87=8D=E5=AE=9A=E5=90=91=E9=93=BE=20?=
=?UTF-8?q?+=20=E4=BA=A4=E4=BA=92=E4=BF=AE=E5=A4=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 根因 1:LocaleLink 手动拼 ${locale} 前缀,英文站产生 /en/... 链接,
再被 middleware 307 打回无前缀形式(每页最多 60+ 个重定向链)
- 根因 2:next-intl 的 Link 在 locale prop === 当前 locale 时透传 href
不做前缀化(4.14 行为),无法作为修复载体
- 修复:
· LocaleLink 重写为确定性实现(原生 Link + 显式 as-needed 前缀规则:
en 无前缀 / zh 带 /zh)
· ThemeHeader/ThemeFooter/Section 同样确定性前缀
· 12 个文件的 router.push/replace(${locale}/...) 全部迁移 next-intl
useRouter(zh 下 push /tools 自动变 /zh/tools,不再丢 locale)
· Link→LocaleLink 批量转换(admin/导航/工具表等 15 文件)
· 修复衍生 bug:16 处模板字符串 href 被转成字面量(href="...\${...}")
- 回归:五站×13 页 0 个 /en/ 链接、0 个字面量 href;zh 页 30 链接全部带
/zh 前缀;点击链路 0 次 /en/ 请求
---
.../core/src/components/featured-tools.tsx | 20 ++++----
packages/core/src/components/locale-link.tsx | 47 ++++++++++++-------
.../admin/components/admin-dashboard.tsx | 14 +++---
.../admin/components/admin-sidebar.tsx | 12 ++---
.../alternatives/components/views.tsx | 20 ++++----
.../auth/components/email-verified-guard.tsx | 6 +--
.../features/auth/components/login-form.tsx | 10 ++--
.../auth/components/session-guard.tsx | 2 +-
.../features/auth/components/signup-form.tsx | 8 ++--
.../claim/components/claim-button.tsx | 2 +-
.../navigation/components/mobile-navbar.tsx | 23 ++++-----
.../navigation/components/user-menu.tsx | 32 ++++++-------
.../reviews/components/reviews-section.tsx | 4 +-
.../components/submit-tool-form.tsx | 6 +--
.../tools/components/favorite-button.tsx | 2 +-
.../components/favorite-tools-section.tsx | 14 +++---
.../features/tools/components/tools-list.tsx | 27 ++++++-----
.../features/tools/components/tools-table.tsx | 8 ++--
.../voting/components/vote-button.tsx | 6 +--
packages/core/src/kit/section.tsx | 17 +++++--
packages/core/src/kit/theme-footer.tsx | 22 ++++++---
packages/core/src/kit/theme-header.tsx | 33 ++++++++-----
.../(marketing)/category/[slug]/page.tsx | 14 +++---
.../(marketing)/tools/[slug]/page.tsx | 38 +++++++--------
.../[locale]/(protected)/dashboard/page.tsx | 6 +--
.../src/pages/[locale]/check-email/page.tsx | 7 +--
.../src/pages/[locale]/verify-email/page.tsx | 32 +++++++------
27 files changed, 238 insertions(+), 194 deletions(-)
diff --git a/packages/core/src/components/featured-tools.tsx b/packages/core/src/components/featured-tools.tsx
index 2ba5ce0..2dc77af 100644
--- a/packages/core/src/components/featured-tools.tsx
+++ b/packages/core/src/components/featured-tools.tsx
@@ -1,6 +1,6 @@
"use client";
-import Link from "next/link";
+import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl";
import { ExternalLink, ArrowRight } from "lucide-react";
import { Heading } from "./heading";
@@ -54,9 +54,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{categories.length > 0 && (
{categories.slice(0, 6).map((category) => (
-
{category.icon && {category.icon}}
@@ -64,7 +64,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
({category.toolCount})
-
+
))}
)}
@@ -72,9 +72,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 工具网格 */}
{tools.slice(0, 8).map((tool) => (
-
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{t("visitWebsite")}
)}
-
+
))}
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 查看更多按钮 */}
{tools.length > 0 && (
)}
diff --git a/packages/core/src/components/locale-link.tsx b/packages/core/src/components/locale-link.tsx
index 86f8c77..9bd93ad 100644
--- a/packages/core/src/components/locale-link.tsx
+++ b/packages/core/src/components/locale-link.tsx
@@ -1,32 +1,43 @@
"use client";
+import Link from "next/link";
import { useLocale } from 'next-intl';
-import { Link } from 'next-view-transitions';
+import { defaultLocale } from '@/i18n.config';
-interface LocaleLinkProps {
+/**
+ * # 组件 LocaleLink
+ * 说明:本地化链接(确定性实现)。
+ * href 规则:默认语言(en)无前缀,其他语言带 /{locale} 前缀——
+ * 与 middleware 的 localePrefix: 'as-needed' 完全一致,
+ * 站内链接不再产生 /en/... → 307 → /... 的重定向链(SEO/交互问题)。
+ * locale prop 用于显式指定目标语言(语言切换场景),默认取当前 locale。
+ */
+export function LocaleLink({
+ href,
+ locale: localeProp,
+ children,
+ ...props
+}: {
href: string;
+ /** 显式目标语言(可选,默认当前语言) */
+ locale?: string;
children: React.ReactNode;
- className?: string;
[key: string]: any;
-}
-
-export function LocaleLink({ href, children, ...props }: LocaleLinkProps) {
- const locale = useLocale();
+}) {
+ const currentLocale = useLocale();
+ const target = localeProp ?? currentLocale;
if (href.startsWith('http') || href.startsWith('//')) {
- return {children};
+ return {children};
}
const normalizedHref = href.startsWith('/') ? href : `/${href}`;
+ const prefix = target === defaultLocale ? '' : `/${target}`;
+ const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
- if (
- normalizedHref === `/${locale}` ||
- normalizedHref.startsWith(`/${locale}/`)
- ) {
- return {children};
- }
-
- const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
-
- return {children};
+ return (
+
+ {children}
+
+ );
}
diff --git a/packages/core/src/features/admin/components/admin-dashboard.tsx b/packages/core/src/features/admin/components/admin-dashboard.tsx
index ffe5a81..5ebfbc1 100644
--- a/packages/core/src/features/admin/components/admin-dashboard.tsx
+++ b/packages/core/src/features/admin/components/admin-dashboard.tsx
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react";
-import Link from "next/link";
+import { LocaleLink } from "@/components/locale-link";
import { useLocale } from "next-intl";
interface AdminDashboardProps {
@@ -99,9 +99,9 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
{statCards.map((stat) => {
const Icon = stat.icon;
return (
-
@@ -115,7 +115,7 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
-
+
);
})}
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
))}
-
{t("viewAllUsers")}
-
+
diff --git a/packages/core/src/features/admin/components/admin-sidebar.tsx b/packages/core/src/features/admin/components/admin-sidebar.tsx
index a6d6a69..c6e6e12 100644
--- a/packages/core/src/features/admin/components/admin-sidebar.tsx
+++ b/packages/core/src/features/admin/components/admin-sidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import Link from "next/link";
+import { LocaleLink } from "@/components/locale-link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react";
@@ -73,13 +73,13 @@ export function AdminSidebar() {
-
{t("backToDashboard")}
-
+
diff --git a/packages/core/src/features/alternatives/components/views.tsx b/packages/core/src/features/alternatives/components/views.tsx
index 6153420..df1e432 100644
--- a/packages/core/src/features/alternatives/components/views.tsx
+++ b/packages/core/src/features/alternatives/components/views.tsx
@@ -123,8 +123,8 @@ export async function AlternativesView({
{[base, ...alternatives].map((tool) => (
|
-
{tool.nameEn === base.nameEn && tool.id === base.id
@@ -132,7 +132,7 @@ export async function AlternativesView({
: locale === "zh" && tool.nameZh
? tool.nameZh
: tool.nameEn}
-
+
|
@@ -311,12 +311,12 @@ export async function ComparisonView({
{winnerLabel && (
<>
{" "}{tc("leaning", { name: winnerLabel })}{" "}
-
{winnerLabel}
-
+
.
>
)}
@@ -331,14 +331,14 @@ export async function ComparisonView({
Compare
|
-
+
{nameA}
-
+
|
-
+
{nameB}
-
+
|
diff --git a/packages/core/src/features/auth/components/email-verified-guard.tsx b/packages/core/src/features/auth/components/email-verified-guard.tsx
index b774f2d..cc8e55e 100644
--- a/packages/core/src/features/auth/components/email-verified-guard.tsx
+++ b/packages/core/src/features/auth/components/email-verified-guard.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { useRouter } from "next/navigation";
+import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from 'next-intl';
import { Mail, AlertCircle } from 'lucide-react';
import { useSession } from "@/lib/auth-client";
@@ -15,7 +15,7 @@ export function EmailVerifiedGuard({
children,
requireEmailVerification = true
}: EmailVerifiedGuardProps) {
- const router = useRouter();
+ const router = useIntlRouter();
const session = useSession();
const locale = useLocale();
const t = useTranslations('auth.emailVerifiedGuard');
@@ -24,7 +24,7 @@ export function EmailVerifiedGuard({
React.useEffect(() => {
if (!session.isPending && !session.data) {
- router.replace(`/${locale}/login`);
+ router.replace(`/login`);
}
}, [router, session.data, session.isPending, locale]);
diff --git a/packages/core/src/features/auth/components/login-form.tsx b/packages/core/src/features/auth/components/login-form.tsx
index 8c32227..df18630 100644
--- a/packages/core/src/features/auth/components/login-form.tsx
+++ b/packages/core/src/features/auth/components/login-form.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { useRouter } from "next/navigation";
-import Link from "next/link";
+import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -97,9 +97,9 @@ export function LoginForm() {
footer={
{t('noAccount')}{" "}
-
+
{t('signUpLink')}
-
+
}
socialSlot={
@@ -131,9 +131,9 @@ export function LoginForm() {
autoComplete="current-password"
/>
-
+
{t('forgotPassword')}
-
+
);
diff --git a/packages/core/src/features/auth/components/session-guard.tsx b/packages/core/src/features/auth/components/session-guard.tsx
index 6264a9c..f5f0789 100644
--- a/packages/core/src/features/auth/components/session-guard.tsx
+++ b/packages/core/src/features/auth/components/session-guard.tsx
@@ -13,7 +13,7 @@ export function SessionGuard({ children }: { children: React.ReactNode }) {
React.useEffect(() => {
if (!session.isPending && !session.data) {
- router.replace(`/${locale}/login`);
+ router.replace(`/login`);
}
}, [router, session.data, session.isPending]);
diff --git a/packages/core/src/features/auth/components/signup-form.tsx b/packages/core/src/features/auth/components/signup-form.tsx
index 38c39b5..89765cc 100644
--- a/packages/core/src/features/auth/components/signup-form.tsx
+++ b/packages/core/src/features/auth/components/signup-form.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { useRouter } from "next/navigation";
-import Link from "next/link";
+import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -60,7 +60,7 @@ export function SignupForm() {
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
- router.push(`/${locale}/check-email`);
+ router.push(`/check-email`);
} else {
const redirect =
typeof window !== "undefined"
@@ -115,9 +115,9 @@ export function SignupForm() {
footer={
{t('hasAccount')}{" "}
-
+
{t('signInLink')}
-
+
}
socialSlot={
diff --git a/packages/core/src/features/claim/components/claim-button.tsx b/packages/core/src/features/claim/components/claim-button.tsx
index 28969e8..d39d6a6 100644
--- a/packages/core/src/features/claim/components/claim-button.tsx
+++ b/packages/core/src/features/claim/components/claim-button.tsx
@@ -53,7 +53,7 @@ export function ClaimToolButton({
setOpen(false);
} else if (result.error === "UNAUTHORIZED") {
router.push(
- `/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
+ `/login?redirect=${encodeURIComponent(window.location.pathname)}`
);
} else if (result.error === "PENDING_EXISTS") {
toast.info(t("pendingExists"));
diff --git a/packages/core/src/features/navigation/components/mobile-navbar.tsx b/packages/core/src/features/navigation/components/mobile-navbar.tsx
index 07c6958..ee96978 100644
--- a/packages/core/src/features/navigation/components/mobile-navbar.tsx
+++ b/packages/core/src/features/navigation/components/mobile-navbar.tsx
@@ -5,6 +5,7 @@ import { useState } from "react";
import { useMotionValueEvent, useScroll } from "framer-motion";
import { IoIosClose, IoIosMenu } from "react-icons/io";
import { Link } from "next-view-transitions";
+import { LocaleLink } from "@/components/locale-link";
import { Button } from "@/components/button";
import { Logo } from "@/components/Logo";
@@ -66,15 +67,15 @@ export const MobileNavbar = () => {
{marketingNavigationKeys.map((navItem) => (
- setOpen(false)}
className="relative block w-full py-2 hover:opacity-70 transition-opacity"
>
{t(navItem.key)}
-
+
))}
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
)}
-
setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
>
{t('dashboard')}
-
-
+
setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
>
{t('profile')}
-
+
)}
-
{tool.nameEn}
-
+
{tool.nameZh && (
{tool.nameZh}
diff --git a/packages/core/src/features/voting/components/vote-button.tsx b/packages/core/src/features/voting/components/vote-button.tsx
index 8b61246..0f42b29 100644
--- a/packages/core/src/features/voting/components/vote-button.tsx
+++ b/packages/core/src/features/voting/components/vote-button.tsx
@@ -8,7 +8,7 @@ import {
useMemo,
useState,
} from "react";
-import { useRouter } from "next/navigation";
+import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner";
import { ChevronUp } from "lucide-react";
@@ -41,7 +41,7 @@ export function VotesProvider({
initialCounts?: Record
;
}) {
const session = useSession();
- const router = useRouter();
+ const router = useIntlRouter();
const locale = useLocale();
const t = useTranslations("voting");
const isAuthenticated = !!session.data?.user;
@@ -65,7 +65,7 @@ export function VotesProvider({
if (!isAuthenticated) {
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
toast(t("loginRequired"));
- router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`);
+ router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return;
}
if (pendingIds.has(toolId)) return;
diff --git a/packages/core/src/kit/section.tsx b/packages/core/src/kit/section.tsx
index a7e563e..30ffedc 100644
--- a/packages/core/src/kit/section.tsx
+++ b/packages/core/src/kit/section.tsx
@@ -1,4 +1,12 @@
-import { LocaleLink } from "@/components/locale-link";
+import Link from "next/link";
+import { useLocale } from "next-intl";
+import { defaultLocale } from "@/i18n.config";
+
+/** 站内链接的确定性 locale 前缀 */
+function localizedHref(href: string, locale: string): string {
+ const prefix = locale === defaultLocale ? "" : `/${locale}`;
+ return `${prefix}${href === "/" ? "" : href}`;
+}
import { ArrowRight } from "lucide-react";
/**
@@ -18,6 +26,7 @@ export function SectionHeader({
viewAllLabel?: string;
className?: string;
}) {
+ const locale = useLocale();
return (
@@ -29,13 +38,13 @@ export function SectionHeader({
)}
{viewAllHref && (
-
{viewAllLabel}
-
+
)}
);
diff --git a/packages/core/src/kit/theme-footer.tsx b/packages/core/src/kit/theme-footer.tsx
index ab647dc..8c37f90 100644
--- a/packages/core/src/kit/theme-footer.tsx
+++ b/packages/core/src/kit/theme-footer.tsx
@@ -1,6 +1,15 @@
"use client";
-import { LocaleLink } from "@/components/locale-link";
+import Link from "next/link";
+import { useLocale } from "next-intl";
+import { defaultLocale } from "@/i18n.config";
+
+/** 站内链接的确定性 locale 前缀 */
+function localizedHref(href: string, locale: string): string {
+ const prefix = locale === defaultLocale ? "" : `/${locale}`;
+ return `${prefix}${href === "/" ? "" : href}`;
+}
+
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "./types";
@@ -15,17 +24,18 @@ export interface ThemeFooterProps {
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
*/
export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
+ const locale = useLocale();
return (
{/* Security Note */}
diff --git a/packages/core/src/pages/[locale]/verify-email/page.tsx b/packages/core/src/pages/[locale]/verify-email/page.tsx
index b72f5cb..f17fa7b 100644
--- a/packages/core/src/pages/[locale]/verify-email/page.tsx
+++ b/packages/core/src/pages/[locale]/verify-email/page.tsx
@@ -1,14 +1,16 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
-import { useSearchParams, useRouter } from 'next/navigation';
+import { LocaleLink } from "@/components/locale-link";
+import { useSearchParams } from 'next/navigation';
+import { useRouter as useIntlRouter } from '@/i18n/routing';
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
function VerifyEmailPageInner() {
const searchParams = useSearchParams();
- const router = useRouter();
+ const router = useIntlRouter();
const locale = useLocale();
const t = useTranslations('auth.verifyEmail');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
@@ -34,7 +36,7 @@ function VerifyEmailPageInner() {
// Redirect to login after 3 seconds
setTimeout(() => {
- router.push(`/${locale}/login`);
+ router.push(`/login`);
}, 3000);
} else {
setStatus('error');
@@ -74,12 +76,12 @@ function VerifyEmailPageInner() {
{message}
-
{t('goToLogin')}
-
+
)}
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
{message}
-
{t('goToLogin')}
-
+
>
) : (
<>
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
{message}
-
{t('goToLogin')}
-
-
+
{t('requestNew')}
-
+
>
)}