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:
@@ -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";
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { locales, localeNames, defaultLocale } from '@/i18n.config';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname } from '@/i18n/routing';
|
||||
import { locales, localeNames } from '@/i18n.config';
|
||||
import type { Locale } from '@/i18n.config';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, Globe, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { localeHref } from '@/lib/locale-href';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
// next-intl 的 usePathname 返回无语言前缀的路径(/zh/tools → /tools)
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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) => {
|
||||
if (newLocale === locale) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let path = pathname;
|
||||
|
||||
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}`);
|
||||
const search = typeof window !== 'undefined' ? window.location.search : '';
|
||||
router.push(`${localeHref(pathname, newLocale)}${search}`);
|
||||
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 { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { localeHref } from "@/lib/locale-href";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Mail, AlertCircle } from 'lucide-react';
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
@@ -121,7 +122,7 @@ export function EmailVerifiedGuard({
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { localeHref } from "@/lib/locale-href";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -49,7 +50,8 @@ export function LoginForm() {
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("redirect")
|
||||
: null;
|
||||
router.push(redirect || `/${locale}/`);
|
||||
// localeHref 剥离 redirect 上可能带的语言段并按目标语言重拼,en 根路径返回 "/" 而非空串
|
||||
router.push(localeHref(redirect || "/", locale));
|
||||
} catch (err) {
|
||||
setError(t('errors.loginFailed'));
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { localeHref } from '@/lib/locale-href';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -60,13 +61,13 @@ export function SignupForm() {
|
||||
|
||||
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
|
||||
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
|
||||
router.push(`/check-email`);
|
||||
router.push(localeHref('/check-email', locale));
|
||||
} else {
|
||||
const redirect =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("redirect")
|
||||
: null;
|
||||
router.push(redirect || `/${locale}/`);
|
||||
router.push(localeHref(redirect || '/', locale));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('errors.signupFailed'));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { localeHref } from "@/lib/locale-href";
|
||||
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
interface SocialAuthButtonsProps {
|
||||
@@ -51,7 +52,7 @@ export function SocialAuthButtons({
|
||||
if (!res.ok) throw new Error("mock failed");
|
||||
// session 已建立,回跳原页面
|
||||
const target = getCallbackURL();
|
||||
window.location.assign(`/${locale}${target === "/" ? "" : target}`);
|
||||
window.location.assign(localeHref(target, locale));
|
||||
} catch {
|
||||
setMockLoading(null);
|
||||
setMockError(true);
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Link } from "next-view-transitions";
|
||||
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 { localeHref } from "@/lib/locale-href";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -39,7 +33,7 @@ export function SectionHeader({
|
||||
</div>
|
||||
{viewAllHref && (
|
||||
<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"
|
||||
>
|
||||
{viewAllLabel}
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
|
||||
import { Link } from "next-view-transitions";
|
||||
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 { localeHref } from "@/lib/locale-href";
|
||||
|
||||
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
|
||||
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="grid gap-10 md:grid-cols-4">
|
||||
<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">
|
||||
{brand.name.slice(0, 1)}
|
||||
</span>
|
||||
@@ -52,7 +46,7 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
{group.links.map((link) => (
|
||||
<li key={link.href + link.label}>
|
||||
<Link
|
||||
href={localizedHref(link.href, locale)}
|
||||
href={localeHref(link.href, locale)}
|
||||
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -6,16 +6,9 @@ import { Link } from "next-view-transitions";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
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 { 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 { localeHref } from "@/lib/locale-href";
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThemeBrand } from "./types";
|
||||
@@ -57,9 +50,11 @@ export function ThemeHeader({
|
||||
router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
|
||||
};
|
||||
|
||||
// pathname 来自 next-intl 路由(无语言前缀),直接与站内 href 比较;
|
||||
// 原实现拼 `/${locale}${href}` 在 en 下得到 /en/tools,与真实路径永不相等
|
||||
const isActive = (href: string) => {
|
||||
const full = `/${locale}${href === "/" ? "" : href}`;
|
||||
return pathname === full || (href !== "/" && pathname.startsWith(full));
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 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,自带品牌色底,深浅通吃) */}
|
||||
{/* 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} />
|
||||
@@ -85,7 +80,7 @@ export function ThemeHeader({
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
href={localeHref(item.href, locale)}
|
||||
className={cn(
|
||||
"whitespace-nowrap rounded-lg px-2 py-1.5 text-[0.95rem] transition-colors",
|
||||
isActive(item.href)
|
||||
@@ -140,7 +135,7 @@ export function ThemeHeader({
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
href={localeHref(item.href, locale)}
|
||||
className={cn(
|
||||
"block rounded-lg px-3 py-2.5 text-[0.95rem]",
|
||||
isActive(item.href)
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { GeistSans } from "geist/font/sans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
import { ThemeProvider } from "@/context/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { SiteToaster } from "@/components/site-toaster";
|
||||
import Analytics from "@/analytics/analytics";
|
||||
import { NavigationProgress } from "@/components/navigation-progress";
|
||||
|
||||
@@ -79,7 +79,7 @@ export default async function LocaleLayout({
|
||||
<NavigationProgress />
|
||||
</Suspense>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
<SiteToaster />
|
||||
<Analytics />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user