diff --git a/packages/core/scripts/ui-test-i18n-toast.mjs b/packages/core/scripts/ui-test-i18n-toast.mjs new file mode 100644 index 0000000..54b1ed6 --- /dev/null +++ b/packages/core/scripts/ui-test-i18n-toast.mjs @@ -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-.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); diff --git a/packages/core/src/components/language-switcher.tsx b/packages/core/src/components/language-switcher.tsx index 039677e..99ab485 100644 --- a/packages/core/src/components/language-switcher.tsx +++ b/packages/core/src/components/language-switcher.tsx @@ -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(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); }; diff --git a/packages/core/src/components/site-toaster.tsx b/packages/core/src/components/site-toaster.tsx new file mode 100644 index 0000000..f103798 --- /dev/null +++ b/packages/core/src/components/site-toaster.tsx @@ -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 = { + "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: , + error: , + warning: , + info: , + }, + }, + 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: , + error: , + warning: , + info: , + }, + }, + "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: , + error: , + warning: , + info: , + }, + }, + "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: , + error: , + warning: , + info: , + }, + }, + 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: , + error: , + warning: , + info: , + }, + }, +}; + +export function SiteToaster() { + // NEXT_PUBLIC_* 在构建期内联进客户端包,每站构建各得其所 + const skin = SKINS[process.env.NEXT_PUBLIC_THEME_ID || "ai-stack"] ?? SKINS["ai-stack"]; + return ( + + ); +} diff --git a/packages/core/src/features/auth/components/email-verified-guard.tsx b/packages/core/src/features/auth/components/email-verified-guard.tsx index cc8e55e..965a46c 100644 --- a/packages/core/src/features/auth/components/email-verified-guard.tsx +++ b/packages/core/src/features/auth/components/email-verified-guard.tsx @@ -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({