fix: i18n 链接体系重构 + 五站差异化 toast 皮肤

语言切换/LOGO 同源 bug:手拼 /\${locale} 前缀在默认语言 en 下
产生空 href(<Link href=""> 点击无效、push('') 停留原地)或 /en/*(middleware 契约 404)。
- 新增 lib/locale-href.ts 统一拼接:剥已有语言段、重拼目标语言、根路径保证非空
- language-switcher:/zh 首页切回 en 的 push('') 修复(next-intl 路由
  的 {locale} 选项会强制加前缀 forcePrefix,与 as-needed 契约冲突,弃用)
- theme-header/footer/section:LOGO、View all、页脚链接走 localeHref;
  isActive 改用无前缀 pathname(en 下高亮失效修复)
- login/signup/email-verified-guard/social 回跳:消除 /en/* 404 死链
- ai-stack/findai 首页搜索表单 action 同修
- 新增 components/site-toaster.tsx:按 NEXT_PUBLIC_THEME_ID 切换
  位置/圆角/字体/图标/配色五套皮肤(蓝右下/橙顶中/纸感底中/终端左下/玻璃右上)
- 新增 ui-test-i18n-toast.mjs 回归脚本(16 项浏览器检查全通过)
This commit is contained in:
bigcode
2026-09-16 11:11:19 +08:00
parent 2a269c25ae
commit b5a4532dd0
14 changed files with 365 additions and 55 deletions
@@ -0,0 +1,166 @@
/**
* # 脚本 ui-test-i18n-toast.mjs
* 说明:i18n 链接修复回归测试 + 各站差异化 toast 验证。
* 覆盖:en↔zh 互切(含 /zh 首页回 en 的历史 bug)、子路径切换、
* LOGO 回首页(en/zh)、导航高亮、登录默认去向(含 redirect)、
* 5 站未登录投票触发 toast 并截图(/tmp/ui-test/toast-<site>.png)。
* 运行:bun packages/core/scripts/ui-test-i18n-toast.mjs
*/
import { chromium } from "playwright";
const BASE = "https://ai-stack-bdev.opentest.cc";
const SITES = [
["ai-stack", "https://ai-stack-bdev.opentest.cc", "/tools/chatgpt"],
["launchai", "https://launchai-bdev.opentest.cc", "/tools/perplexity"],
["the-ai-ledger", "https://the-ai-ledger-bdev.opentest.cc", "/tools/midjourney"],
["devai-tools", "https://devai-tools-bdev.opentest.cc", "/tools/github-copilot"],
["findai", "https://findai-bdev.opentest.cc", "/tools/figma-ai"],
];
const browser = await chromium.launch({
executablePath: "/opt/brave.com/brave/brave",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(30000);
let pass = 0, fail = 0;
async function step(name, fn) {
try {
await fn();
pass++;
console.log(` ✓ ${name}`);
} catch (e) {
fail++;
console.log(` ✗ ${name}: ${String(e).split("\n")[0].slice(0, 160)}`);
await page.screenshot({ path: `/tmp/ui-test/i18n-fail-${fail}.png` }).catch(() => {});
}
}
/* goto + 网络空闲(水合后才能点导航) */
const gotoWait = async (url) => {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
} catch (e) {
if (!String(e).includes("ERR_NETWORK_CHANGED")) throw e;
await page.waitForTimeout(2000);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
}
await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {});
await page.waitForTimeout(600);
};
/* 语言切换:等 get-session 解析 + 菜单出现 */
async function switchTo(target) {
await page.request.get(`${new URL(page.url()).origin}/api/auth/get-session`).catch(() => {});
const btn = page.locator('button[aria-label="Select language"]');
await btn.click();
await page.getByRole("menuitem", { name: target }).click();
await page.waitForTimeout(1500);
}
const langBtn = () => page.locator('button[aria-label="Select language"]');
const logoLink = () => page.locator("header a").filter({ has: page.locator('img[src="/icon.svg"]') }).first();
console.log("== 1. 语言切换(ai-stack)==");
await step("en 首页 → 切中文 → /zh", async () => {
await gotoWait(`${BASE}/`);
await switchTo("中文");
if (new URL(page.url()).pathname !== "/zh") throw new Error(`URL=${page.url()}`);
});
await step("/zh 首页 → 切回英文 → /(原 bug:停留原地)", async () => {
await switchTo("English");
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("/zh/tools → 切英文 → /tools(子路径)", async () => {
await gotoWait(`${BASE}/zh/tools`);
await switchTo("English");
if (new URL(page.url()).pathname !== "/tools") throw new Error(`URL=${page.url()}`);
});
await step("/tools → 切中文 → /zh/tools(子路径反向)", async () => {
await switchTo("中文");
if (new URL(page.url()).pathname !== "/zh/tools") throw new Error(`URL=${page.url()}`);
});
console.log("== 2. LOGO 回首页 ==");
await step("en:/tools 点 LOGO → /", async () => {
await gotoWait(`${BASE}/tools`);
await logoLink().click();
await page.waitForTimeout(1200);
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("zh:/zh/tools 点 LOGO → /zh", async () => {
await gotoWait(`${BASE}/zh/tools`);
await logoLink().click();
await page.waitForTimeout(1200);
if (new URL(page.url()).pathname !== "/zh") throw new Error(`URL=${page.url()}`);
});
await step("en:/tools 导航项 Discover 有激活高亮", async () => {
await gotoWait(`${BASE}/tools`);
const active = await page
.locator('header nav a[href="/tools"]')
.first()
.evaluate((el) => el.className.includes("bg-secondary"));
if (!active) throw new Error("无激活样式");
});
console.log("== 3. 登录跳转 ==");
await step("清理会话(API 登出)", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
const session = await page.request.get(`${BASE}/api/auth/get-session`);
if ((JSON.parse(await session.text()) || {}).user) throw new Error("登出失败");
});
await step("en 带 redirect 登录 → /dashboard(原 bug:/en/ 404)", async () => {
await gotoWait(`${BASE}/login?redirect=%2Fdashboard`);
await page.getByLabel(/email/i).first().fill("alice@demo.dev");
await page.getByLabel(/password/i).first().fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL("**/dashboard", { timeout: 20000 });
if (new URL(page.url()).pathname !== "/dashboard") throw new Error(`URL=${page.url()}`);
});
await step("en 无 redirect 登录默认去当前语言首页 /(不再 /en/ 404)", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
await gotoWait(`${BASE}/login`);
await page.getByLabel(/email/i).first().fill("alice@demo.dev");
await page.getByLabel(/password/i).first().fill("password123");
await page.getByRole("button", { name: /sign in/i }).first().click();
await page.waitForURL((u) => !u.pathname.includes("/login"), { timeout: 20000 });
if (new URL(page.url()).pathname !== "/") throw new Error(`URL=${page.url()}`);
});
await step("zh 带 redirect 登录 → /zh/dashboard", async () => {
await page.request.post(`${BASE}/api/auth/sign-out`);
await gotoWait(`${BASE}/zh/login?redirect=%2Fdashboard`);
await page.getByLabel(/邮箱|email/i).first().fill("alice@demo.dev");
await page.getByLabel(/密码|password/i).first().fill("password123");
// 精确匹配"登录",避免误点"使用 GitHub 登录"
await page.getByRole("button", { name: "登录", exact: true }).first().click();
await page.waitForURL("**/zh/dashboard", { timeout: 20000 });
if (new URL(page.url()).pathname !== "/zh/dashboard") throw new Error(`URL=${page.url()}`);
});
console.log("== 4. 各站差异化 toast(未登录点 Upvote)==");
await ctx.clearCookies();
const _sess = await page.request.get(`${BASE}/api/auth/get-session`);
if ((JSON.parse(await _sess.text()) || {}).user) throw new Error("清理 cookie 后仍有会话");
for (const [site, base, tool] of SITES) {
await step(`${site}: toast 弹出且皮肤存在`, async () => {
await gotoWait(`${base}${tool}`);
const voteBtn = page.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first();
await voteBtn.waitFor();
await voteBtn.click();
try {
await page.waitForSelector("[data-sonner-toast]", { timeout: 4000 });
} catch {
// 若已是已投状态,第一次点击是取消投票(无 toast),再点一次触发拦截提示
await voteBtn.click();
await page.waitForSelector("[data-sonner-toast]", { timeout: 10000 });
}
await page.waitForTimeout(600);
await page.screenshot({ path: `/tmp/ui-test/toast-${site}.png` });
});
}
console.log(`\n结果: ${pass} 通过 / ${fail} 失败`);
await browser.close();
process.exit(fail > 0 ? 1 : 0);
@@ -1,39 +1,35 @@
"use client"; "use client";
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { locales, localeNames, defaultLocale } from '@/i18n.config'; import { usePathname } from '@/i18n/routing';
import { locales, localeNames } from '@/i18n.config';
import type { Locale } from '@/i18n.config'; import type { Locale } from '@/i18n.config';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Globe, Check } from 'lucide-react'; import { ChevronDown, Globe, Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { localeHref } from '@/lib/locale-href';
export function LanguageSwitcher() { export function LanguageSwitcher() {
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
// next-intl 的 usePathname 返回无语言前缀的路径(/zh/tools → /tools)
const pathname = usePathname(); const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// URL 即语言:默认语言(en)无前缀,其他语言带 /{locale};去掉当前路径上的语言段后再拼目标前缀 // URL 即语言:用 localeHref 统一重拼目标语言链接(默认语言 en 无前缀、根路径
// 返回 '/' 而非空串)。不能用 next-intl 路由的 {locale} 选项——该实现只要
// 显式传 locale 就强制加前缀(forcePrefix),默认语言会得到 '/en/...',
// 而 middleware 契约里 /en/* 是 404,这正是"zh 切不回 en"的根因。
const switchLocale = (newLocale: Locale) => { const switchLocale = (newLocale: Locale) => {
if (newLocale === locale) { if (newLocale === locale) {
setIsOpen(false); setIsOpen(false);
return; return;
} }
let path = pathname; const search = typeof window !== 'undefined' ? window.location.search : '';
router.push(`${localeHref(pathname, newLocale)}${search}`);
for (const loc of locales) {
if (pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)) {
path = pathname.slice(loc.length + 1) || '/';
break;
}
}
const normalizedPath = path === '/' ? '' : path;
const prefix = newLocale === defaultLocale ? '' : `/${newLocale}`;
router.push(`${prefix}${normalizedPath}`);
setIsOpen(false); setIsOpen(false);
}; };
@@ -0,0 +1,138 @@
"use client";
import { Toaster } from "sonner";
import type { ToasterProps } from "sonner";
import {
Layers,
Rocket,
BookOpen,
Terminal,
Search,
CircleX,
TriangleAlert,
Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* # 组件 SiteToaster
* 说明:按站点主题(NEXT_PUBLIC_THEME_ID,构建期内联)切换 toast 皮肤——
* 位置、圆角、字体、配色、图标全部差异化,替代五站同款的
* shadcn/sonner 默认样式。各站皮肤:
* - ai-stack 专业蓝:右下角、圆角卡片 + 品牌蓝左侧竖条、层叠图标
* - launchai 活力橙:顶部居中、橙色渐变胶囊(白字)、火箭图标
* - the-ai-ledger 编辑纸感:底部居中、直角米色纸面 + 硬投影 + 衬线字
* - devai-tools 终端绿:左下角、深色终端底 + 等宽字、终端图标
* - findai 玻璃紫:右上角、毛玻璃圆角卡 + 紫色描边、放大镜图标
*/
interface SiteSkin {
/** 位置(五站互不相同,避免互相雷同) */
position: ToasterProps["position"];
/** 距视口偏移(顶部皮肤需避开 sticky header 与导航进度条) */
offset?: number | string;
/** 应用到每条 toast 的 class(! 前缀覆盖 sonner 默认底色/文字色) */
toastClass: string;
/** 应用到每条 toast 的内联样式(字体等 Tailwind 不便表达的项) */
toastStyle?: React.CSSProperties;
/** 各类型 toast 的自定义图标 */
icons: ToasterProps["icons"];
}
const SKINS: Record<string, SiteSkin> = {
"ai-stack": {
position: "bottom-right",
toastClass: cn(
"!rounded-xl !border-blue-600/25 !bg-white !text-slate-800",
"!shadow-lg !shadow-blue-950/10 dark:!bg-zinc-900 dark:!text-zinc-100",
"dark:!border-blue-400/25"
),
toastStyle: { borderLeft: "3px solid #2563eb" },
icons: {
success: <Layers className="h-4 w-4 text-blue-600 dark:text-blue-400" />,
error: <CircleX className="h-4 w-4 text-red-500" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
},
},
launchai: {
position: "top-center",
offset: 68,
toastClass: cn(
"!rounded-full !border-0 !bg-gradient-to-r !from-orange-500 !to-amber-500",
"!text-white !shadow-lg !shadow-orange-500/30"
),
toastStyle: { fontWeight: 600 },
icons: {
success: <Rocket className="h-4 w-4 text-white" />,
error: <CircleX className="h-4 w-4 text-white" />,
warning: <TriangleAlert className="h-4 w-4 text-white" />,
info: <Info className="h-4 w-4 text-white" />,
},
},
"the-ai-ledger": {
position: "bottom-center",
toastClass: cn(
"!rounded-none !border !border-[#26221f] !bg-[#fbf7f0] !text-[#26221f]",
"!shadow-[4px_4px_0_#26221f]"
),
toastStyle: {
fontFamily: "Georgia, 'Times New Roman', serif",
},
icons: {
success: <BookOpen className="h-4 w-4 text-amber-800" />,
error: <CircleX className="h-4 w-4 text-red-800" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-700" />,
info: <Info className="h-4 w-4 text-amber-800" />,
},
},
"devai-tools": {
position: "bottom-left",
toastClass: cn(
"!rounded-md !border !border-emerald-500/40 !bg-[#0d1117] !text-emerald-300",
"!shadow-lg !shadow-emerald-950/40",
"[&_\\[data-description\\]]:!text-emerald-400/80"
),
toastStyle: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
},
icons: {
success: <Terminal className="h-4 w-4 text-emerald-400" />,
error: <CircleX className="h-4 w-4 text-red-400" />,
warning: <TriangleAlert className="h-4 w-4 text-yellow-400" />,
info: <Info className="h-4 w-4 text-sky-400" />,
},
},
findai: {
position: "top-right",
offset: 64,
toastClass: cn(
"!rounded-2xl !border-violet-500/30 !bg-white/75 !text-zinc-800",
"!shadow-xl !shadow-violet-500/10 !backdrop-blur-xl",
"dark:!bg-zinc-900/75 dark:!text-zinc-100 dark:!border-violet-400/30"
),
toastStyle: { boxShadow: "inset 0 0 0 1px rgba(124,58,237,0.12)" },
icons: {
success: <Search className="h-4 w-4 text-violet-600 dark:text-violet-400" />,
error: <CircleX className="h-4 w-4 text-red-500" />,
warning: <TriangleAlert className="h-4 w-4 text-amber-500" />,
info: <Info className="h-4 w-4 text-violet-500" />,
},
},
};
export function SiteToaster() {
// NEXT_PUBLIC_* 在构建期内联进客户端包,每站构建各得其所
const skin = SKINS[process.env.NEXT_PUBLIC_THEME_ID || "ai-stack"] ?? SKINS["ai-stack"];
return (
<Toaster
position={skin.position}
offset={skin.offset}
toastOptions={{
classNames: { toast: skin.toastClass },
style: skin.toastStyle,
}}
icons={skin.icons}
/>
);
}
@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter as useIntlRouter } from "@/i18n/routing"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { localeHref } from "@/lib/locale-href";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Mail, AlertCircle } from 'lucide-react'; import { Mail, AlertCircle } from 'lucide-react';
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@@ -121,7 +122,7 @@ export function EmailVerifiedGuard({
<button <button
onClick={() => { onClick={() => {
// Sign out and redirect to login // Sign out and redirect to login
window.location.href = `/${locale}/login`; window.location.href = localeHref("/login", locale);
}} }}
className="text-sm text-muted-foreground hover:text-foreground" className="text-sm text-muted-foreground hover:text-foreground"
> >
@@ -3,6 +3,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { localeHref } from "@/lib/locale-href";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -49,7 +50,8 @@ export function LoginForm() {
typeof window !== "undefined" typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect") ? new URLSearchParams(window.location.search).get("redirect")
: null; : null;
router.push(redirect || `/${locale}/`); // localeHref 剥离 redirect 上可能带的语言段并按目标语言重拼,en 根路径返回 "/" 而非空串
router.push(localeHref(redirect || "/", locale));
} catch (err) { } catch (err) {
setError(t('errors.loginFailed')); setError(t('errors.loginFailed'));
} finally { } finally {
@@ -4,6 +4,7 @@ import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { localeHref } from '@/lib/locale-href';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -60,13 +61,13 @@ export function SignupForm() {
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页 // 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") { if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
router.push(`/check-email`); router.push(localeHref('/check-email', locale));
} else { } else {
const redirect = const redirect =
typeof window !== "undefined" typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("redirect") ? new URLSearchParams(window.location.search).get("redirect")
: null; : null;
router.push(redirect || `/${locale}/`); router.push(localeHref(redirect || '/', locale));
} }
} catch (err) { } catch (err) {
setError(t('errors.signupFailed')); setError(t('errors.signupFailed'));
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { localeHref } from "@/lib/locale-href";
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react"; import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
interface SocialAuthButtonsProps { interface SocialAuthButtonsProps {
@@ -51,7 +52,7 @@ export function SocialAuthButtons({
if (!res.ok) throw new Error("mock failed"); if (!res.ok) throw new Error("mock failed");
// session 已建立,回跳原页面 // session 已建立,回跳原页面
const target = getCallbackURL(); const target = getCallbackURL();
window.location.assign(`/${locale}${target === "/" ? "" : target}`); window.location.assign(localeHref(target, locale));
} catch { } catch {
setMockLoading(null); setMockLoading(null);
setMockError(true); setMockError(true);
+2 -8
View File
@@ -1,12 +1,6 @@
import { Link } from "next-view-transitions"; import { Link } from "next-view-transitions";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config"; import { localeHref } from "@/lib/locale-href";
/** 站内链接的确定性 locale 前缀 */
function localizedHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
return `${prefix}${href === "/" ? "" : href}`;
}
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
/** /**
@@ -39,7 +33,7 @@ export function SectionHeader({
</div> </div>
{viewAllHref && ( {viewAllHref && (
<Link <Link
href={localizedHref(viewAllHref, locale)} href={localeHref(viewAllHref, locale)}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary" className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
> >
{viewAllLabel} {viewAllLabel}
+3 -9
View File
@@ -2,13 +2,7 @@
import { Link } from "next-view-transitions"; import { Link } from "next-view-transitions";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config"; import { localeHref } from "@/lib/locale-href";
/** 站内链接的确定性 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 { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -30,7 +24,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
<div className="site-container px-4 py-12 sm:px-6"> <div className="site-container px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4"> <div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1"> <div className="md:col-span-1">
<Link href={localizedHref("/", locale)} className="flex items-center gap-2"> <Link href={localeHref("/", 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"> <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)} {brand.name.slice(0, 1)}
</span> </span>
@@ -52,7 +46,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
{group.links.map((link) => ( {group.links.map((link) => (
<li key={link.href + link.label}> <li key={link.href + link.label}>
<Link <Link
href={localizedHref(link.href, locale)} href={localeHref(link.href, locale)}
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground" className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
> >
{link.label} {link.label}
+9 -14
View File
@@ -6,16 +6,9 @@ import { Link } from "next-view-transitions";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu"; import { UserMenu } from "@/features/navigation/components/user-menu";
import { usePathname } from "next/navigation"; import { usePathname } from "@/i18n/routing";
import { useRouter as useIntlRouter } from "@/i18n/routing"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { locales, defaultLocale, type Locale } from "@/i18n.config"; import { localeHref } from "@/lib/locale-href";
/** 站内链接的确定性 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 { useLocale } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -57,9 +50,11 @@ export function ThemeHeader({
router.push(`/tools?search=${encodeURIComponent(query.trim())}`); router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
}; };
// pathname 来自 next-intl 路由(无语言前缀),直接与站内 href 比较;
// 原实现拼 `/${locale}${href}` 在 en 下得到 /en/tools,与真实路径永不相等
const isActive = (href: string) => { const isActive = (href: string) => {
const full = `/${locale}${href === "/" ? "" : href}`; if (href === "/") return pathname === "/";
return pathname === full || (href !== "/" && pathname.startsWith(full)); return pathname === href || pathname.startsWith(`${href}/`);
}; };
return ( return (
@@ -71,7 +66,7 @@ export function ThemeHeader({
> >
<div className="site-container flex h-14 items-center justify-between gap-2 px-4 sm:px-6"> <div className="site-container flex h-14 items-center justify-between gap-2 px-4 sm:px-6">
{/* Logo */} {/* Logo */}
<Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2 whitespace-nowrap"> <Link href={localeHref("/", locale)} className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{/* 站点 logo(codex 生成的 icon.svg,自带品牌色底,深浅通吃) */} {/* 站点 logo(codex 生成的 icon.svg,自带品牌色底,深浅通吃) */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" width={28} height={28} /> <img src="/icon.svg" alt="" className="h-7 w-7 rounded-lg" width={28} height={28} />
@@ -85,7 +80,7 @@ export function ThemeHeader({
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={localizedHref(item.href, locale)} href={localeHref(item.href, locale)}
className={cn( className={cn(
"whitespace-nowrap rounded-lg px-2 py-1.5 text-[0.95rem] transition-colors", "whitespace-nowrap rounded-lg px-2 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href) isActive(item.href)
@@ -140,7 +135,7 @@ export function ThemeHeader({
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={localizedHref(item.href, locale)} href={localeHref(item.href, locale)}
className={cn( className={cn(
"block rounded-lg px-3 py-2.5 text-[0.95rem]", "block rounded-lg px-3 py-2.5 text-[0.95rem]",
isActive(item.href) isActive(item.href)
+20
View File
@@ -0,0 +1,20 @@
import { defaultLocale, locales } from '@/i18n.config';
/**
* # 函数 localeHref
* 说明:与 middleware localePrefix: 'as-needed' 一致的站内链接拼接:
* - 默认语言(en)不带前缀,其他语言带 /{locale};
* - 先剥离 href 上已有的语言段(/en、/zh),避免叠加出 /zh/en/... 之类死链;
* - 保证返回值非空:根路径在默认语言下返回 '/'——手拼 `/${locale}` 时
* en 根路径会得到空串(<Link href=""> 点击无效、router.push('') 停留原地)
* 或 '/en/...'(该前缀在本配置下 404),站内所有拼接必须统一走这里。
* 参数:href 站内路径(可带语言段/查询串);locale 目标语言
* 返回:可直接用于 Link/router/window.location 的路径
*/
export function localeHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? '' : `/${locale}`;
const cleaned = href.replace(new RegExp(`^/(${locales.join('|')})(?=/|$)`), '');
if (cleaned === '' || cleaned === '/') return prefix || '/';
const normalized = cleaned.startsWith('/') ? cleaned : `/${cleaned}`;
return `${prefix}${normalized}`;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { GeistSans } from "geist/font/sans";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";
import { ThemeProvider } from "@/context/theme-provider"; import { ThemeProvider } from "@/context/theme-provider";
import { Toaster } from "sonner"; import { SiteToaster } from "@/components/site-toaster";
import Analytics from "@/analytics/analytics"; import Analytics from "@/analytics/analytics";
import { NavigationProgress } from "@/components/navigation-progress"; import { NavigationProgress } from "@/components/navigation-progress";
@@ -79,7 +79,7 @@ export default async function LocaleLayout({
<NavigationProgress /> <NavigationProgress />
</Suspense> </Suspense>
{children} {children}
<Toaster position="top-right" richColors /> <SiteToaster />
<Analytics /> <Analytics />
</NextIntlClientProvider> </NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
+2 -1
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { ToolCard, ToolCardGrid } from "@/components/tool-card"; import { ToolCard, ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { SectionHeader } from "@/kit/section"; import { SectionHeader } from "@/kit/section";
import { localeHref } from "@/lib/locale-href";
import { ThemeHeader } from "@/kit/theme-header"; import { ThemeHeader } from "@/kit/theme-header";
import { ThemeFooter } from "@/kit/theme-footer"; import { ThemeFooter } from "@/kit/theme-footer";
import { getHomeDataSafe } from "@/kit/home-data"; import { getHomeDataSafe } from "@/kit/home-data";
@@ -66,7 +67,7 @@ async function SiteHome({ locale }: { locale: string }) {
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base"> <p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-muted-foreground sm:text-base">
{th("description")} {th("description")}
</p> </p>
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl"> <form action={localeHref("/tools", locale)} className="mx-auto mt-8 max-w-xl">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
+2 -1
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { ToolCardGrid } from "@/components/tool-card"; import { ToolCardGrid } from "@/components/tool-card";
import { LocaleLink } from "@/components/locale-link"; import { LocaleLink } from "@/components/locale-link";
import { getHomeDataSafe } from "@/kit/home-data"; import { getHomeDataSafe } from "@/kit/home-data";
import { localeHref } from "@/lib/locale-href";
async function SiteHome({ locale }: { locale: string }) { async function SiteHome({ locale }: { locale: string }) {
const t = await getTranslations("siteHome"); const t = await getTranslations("siteHome");
@@ -31,7 +32,7 @@ async function SiteHome({ locale }: { locale: string }) {
{siteConfig.brand.tagline.replace(/\.$/, "")} {siteConfig.brand.tagline.replace(/\.$/, "")}
</p> </p>
<form action={`/${locale}/tools`} className="mt-10 w-full max-w-xl"> <form action={localeHref("/tools", locale)} className="mt-10 w-full max-w-xl">
<div className="relative"> <div className="relative">
<Search className="absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<input <input