From ee72835ea38cd0a853d6f263dae3e6010df0e9db Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 1 Nov 2026 23:50:32 +0800 Subject: [PATCH] feat(auth): add recovery UI and stable viewport login interactions --- apps/waterfall-wiki/src/pages/account.tsx | 359 ++++-------------- .../src/styles/account.module.css | 99 +++-- .../tests/guidance-workspace-ui.ts | 40 ++ scripts/verify-studio-visual.cjs | 7 +- 4 files changed, 196 insertions(+), 309 deletions(-) diff --git a/apps/waterfall-wiki/src/pages/account.tsx b/apps/waterfall-wiki/src/pages/account.tsx index 4d5d73f..d427b8c 100644 --- a/apps/waterfall-wiki/src/pages/account.tsx +++ b/apps/waterfall-wiki/src/pages/account.tsx @@ -12,9 +12,8 @@ import { MailCheck, } from "lucide-react"; import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell"; -import { Notice } from "@/components/GuidanceUI"; import s from "@/styles/guidance.module.css"; -type Mode = "login" | "register" | "resend"; +type Mode = "login" | "register" | "resend" | "forgot" | "reset" | "verify"; function localNext(value: string | string[] | undefined) { if ( typeof value !== "string" || @@ -33,304 +32,104 @@ function localNext(value: string | string[] | undefined) { } } export default function AccountPage() { - return ( - - - - ); + return ; } function AccountContent() { - const account = useGuidanceAccount(), - router = useRouter(); - const [mode, setMode] = useState("login"), - [email, setEmail] = useState(""), - [password, setPassword] = useState(""), - [showPassword, setShowPassword] = useState(false); - const [busy, setBusy] = useState(false), - [token, setToken] = useState(""), - [feedback, setFeedback] = useState<{ - tone: "success" | "error"; - text: string; - } | null>(null); + const account = useGuidanceAccount(), router = useRouter(); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""), [password, setPassword] = useState(""), [confirm, setConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false), [busy, setBusy] = useState(false); + const [token, setToken] = useState(""); + const [feedback, setFeedback] = useState<{tone: "success" | "error"; text: string} | null>(null); useEffect(() => { const url = new URL(window.location.href); - const value = url.searchParams.get("verify"); - if (value) { - setToken(value); - url.searchParams.delete("verify"); + const reset = url.searchParams.get("reset"), verify = url.searchParams.get("verify"); + if (reset || verify) { + setToken(reset || verify || ""); setMode(reset ? "reset" : "verify"); + url.searchParams.delete("reset"); url.searchParams.delete("verify"); window.history.replaceState({}, "", url.pathname + url.search + url.hash); } }, []); - async function action(name: Mode | "verify" | "logout", body: object = {}) { + function switchMode(value: Mode) { setMode(value); setFeedback(null); setPassword(""); setConfirm(""); setShowPassword(false); } + async function action(name: Mode | "logout", body: object = {}) { if (busy) return; - setBusy(true); - setFeedback(null); + setBusy(true); setFeedback(null); try { - const response = await fetch("/api/account/" + name, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + const response = await fetch("/api/account/" + name, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}); const data = await response.json(); if (!response.ok) throw Error(data.error || "暂时无法完成操作,请重试"); - if (name === "verify") { - setToken(""); - setMode("login"); - setFeedback({ tone: "success", text: "邮箱已验证,现在可以登录。" }); - } else if (name === "login") { - setPassword(""); - setFeedback({ tone: "success", text: "登录成功,欢迎回到啡跃。" }); - } else if (name === "logout") { - setPassword(""); - setFeedback({ tone: "success", text: "已退出登录。" }); - } else - setFeedback({ - tone: "success", - text: - data.message || - "如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。", - }); + if (name === "verify" || name === "reset") { setToken(""); switchMode("login"); } + if (name === "login" || name === "logout" || name === "reset") { setPassword(""); setConfirm(""); } + setFeedback({tone: "success", text: name === "verify" ? "邮箱已验证,现在可以登录。" : name === "reset" ? "密码已更新,请使用新密码登录。" : name === "login" ? "登录成功,欢迎回到啡跃。" : name === "logout" ? "已退出登录。" : data.message || "若账户符合条件,邮件将发送到你的邮箱。"}); await account.refresh(); if (name === "login") await router.push(localNext(router.query.next)); - } catch (e) { - setFeedback({ - tone: "error", - text: e instanceof Error ? e.message : "网络异常,请稍后重试", - }); - } finally { - setBusy(false); - } + } catch (error) { setFeedback({tone: "error", text: error instanceof Error ? error.message : "网络异常,请稍后重试"}); } + finally { setBusy(false); } } function submit(event: FormEvent) { event.preventDefault(); - void action(mode, { email, password }); + if (mode === "reset" && password !== confirm) { setFeedback({tone: "error", text: "两次输入的密码不一致。"}); return; } + void action(mode, mode === "reset" ? {token, password} : mode === "verify" ? {token} : {email, password}); } - function switchMode(value: Mode) { - setMode(value); - setFeedback(null); - } - return ( -
-
- -
-

A SPACE FOR YOUR NEXT IDEA

-

给想法留白,
让灵感有形。

-

收集一闪而过的灵感,把有用的知识连接起来。下一件作品,从这里开始。

- + + + +
{mode === "login" || mode === "register" ? : }
+

{mode === "forgot" || mode === "reset" ? "链接仅供本人使用。重设成功后,所有旧会话将退出。" : "首次注册需要验证邮箱;已验证的账户可直接登录。"}

+ } +
+
+ {toast &&

{toast.text}

} +
; } diff --git a/apps/waterfall-wiki/src/styles/account.module.css b/apps/waterfall-wiki/src/styles/account.module.css index 4dc6d79..e1f7581 100644 --- a/apps/waterfall-wiki/src/styles/account.module.css +++ b/apps/waterfall-wiki/src/styles/account.module.css @@ -1,18 +1,19 @@ -.page { min-height: 100svh; background: var(--surface); color: var(--foreground); } +.page { height: 100dvh; overflow: hidden; background: var(--surface); color: var(--foreground); } +.page > main { height: 100%; min-height: 0; } .header { position: absolute; z-index: 2; top: 28px; right: 36px; } .header > div { display: flex; align-items: center; gap: 20px; font-size: 13px; color: var(--muted-foreground); } .header a:hover { color: var(--foreground-strong); } -.layout { display: grid; grid-template-columns: 46% minmax(0, 1fr); min-height: 100svh; } +.layout { display: grid; grid-template-columns: 46% minmax(0, 1fr); height: 100%; min-height: 0; } .intro { position: relative; isolation: isolate; display: flex; flex-direction: column; min-width: 0; padding: 38px clamp(32px, 5vw, 80px) 30px; overflow: hidden; background: var(--background); color: var(--foreground); } .intro::before { content: ''; position: absolute; z-index: -1; inset: 0; background: radial-gradient(ellipse at 0% 35%, color-mix(in srgb, var(--el-deep-mocha) 85%, transparent), transparent 60%), radial-gradient(ellipse at 100% 92%, color-mix(in srgb, var(--primary) 45%, transparent), transparent 65%); } .brand { align-self: flex-start; } -.story { margin: auto 0; padding-top: 64px; } +.story { margin: auto 0; padding-top: clamp(16px, 4dvh, 40px); } .eyebrow { margin: 0 0 20px; font-size: 10px; letter-spacing: .18em; color: var(--muted-foreground); line-height: 1.7; } .story h1 { margin: 0 0 22px; font-size: clamp(32px, 3.3vw, 50px); font-weight: 600; line-height: 1.3; letter-spacing: -.035em; color: var(--foreground-strong); } .description { margin: 0; max-width: 340px; font-size: 15px; line-height: 1.9; color: var(--foreground); } .signature { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 32px 0 0; font-size: 11px; line-height: 1.7; color: var(--muted-foreground); } .signature span { opacity: .8; } -.art { position: relative; height: 300px; margin: 28px -12px 0; pointer-events: none; } +.art { position: relative; height: clamp(180px, 28dvh, 280px); margin: 28px -12px 0; pointer-events: none; } .orbit { position: absolute; width: 245px; height: 245px; left: 15%; top: 5%; border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent); border-radius: 50%; } .orbit::after { content: ''; position: absolute; inset: 25px -35px; border: inherit; border-radius: 50%; transform: rotate(-38deg); } .idea, .work { position: absolute; display: flex; flex-direction: column; border: 1px solid color-mix(in srgb, var(--foreground) 24%, transparent); border-radius: 14px; box-shadow: 0 20px 50px var(--shadow); } @@ -24,29 +25,71 @@ .artwork { height: 110px; margin: 16px 0 14px; overflow: hidden; border-radius: 100px 100px 8px 8px; background: radial-gradient(ellipse at 75% 20%, var(--el-cream), transparent 60%), linear-gradient(135deg, var(--el-deep-mocha), var(--el-espresso)); } .work strong { margin-bottom: 6px; font-size: 19px; font-weight: 500; } .artCaption { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; font-size: 8px; letter-spacing: .2em; color: var(--muted-foreground); } -.page .form { align-self: center; width: min(400px, calc(100% - 96px)); min-width: 0; margin: 116px auto 80px; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; } -.page .form h2 { font-size: 28px; font-weight: 600; text-align: center; letter-spacing: -.04em; } -.page .form > p:first-of-type { font-size: 14px; text-align: center; margin: 10px 0 32px; } -.page .form label { font-size: 13px; font-weight: 500; } -.page .form input { min-height: 46px; font-size: 14px; background: var(--surface); border: 1px solid var(--input); border-radius: 8px; box-shadow: 0 1px 2px var(--shadow); } -.page .form nav { background: var(--surface-muted); padding: 4px; margin-bottom: 28px; border: 1px solid var(--border); border-radius: 9px; } -.page .form nav button { min-height: 36px; font-size: 13px; } -.page .form button[type='submit'] { min-height: 46px; font-size: 14px; border-radius: 8px; margin-top: 8px; } -.page .form form + div { margin-top: 22px; } -.page .form form + div button { font-size: 12px; } -.page .form > p:last-child { margin-top: 32px !important; font-size: 12px; line-height: 1.8; } -@media (min-width: 1500px) { .story { max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; } .art { height: 330px; } .brand, .signature { margin-left: 0; } } -@media (max-width: 1000px) { .intro { padding-left: 28px; padding-right: 28px; } .story h1 { font-size: 34px; } .description { font-size: 14px; } .page .form { width: min(380px, calc(100% - 64px)); } .header { right: 28px; } } + +.form { align-self: center; justify-self: center; width: min(400px, calc(100% - 80px)); min-width: 0; padding: 0; } +.frame { height: 560px; display: flex; flex-direction: column; justify-content: center; } +.frame h2 { margin: 0; font-size: 28px; font-weight: 600; line-height: 1.3; text-align: center; letter-spacing: -.04em; animation: mode-in 180ms ease-out; } +.formDescription { height: 42px; flex-shrink: 0; margin: 10px 0 20px; font-size: 14px; line-height: 1.5; text-align: center; color: var(--muted-foreground); } +.tabSlot { height: 44px; flex-shrink: 0; margin-bottom: 24px; } +.tabs { position: relative; display: grid; grid-template-columns: 1fr 1fr; height: 44px; padding: 4px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-muted); isolation: isolate; } +.tabs::before { content: ''; position: absolute; z-index: -1; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px); border-radius: 5px; background: var(--surface); box-shadow: 0 1px 3px var(--shadow); transition: transform 180ms ease; } +.tabs[data-active='register']::before { transform: translateX(100%); } +.tabs button { border: 0; background: transparent; font-size: 13px; color: var(--muted-foreground); } +.tabs button[aria-pressed='true'] { color: var(--foreground-strong); font-weight: 600; } +.fieldset { margin: 0; padding: 0; min-width: 0; border: 0; } +.fields { height: 168px; display: flex; flex-direction: column; gap: 16px; animation: mode-in 180ms ease-out; } +.field { min-width: 0; } +.field label { display: block; margin-bottom: 7px; font-size: 13px; line-height: 20px; font-weight: 500; } +.labelRow { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.labelRow button { font-size: 12px; color: var(--muted-foreground); } +.labelRow button:hover { color: var(--foreground-strong); } +.page .form input { width: 100%; height: 44px; min-height: 44px; padding: 0 12px; font-size: 14px; background: var(--surface); border: 1px solid var(--input); border-radius: 8px; box-shadow: 0 1px 2px var(--shadow); } +.page .form input[name='password'] { padding-right: 44px; } +.page .submit { width: 100%; min-height: 44px; margin-top: 8px; font-size: 14px; border-radius: 8px; } +.secondarySlot { min-height: 38px; display: grid; place-items: center; margin-top: 14px; } +.secondary { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 0; font-size: 12px; color: var(--muted-foreground); text-decoration: underline; text-underline-offset: 4px; } +.footnote { height: 60px; flex-shrink: 0; margin: 18px 0 0; padding-top: 16px; border-top: 1px solid var(--border); font-size: 12px; line-height: 1.7; text-align: center; color: var(--muted-foreground); } +.verifyInfo, .accountDetails { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; height: 168px; text-align: center; color: var(--muted-foreground); } +.accountDetails strong { color: var(--foreground-strong); overflow-wrap: anywhere; } +.accountDetails span, .verifyInfo p { font-size: 13px; line-height: 1.8; } +.loading { height: 320px; display: grid; place-items: center; font-size: 13px; color: var(--muted-foreground); } +.toast { position: fixed; z-index: 50; right: 20px; bottom: 20px; width: min(420px, calc(100% - 40px)); display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--foreground); box-shadow: 0 8px 32px var(--shadow); animation: mode-in 180ms ease-out; } +.toast > span { flex: 0 0 20px; height: 20px; text-align: center; border-radius: 50%; background: var(--success-background); color: var(--success); font-size: 13px; font-weight: 600; } +.toast[data-tone='error'] > span { color: var(--danger); background: var(--danger-background); } +.toast p { flex: 1; min-width: 0; margin: 0; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } +.toast button { flex: 0 0 24px; min-height: 24px; color: var(--muted-foreground); } +@keyframes mode-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } +@media (min-width: 1500px) { .story { max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; } } +@media (max-width: 1000px) { .intro { padding-left: 28px; padding-right: 28px; } .story h1 { font-size: 34px; } .description { font-size: 14px; } .form { width: min(380px, calc(100% - 64px)); } .header { right: 28px; } } @media (max-width: 760px) { - .header { position: static; display: flex; justify-content: flex-end; padding: 16px 20px; } - .layout { display: flex; flex-direction: column; min-height: auto; } - .intro { margin: 0 16px; padding: 24px; border-radius: 16px; } - .brand { margin-bottom: 28px; } - .story { margin: 0; padding: 0; } - .eyebrow { margin-bottom: 12px; font-size: 9px; } - .story h1 { font-size: 30px; margin-bottom: 14px; } - .description { max-width: 420px; font-size: 13px; line-height: 1.8; } - .art, .signature { display: none; } - .page .form { width: min(400px, calc(100% - 48px)); margin: 40px auto 48px; } - .page .form h2 { font-size: 25px; } + .header { top: 20px; right: 20px; } + .layout { grid-template-columns: minmax(0, 1fr); } + .intro { position: absolute; top: 20px; left: 24px; padding: 0; background: transparent; overflow: visible; } + .intro::before, .story, .signature { display: none; } + .brand { padding: 5px; border-radius: 8px; background: var(--background); } + .form { width: min(400px, calc(100% - 48px)); padding-top: 40px; } + .frame h2 { font-size: 25px; } } +@media (max-height: 760px) { + .art, .signature { display: none; } + .intro { padding-top: 24px; padding-bottom: 24px; } + .formDescription { height: 34px; font-size: 13px; margin: 8px 0 14px; } + .frame { height: 460px; } + .frame h2 { font-size: 25px; } + .tabSlot { margin-bottom: 18px; } + .fields { height: 158px; gap: 12px; } + .footnote { display: none; } + .secondarySlot { margin-top: 10px; } +} +@media (max-height: 560px) { + .header a { display: none; } + .formDescription { display: none; } + .frame { height: 340px; } + .frame h2 { font-size: 22px; margin-bottom: 14px; } + .tabSlot { height: 36px; margin-bottom: 14px; } + .tabs { height: 36px; } + .story h1 { font-size: 28px; } + .eyebrow { display: none; } +} +@media (max-width: 760px) and (max-height: 760px) { .intro { padding: 0; } } +@media (prefers-reduced-motion: reduce) { .frame h2, .fields, .toast { animation: none; } .tabs::before { transition: none; } } diff --git a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts index dbd7264..b8b3956 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts @@ -164,6 +164,26 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) assert.equal(await page.getByRole("complementary").count(), 0, "Account page has no workspace sidebar"); assert.equal(await page.getByRole("link", { name: "创建文档", exact: true }).count(), 0); await page.getByRole("link", { name: "返回首页", exact: true }).waitFor(); + const accountFits = async () => assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.documentElement.scrollHeight <= innerHeight + 1), "Account page fits the viewport on both axes"); + await accountFits(); + await context.route("**/api/account/login", route => route.fulfill({status: 401, json: {error: "测试登录失败,请重试"}})); + await page.getByLabel("邮箱地址", {exact: true}).fill("fixture@example.com"); + await page.getByLabel("密码", {exact: true}).fill("fixture-password-long"); + await page.waitForFunction(() => document.getAnimations().every(animation => animation.playState !== "running")); + const beforeMessage = await page.getByLabel("邮箱地址", {exact: true}).boundingBox(); + await page.locator("form").getByRole("button", {name: "登录", exact: true}).click(); + await page.getByRole("alert").getByText("测试登录失败,请重试").waitFor(); + const afterMessage = await page.getByLabel("邮箱地址", {exact: true}).boundingBox(); + assert.equal(afterMessage?.y, beforeMessage?.y, "Status messages must not move the form"); + await accountFits(); + await page.getByRole("button", {name: "关闭提示", exact: true}).click(); + await context.route("**/api/account/forgot", route => route.fulfill({json: {ok: true, message: "若账户符合条件,重设链接将发送到邮箱。"}})); + await page.getByRole("button", {name: "忘记密码?", exact: true}).click(); + await page.getByRole("heading", {name: "找回你的账户", exact: true}).waitFor(); + await page.getByLabel("邮箱地址", {exact: true}).fill("fixture@example.com"); + await page.getByRole("button", {name: "发送重设链接", exact: true}).click(); + await page.getByRole("status").getByText("若账户符合条件,重设链接将发送到邮箱。").waitFor(); + await page.getByRole("button", {name: "返回登录", exact: true}).click(); await page.getByRole("button", { name: "创建账户", exact: true }).click(); await page.getByRole("heading", { name: "创建你的账户" }).waitFor(); await page @@ -181,6 +201,26 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png", fullPage: true, }); + for (const viewport of [{width: 390, height: 844}, {width: 320, height: 568}]) { + await page.setViewportSize(viewport); + await accountFits(); + await page.getByLabel("邮箱地址", {exact: true}).waitFor({state: "visible"}); + } + await page.setViewportSize({width: 1440, height: 1000}); + await context.route("**/api/account/reset", async route => { + const body = route.request().postDataJSON(); + assert.equal(body.token, "f".repeat(64)); + assert.equal(body.password, "replacement-fixture-password"); + await route.fulfill({json: {ok: true}}); + }); + await visitBrowserDocument(page, base + "/account?reset=" + "f".repeat(64)); + await page.getByRole("heading", {name: "设置新密码", exact: true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.has("reset"), false, "Recovery token is removed from the visible URL"); + await page.getByLabel("新密码", {exact: true}).fill("replacement-fixture-password"); + await page.getByLabel("确认新密码", {exact: true}).fill("replacement-fixture-password"); + await page.getByRole("button", {name: "保存新密码", exact: true}).click(); + await page.getByRole("heading", {name: "欢迎回来", exact: true}).waitFor(); + await accountFits(); await navigate(base + "/write"); await page.getByRole("heading", { name: "登录后开始编写" }).waitFor(); signedIn = true; diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index dee8da8..99d5a7d 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -38,14 +38,19 @@ const assert=require('node:assert/strict'); const accountSections=await page.locator('#account-content > div > section').evaluateAll(elements=>elements.map(el=>{const r=el.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width}})); assert.equal(accountSections.length,2); assert.ok(accountSections[0].right<=accountSections[1].x,'Brand panel and credentials occupy separate columns'); + const accountFits=async()=>assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1&&document.documentElement.scrollHeight<=innerHeight+1),'Account document fits both viewport axes'); + const accountControlsFit=async()=>assert.ok(await page.locator('#account-content input, #account-content button[type="submit"]').evaluateAll(elements=>elements.every(el=>{const r=el.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})),'Account inputs and submit button remain visible, not merely clipped'); + await accountFits();await accountControlsFit(); await page.screenshot({path:dir+'/account-desktop.png',fullPage:true}); await page.getByRole('button',{name:'创建账户',exact:true}).click(); await page.getByRole('heading',{name:'创建你的账户',exact:true}).waitFor(); await page.screenshot({path:dir+'/account-register.png',fullPage:true}); await page.getByRole('button',{name:'登录',exact:true}).click(); await page.setViewportSize({width:390,height:844}); - assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'Mobile account page must not overflow horizontally'); + await accountFits();await accountControlsFit(); await page.screenshot({path:dir+'/account-mobile.png',fullPage:true}); + await page.setViewportSize({width:320,height:568});await accountFits();await accountControlsFit(); + await page.screenshot({path:dir+'/account-mobile-compact.png',fullPage:true}); await page.setViewportSize({width:1100,height:1000}); await page.goto(base+'/graphic',{waitUntil:'networkidle'}); assert.equal(new URL(page.url()).pathname,'/graphic');