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);