fix: 全站 229 个 /en/ 重定向链接清零(SEO 重定向链 + 交互修复)

- 根因 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/ 请求
This commit is contained in:
AI Directory Platform
2026-09-10 04:35:57 +00:00
parent 442ce3a003
commit c00600b9a7
27 changed files with 238 additions and 194 deletions
+10 -10
View File
@@ -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 && (
<div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => (
<Link
<LocaleLink
key={category.id}
href={`/${locale}/tools?category=${category.slug}`}
href={`/tools?category=${category.slug}`}
className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium"
>
{category.icon && <span className="mr-1">{category.icon}</span>}
@@ -64,7 +64,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
<span className="ml-1 text-muted-foreground">
({category.toolCount})
</span>
</Link>
</LocaleLink>
))}
</div>
)}
@@ -72,9 +72,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-12">
{tools.slice(0, 8).map((tool) => (
<Link
<LocaleLink
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
<span>{t("visitWebsite")}</span>
</div>
)}
</Link>
</LocaleLink>
))}
</div>
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 查看更多按钮 */}
{tools.length > 0 && (
<div className="flex justify-center mt-12">
<Link
href={`/${locale}/tools`}
<LocaleLink
href="/tools"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
>
{t("viewAll")}
<ArrowRight className="h-4 w-4" />
</Link>
</LocaleLink>
</div>
)}
</div>
+29 -18
View File
@@ -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 <Link href={href} {...props}>{children}</Link>;
return <a href={href} {...props}>{children}</a>;
}
const normalizedHref = href.startsWith('/') ? href : `/${href}`;
const prefix = target === defaultLocale ? '' : `/${target}`;
const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
if (
normalizedHref === `/${locale}` ||
normalizedHref.startsWith(`/${locale}/`)
) {
return <Link href={normalizedHref} {...props}>{children}</Link>;
}
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
return <Link href={localizedHref} {...props}>{children}</Link>;
return (
<Link href={finalHref} {...props}>
{children}
</Link>
);
}
@@ -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 (
<Link
<LocaleLink
key={stat.title}
href={`/${locale}${stat.link}`}
href={stat.link}
className="bg-card rounded-lg p-6 border border-border hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between">
@@ -115,7 +115,7 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
<Icon className="h-6 w-6 text-white" />
</div>
</div>
</Link>
</LocaleLink>
);
})}
</div>
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
</div>
))}
</div>
<Link
href={`/${locale}/admin/users`}
<LocaleLink
href="/admin/users"
className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline"
>
{t("viewAllUsers")}
</Link>
</LocaleLink>
</div>
</div>
</div>
@@ -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() {
<div className="w-64 bg-background border-r border-border sticky top-0">
<div className="flex h-screen flex-col">
<div className="h-[60px] px-4 border-b border-border flex items-center">
<Link
href={`/${locale}/dashboard`}
<LocaleLink
href="/dashboard"
className="flex items-center gap-2 w-full rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-hover hover:text-hover-foreground transition-colors"
>
<Home className="h-4 w-4" />
{t("backToDashboard")}
</Link>
</LocaleLink>
</div>
<nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
@@ -90,7 +90,7 @@ export function AdminSidebar() {
pathname === href || pathname.startsWith(href + "/");
return (
<Link
<LocaleLink
key={item.href}
href={href}
className={cn(
@@ -102,7 +102,7 @@ export function AdminSidebar() {
>
<Icon className="h-5 w-5" />
{t(item.title)}
</Link>
</LocaleLink>
);
})}
</nav>
@@ -123,8 +123,8 @@ export async function AlternativesView({
{[base, ...alternatives].map((tool) => (
<tr key={tool.id} className="border-b border-border/60 last:border-b-0">
<td className="px-4 py-3">
<Link
href={`/${locale}/tools/${tool.slug}`}
<LocaleLink
href={`/tools/${tool.slug}`}
className="font-medium text-foreground hover:text-primary"
>
{tool.nameEn === base.nameEn && tool.id === base.id
@@ -132,7 +132,7 @@ export async function AlternativesView({
: locale === "zh" && tool.nameZh
? tool.nameZh
: tool.nameEn}
</Link>
</LocaleLink>
</td>
<td className="px-4 py-3 text-muted-foreground tabular-nums">
<span className="inline-flex items-center gap-1">
@@ -311,12 +311,12 @@ export async function ComparisonView({
{winnerLabel && (
<>
{" "}{tc("leaning", { name: winnerLabel })}{" "}
<Link
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
<LocaleLink
href={`/tools/${slugA === toolA.slug ? slugA : slugB}`}
className="font-medium text-primary hover:underline"
>
{winnerLabel}
</Link>
</LocaleLink>
.
</>
)}
@@ -331,14 +331,14 @@ export async function ComparisonView({
Compare
</th>
<th className="px-4 py-3 text-left">
<Link href={`/${locale}/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
<LocaleLink href={`/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
{nameA}
</Link>
</LocaleLink>
</th>
<th className="px-4 py-3 text-left">
<Link href={`/${locale}/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
<LocaleLink href={`/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
{nameB}
</Link>
</LocaleLink>
</th>
</tr>
</thead>
@@ -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]);
@@ -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={
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('noAccount')}{" "}
<Link href={`/${locale}/signup`} className="text-foreground hover:underline">
<LocaleLink href="/signup" className="text-foreground hover:underline">
{t('signUpLink')}
</Link>
</LocaleLink>
</p>
}
socialSlot={
@@ -131,9 +131,9 @@ export function LoginForm() {
autoComplete="current-password"
/>
<div className="flex items-center justify-between">
<Link href={`/${locale}/forgot-password`} className="text-sm font-normal text-muted-foreground hover:text-foreground">
<LocaleLink href="/forgot-password" className="text-sm font-normal text-muted-foreground hover:text-foreground">
{t('forgotPassword')}
</Link>
</LocaleLink>
</div>
</FormShell>
);
@@ -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]);
@@ -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={
<p className="mt-4 text-center text-sm text-muted-foreground">
{t('hasAccount')}{" "}
<Link href={`/${locale}/login`} className="text-foreground hover:underline">
<LocaleLink href="/login" className="text-foreground hover:underline">
{t('signInLink')}
</Link>
</LocaleLink>
</p>
}
socialSlot={
@@ -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"));
@@ -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 = () => {
<div className="flex flex-col items-start justify-start gap-3 px-6 py-6 w-full overflow-y-auto flex-1">
{marketingNavigationKeys.map((navItem) => (
<div key={navItem.key} className="w-full">
<Link
href={`/${locale}${navItem.href}`}
<LocaleLink
href={navItem.href}
onClick={() => setOpen(false)}
className="relative block w-full py-2 hover:opacity-70 transition-opacity"
>
<span className="block text-xl text-foreground font-semibold">
{t(navItem.key)}
</span>
</Link>
</LocaleLink>
</div>
))}
</div>
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
</p>
)}
</div>
<Link
href={`/${locale}/dashboard`}
<LocaleLink
href="/dashboard"
onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
>
{t('dashboard')}
</Link>
<Link
href={`/${locale}/profile`}
</LocaleLink>
<LocaleLink
href="/profile"
onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
>
{t('profile')}
</Link>
</LocaleLink>
<button
onClick={async () => {
await signOut();
@@ -126,7 +127,7 @@ export const MobileNavbar = () => {
<div className="flex flex-col gap-2.5 w-full">
<Button
as={Link}
href={`/${locale}/signup`}
href="/signup"
onClick={() => setOpen(false)}
className="w-full justify-center"
>
@@ -135,7 +136,7 @@ export const MobileNavbar = () => {
<Button
variant="simple"
as={Link}
href={`/${locale}/login`}
href="/login"
onClick={() => setOpen(false)}
className="w-full justify-center"
>
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { LocaleLink } from "@/components/locale-link";
import { signOut, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from 'next-intl';
@@ -43,18 +43,18 @@ export function UserMenu() {
if (!session.data?.user) {
return (
<div className="flex items-center gap-2">
<Link
href={`/${locale}/login`}
<LocaleLink
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('common.actions.signIn')}
</Link>
<Link
href={`/${locale}/signup`}
</LocaleLink>
<LocaleLink
href="/signup"
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
>
{t('common.actions.signUp')}
</Link>
</LocaleLink>
</div>
);
}
@@ -103,34 +103,34 @@ export function UserMenu() {
)}
</div>
<Link
href={`/${locale}/dashboard`}
<LocaleLink
href="/dashboard"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
>
<IconLayoutDashboard className="w-4 h-4" />
{t('navigation.main.dashboard')}
</Link>
</LocaleLink>
{isAdmin && (
<Link
href={`/${locale}/admin`}
<LocaleLink
href="/admin"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors whitespace-nowrap"
>
<IconShield className="w-4 h-4 flex-shrink-0" />
{t('Admin.sidebar.title')}
</Link>
</LocaleLink>
)}
<Link
href={`/${locale}/profile`}
<LocaleLink
href="/profile"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
>
<IconUser className="w-4 h-4" />
{t('navigation.main.profile')}
</Link>
</LocaleLink>
<div className="border-t border-border mt-1 pt-1">
<button
@@ -53,7 +53,7 @@ export function ReviewsSection({
const openForm = async () => {
if (!isAuthenticated) {
router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
);
return;
}
@@ -75,7 +75,7 @@ export function ReviewsSection({
const onHelpful = async (reviewId: string) => {
if (!isAuthenticated) {
router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
);
return;
}
@@ -76,14 +76,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
if (result.success) {
toast.success(t("submitted"));
router.push(`/${locale}/dashboard`);
router.push(`/dashboard`);
} else {
if (result.error === "DUPLICATE") {
setDuplicates(result.duplicates ?? []);
setError(t("duplicateBlocked"));
} else if (result.error === "UNAUTHORIZED") {
router.push(
`/${locale}/login?redirect=${encodeURIComponent("/submit")}`
`/login?redirect=${encodeURIComponent("/submit")}`
);
} else {
setError(t("error"));
@@ -108,7 +108,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
key={dup.slug}
type="button"
className="rounded-full bg-amber-100 px-2.5 py-1 text-[0.85rem] font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)}
onClick={() => router.push(`/tools/${dup.slug}`)}
>
{dup.name}
</button>
@@ -54,7 +54,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
if (!isAuthenticated) {
toast(t("loginRequired"));
router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
);
return;
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl";
import { Heart, Loader2 } from "lucide-react";
@@ -45,19 +45,19 @@ export function FavoriteToolsSection() {
) : tools.length === 0 ? (
<div className="py-6 text-center">
<p className="text-muted-foreground">{t("empty")}</p>
<Link
href={`/${locale}/tools`}
<LocaleLink
href="/tools"
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
>
{t("browseTools")}
</Link>
</LocaleLink>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tools.map((tool) => (
<Link
<LocaleLink
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
href={`/tools/${tool.slug}`}
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
>
{tool.logoUrl ? (
@@ -82,7 +82,7 @@ export function FavoriteToolsSection() {
</p>
)}
</div>
</Link>
</LocaleLink>
))}
</div>
)}
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { LocaleLink } from "@/components/locale-link";
import { useSearchParams } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button";
@@ -89,7 +90,7 @@ export function ToolsList({
useCases = [],
currentSort = "newest",
}: ToolsListProps) {
const router = useRouter();
const router = useIntlRouter();
const searchParams = useSearchParams();
const [searchInput, setSearchInput] = useState(currentSearch || "");
@@ -102,7 +103,7 @@ export function ToolsList({
params.delete("search");
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const handleCategoryChange = (categorySlug: string | null) => {
@@ -113,7 +114,7 @@ export function ToolsList({
params.delete("category");
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const handlePricingChange = (pricing: string | null) => {
@@ -124,7 +125,7 @@ export function ToolsList({
params.delete("pricing");
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
@@ -137,7 +138,7 @@ export function ToolsList({
params.set("usecase", slug);
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const handleSortChange = (sort: string) => {
@@ -148,13 +149,13 @@ export function ToolsList({
params.delete("sort");
}
params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
router.push(`/${locale}/tools?${params.toString()}`);
router.push(`/tools?${params.toString()}`);
};
const getName = (tool: ToolData) =>
@@ -298,9 +299,9 @@ export function ToolsList({
{/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => (
<Link
<LocaleLink
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
href={`/tools/${tool.slug}`}
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
>
<div className="flex items-start gap-4">
@@ -342,7 +343,7 @@ export function ToolsList({
</span>
)}
</div>
</Link>
</LocaleLink>
))}
</div>
@@ -353,7 +354,7 @@ export function ToolsList({
{translations.noTools}
</p>
<button
onClick={() => router.push(`/${locale}/tools`)}
onClick={() => router.push(`/tools`)}
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
>
{translations.clearFilters}
@@ -19,7 +19,7 @@ import {
import { toast } from "sonner";
import { ToolFormModal } from "./tool-form-modal";
import { useTranslations, useLocale } from "next-intl";
import Link from "next/link";
import { LocaleLink } from "@/components/locale-link";
interface ToolData {
id: string;
@@ -225,12 +225,12 @@ export function ToolsTable({ tools: initialTools }: ToolsTableProps) {
</div>
)}
<div>
<Link
href={`/${locale}/tools/${tool.slug}`} target="_blank"
<LocaleLink
href={`/tools/${tool.slug}`} target="_blank"
className="font-medium text-foreground hover:text-primary hover:underline"
>
{tool.nameEn}
</Link>
</LocaleLink>
{tool.nameZh && (
<div className="text-sm text-muted-foreground">
{tool.nameZh}
@@ -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<string, number>;
}) {
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;
+13 -4
View File
@@ -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 (
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
<div>
@@ -29,13 +38,13 @@ export function SectionHeader({
)}
</div>
{viewAllHref && (
<LocaleLink
href={viewAllHref}
<Link
href={localizedHref(viewAllHref, locale)}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
>
{viewAllLabel}
<ArrowRight className="h-3 w-3" />
</LocaleLink>
</Link>
)}
</div>
);
+16 -6
View File
@@ -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 (
<footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1">
<LocaleLink href="/" className="flex items-center gap-2">
<Link href={localizedHref("/", locale)} className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
<span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span>
</LocaleLink>
</Link>
<p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground">
{brand.tagline}
</p>
@@ -41,12 +51,12 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
<ul className="mt-3 space-y-2">
{group.links.map((link) => (
<li key={link.href + link.label}>
<LocaleLink
href={link.href}
<Link
href={localizedHref(link.href, locale)}
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</LocaleLink>
</Link>
</li>
))}
</ul>
+21 -12
View File
@@ -2,11 +2,20 @@
import { useState } from "react";
import { Menu, Search, X } from "lucide-react";
import { LocaleLink } from "@/components/locale-link";
import Link from "next/link";
import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu";
import { useRouter, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { locales, defaultLocale, type Locale } from "@/i18n.config";
/** 站内链接的确定性 locale 前缀:默认语言无前缀,其他语言带 /{locale} */
function localizedHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
const normalized = href === "/" ? "" : href;
return `${prefix}${normalized}`;
}
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
import type { ThemeBrand } from "./types";
@@ -38,14 +47,14 @@ export function ThemeHeader({
}: ThemeHeaderProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const router = useRouter();
const router = useIntlRouter();
const locale = useLocale();
const pathname = usePathname();
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`);
router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
};
const isActive = (href: string) => {
@@ -62,21 +71,21 @@ export function ThemeHeader({
>
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
{/* Logo */}
<LocaleLink href="/" className="flex shrink-0 items-center gap-2">
<Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)}
</span>
<span className="text-[0.95rem] font-bold tracking-tight text-foreground">
{brand.name}
</span>
</LocaleLink>
</Link>
{/* Desktop 导航 */}
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
{navItems.map((item) => (
<LocaleLink
<Link
key={item.href}
href={item.href}
href={localizedHref(item.href, locale)}
className={cn(
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href)
@@ -85,7 +94,7 @@ export function ThemeHeader({
)}
>
{item.label}
</LocaleLink>
</Link>
))}
</nav>
@@ -129,9 +138,9 @@ export function ThemeHeader({
{open && (
<nav className="animate-drawer-in border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
{navItems.map((item) => (
<LocaleLink
<Link
key={item.href}
href={item.href}
href={localizedHref(item.href, locale)}
className={cn(
"block rounded-lg px-3 py-2.5 text-[0.95rem]",
isActive(item.href)
@@ -141,7 +150,7 @@ export function ThemeHeader({
onClick={() => setOpen(false)}
>
{item.label}
</LocaleLink>
</Link>
))}
{showSearch && (
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { LocaleLink } from "@/components/locale-link";
import { db } from "@/lib/db";
import { category as categoryTable } from "@/lib/db/schema";
import {
@@ -126,9 +126,9 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<FavoritesProvider>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => (
<Link
<LocaleLink
key={tool.id}
href={`/${locale}/tools/${tool.slug}`}
href={`/tools/${tool.slug}`}
className="group rounded-xl border border-border bg-background p-5 transition-all hover:border-foreground/20 hover:shadow-lg"
>
<div className="flex items-start gap-4">
@@ -158,7 +158,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<span>{t("visitWebsite")}</span>
</div>
)}
</Link>
</LocaleLink>
))}
</div>
</FavoritesProvider>
@@ -173,13 +173,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
{/* 查看全部 */}
{total > PAGE_SIZE && (
<div className="mt-12 flex justify-center">
<Link
href={`/${locale}/tools?category=${slug}`}
<LocaleLink
href={`/tools?category=${slug}`}
className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 font-medium text-background transition-opacity hover:opacity-90"
>
{t("categoryPage.viewAll", { count: total })}
<ArrowRight className="h-4 w-4" />
</Link>
</LocaleLink>
</div>
)}
</div>
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { LocaleLink } from "@/components/locale-link";
import Image from "next/image";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
@@ -250,22 +250,22 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* 面包屑内链 */}
<nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground">
<Link
href={`/${locale}/tools`}
<LocaleLink
href="/tools"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
AI Tools
</Link>
</LocaleLink>
{primaryCategory && (
<>
<ChevronRight className="h-3 w-3" />
<Link
href={`/${locale}/category/${primaryCategory.slug}`}
<LocaleLink
href={`/category/${primaryCategory.slug}`}
className="hover:text-foreground"
>
{getCategoryName(primaryCategory)}
</Link>
</LocaleLink>
</>
)}
<ChevronRight className="h-3 w-3" />
@@ -349,13 +349,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2>
<div className="flex flex-wrap gap-2">
{relations.useCases.map((uc) => (
<Link
<LocaleLink
key={uc.id}
href={`/${locale}/ai-tools-for/${uc.slug}`}
href={`/ai-tools-for/${uc.slug}`}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
>
{pickName(uc, locale)}
</Link>
</LocaleLink>
))}
</div>
</div>
@@ -368,13 +368,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2>
<div className="flex flex-wrap gap-2">
{relations.audiences.map((aud) => (
<Link
<LocaleLink
key={aud.id}
href={`/${locale}/ai-tools-for/${aud.slug}`}
href={`/ai-tools-for/${aud.slug}`}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
>
{pickName(aud, locale)}
</Link>
</LocaleLink>
))}
</div>
</div>
@@ -391,13 +391,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2>
<div className="flex flex-wrap gap-2">
{tool.categories.map((category) => (
<Link
<LocaleLink
key={category.id}
href={`/${locale}/category/${category.slug}`}
href={`/category/${category.slug}`}
className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover"
>
{getCategoryName(category)}
</Link>
</LocaleLink>
))}
</div>
</section>
@@ -448,12 +448,12 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<h2 className="text-xl font-semibold text-foreground">
{t("alternatives")}
</h2>
<Link
href={`/${locale}/tools/${slug}-alternatives`}
<LocaleLink
href={`/tools/${slug}-alternatives`}
className="text-xs font-medium text-muted-foreground hover:text-primary"
>
{t("seeAllAlternatives")}
</Link>
</LocaleLink>
</div>
<FavoritesProvider>
<VotesProvider>
@@ -1,7 +1,7 @@
"use client";
import { useSession, signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
@@ -10,7 +10,7 @@ import { motion } from "framer-motion";
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
export default function DashboardPage() {
const router = useRouter();
const router = useIntlRouter();
const session = useSession();
const locale = useLocale();
const t = useTranslations("dashboard");
@@ -97,7 +97,7 @@ export default function DashboardPage() {
<Button
variant="outline"
className="w-full justify-start transition-colors"
onClick={() => router.push(`/${locale}/profile`)}
onClick={() => router.push(`/profile`)}
>
{t("cards.quickActions.editProfile")}
</Button>
@@ -1,6 +1,7 @@
'use client';
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
import { LocaleLink } from "@/components/locale-link";
import Link from 'next/link';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
@@ -109,12 +110,12 @@ export default function CheckEmailPage() {
)}
{/* Back to Login */}
<Link
href={`/${locale}/login`}
<LocaleLink
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('backToLogin')}
</Link>
</LocaleLink>
</div>
{/* Security Note */}
@@ -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() {
<p className="text-muted-foreground mb-6">
{message}
</p>
<Link
href={`/${locale}/login`}
<LocaleLink
href="/login"
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
>
{t('goToLogin')}
</Link>
</LocaleLink>
</div>
)}
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
<p className="text-muted-foreground mb-6">
{message}
</p>
<Link
href={`/${locale}/login`}
<LocaleLink
href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
>
{t('goToLogin')}
</Link>
</LocaleLink>
</>
) : (
<>
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
{message}
</p>
<div className="space-y-3">
<Link
href={`/${locale}/login`}
<LocaleLink
href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
>
{t('goToLogin')}
</Link>
<Link
href={`/${locale}/check-email`}
</LocaleLink>
<LocaleLink
href="/check-email"
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
>
{t('requestNew')}
</Link>
</LocaleLink>
</div>
</>
)}