diff --git a/apps/waterfall-wiki/src/components/GuidanceShell.tsx b/apps/waterfall-wiki/src/components/GuidanceShell.tsx index 7a6fe50..b592f49 100644 --- a/apps/waterfall-wiki/src/components/GuidanceShell.tsx +++ b/apps/waterfall-wiki/src/components/GuidanceShell.tsx @@ -1,3 +1,4 @@ +import a from "@/styles/account.module.css"; import { ThemeToggle } from "./ThemeToggle"; import { Brand } from "./Brand"; import Head from "next/head"; @@ -191,11 +192,10 @@ export default function GuidanceShell({ } if (standalone) return ( -
+
{`${title} · 啡跃`} 跳到正文 -
- +
返回首页
{children}
diff --git a/apps/waterfall-wiki/src/pages/account.tsx b/apps/waterfall-wiki/src/pages/account.tsx index 9113181..b1360da 100644 --- a/apps/waterfall-wiki/src/pages/account.tsx +++ b/apps/waterfall-wiki/src/pages/account.tsx @@ -1,23 +1,19 @@ -import { Brand } from "../components/Brand"; +import { Brand } from "@/components/Brand"; +import a from "@/styles/account.module.css"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useState, type FormEvent } from "react"; import { ArrowRight, - BookOpen, CheckCircle2, Eye, EyeOff, - GitBranch, - Link2, LogOut, MailCheck, - Waypoints, } 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" || @@ -36,327 +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 || - "如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。", - }); - await account.refresh(); + 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 || "若账户符合条件,邮件将发送到你的邮箱。"}); + if (["verify", "login", "logout", "reset"].includes(name)) 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 ( -
-
- - - - YOUR KNOWLEDGE, CONNECTED -

- 让有用的知识, -
- 成为下一次行动的起点。 -

-

- 用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent - 在需要的时候找到它。 -

-
-
- -
- 每篇文档,都能独立使用 -

从当前任务出发,无需加载整个集合。

-
-
-
- -
- 每次更新,都有固定版本 -

历史引用保持可读,修改始终有迹可循。

-
-
-
- -
- 知识之间,按条件连接 -

需要更多背景时,再沿有用的链接继续阅读。

-
-
+ const user = mode === "reset" || mode === "verify" || mode === "forgot" ? null : account.user; + const title = user ? "你的账户" : ({login: "欢迎回来", register: "创建你的账户", resend: "重发验证邮件", forgot: "找回你的账户", reset: "设置新密码", verify: "确认验证邮箱"})[mode]; + const description = user ? "你的知识与创作,随时继续。" : ({login: "使用邮箱与密码登录,继续你的创作。", register: "用邮箱建立你的知识与创作空间。", resend: "仅适用于尚未验证的账户,输入邮箱和原密码。", forgot: "输入注册邮箱,我们会发送重设密码链接。", reset: "由你设置新密码,原有内容会完整保留。", verify: "确认后,即可使用邮箱和密码登录。"})[mode]; + const toast = feedback || (account.error ? {tone: "error" as const, text: account.error} : null); + return
+
+ +
+

A SPACE FOR YOUR NEXT IDEA

+

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

+

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

+
-
-

- {account.user - ? "你的账户" - : mode === "register" - ? "创建你的账户" - : mode === "resend" - ? "重发验证邮件" - : "欢迎回来"} -

-

- {account.user - ? "已准备好发布和管理你的独立文档。" - : mode === "register" - ? "验证邮箱后,就可以发布第一篇文档。" - : mode === "resend" - ? "输入注册时的邮箱和密码,重新获取验证链接。" - : "使用邮箱与密码登录,继续你的知识工作。"} -

- {token && ( -
-

- - 确认验证邮箱 -

-

验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。

- -
- )} - {feedback && {feedback.text}} - {account.error && ( - - {account.error}{" "} - - - )} - {account.loading ? ( -
- 正在检查账户状态… +
+

啡跃 Endoleap 为下一次创作,留一个起点。

+
+
+
+

{title}

{description}

+ {account.loading ?
正在检查账户状态…
: user ? <> +
{user.email}邮箱已验证
+ 继续使用啡跃 + + : <> +
+ {(mode === "login" || mode === "register") && }
- ) : account.user ? ( - <> -
- - {account.user.email.slice(0, 1).toUpperCase()} - -
- {account.user.email} - - - 邮箱已验证 - -
-
-
-

- 你的内容,由你管理。 -
- 你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。 -

-
- - 继续使用啡跃 - - -
- -
- - ) : ( - <> - {mode !== "resend" && ( - - )} -
-
-
+ +
+
+ {mode !== "reset" && mode !== "verify" &&
- setEmail(e.target.value)} - /> -
-
- + setEmail(event.target.value)} /> +
} + {mode !== "forgot" && mode !== "verify" &&
+
{mode === "login" && }
- setPassword(e.target.value)} - /> - + setPassword(event.target.value)} /> +
- {mode === "register" && ( - - 建议使用包含字母、数字的长密码。 - - )} -
- -
- -
- {mode === "resend" ? ( - - ) : ( - - )} -
-

- 注册后需要先验证邮箱。公开文档始终可以直接阅读。 -

- - )} -
-
- ); +
} + {mode === "reset" &&
setConfirm(event.target.value)} />
} + {mode === "verify" &&

验证链接有效期为 30 分钟。请确认这是你注册的邮箱。

} +
+ + + +
{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 new file mode 100644 index 0000000..e1f7581 --- /dev/null +++ b/apps/waterfall-wiki/src/styles/account.module.css @@ -0,0 +1,95 @@ +.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); 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: 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: 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); } +.idea { left: 0; top: 42px; width: 48%; min-height: 196px; padding: 22px 18px; transform: rotate(-8deg); background: color-mix(in srgb, var(--surface) 78%, transparent); backdrop-filter: blur(16px); } +.idea span, .work > span { font-size: 8px; letter-spacing: .12em; line-height: 1.6; } +.idea strong { margin: 24px 0 18px; font-size: clamp(18px, 1.8vw, 26px); font-weight: 500; line-height: 1.5; } +.idea i, .work i { font-size: 9px; font-style: normal; line-height: 1.7; opacity: .72; } +.work { right: 0; top: 15px; width: 51%; padding: 18px; transform: rotate(8deg); background: var(--el-cream); color: var(--el-espresso); } +.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); } + +.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 { 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/src/styles/guidance.module.css b/apps/waterfall-wiki/src/styles/guidance.module.css index 4c1df1a..56352c8 100644 --- a/apps/waterfall-wiki/src/styles/guidance.module.css +++ b/apps/waterfall-wiki/src/styles/guidance.module.css @@ -1678,66 +1678,11 @@ height: 31px; color: var(--muted-foreground); } -.accountLayout { - display: grid; - grid-template-columns: minmax(0, 1fr) 410px; - max-width: 990px; - gap: 70px; - align-items: center; - margin: 50px auto 70px; -} -.accountIntro { - padding: 20px 0; -} -.accountSymbol { - width: 54px; - height: 54px; - display: grid; - place-items: center; - background: var(--selection); - border: 1px solid var(--selection-border); - border-radius: 14px; - color: var(--primary); - margin-bottom: 28px; -} -.accountIntro h1 { - font-size: 32px; - line-height: 1.5; - letter-spacing: -1px; - font-weight: 650; - margin-bottom: 17px; -} -.accountIntro > p { - font-size: 13px; - line-height: 1.9; - color: var(--muted-foreground); - max-width: 360px; -} -.accountFeatures { - display: grid; - gap: 21px; - margin-top: 35px; -} -.accountFeatures > div { - display: flex; - align-items: flex-start; - gap: 12px; -} -.accountFeatures svg { - color: var(--muted-foreground); - margin-top: 2px; -} -.accountFeatures strong { - display: block; - font-size: 12px; - font-weight: 600; -} -.accountFeatures p { - font-size: 11px; - color: var(--muted-foreground); - line-height: 1.8; - margin-top: 5px; -} + + + + + .authCard { padding: 28px; border: 1px solid var(--line); @@ -1922,13 +1867,7 @@ grid-template-columns: minmax(0, 1fr) 230px; gap: 20px; } - .accountLayout { - gap: 35px; - grid-template-columns: minmax(0, 1fr) 370px; - } - .accountIntro h1 { - font-size: 29px; - } + .protocolLayout { grid-template-columns: minmax(0, 1fr) 190px; } @@ -1968,30 +1907,9 @@ .protocolLayout > aside { display: none; } - .accountLayout { - grid-template-columns: 1fr; - max-width: 430px; - margin: 15px auto 40px; - gap: 25px; - } - .accountIntro { - padding: 0; - } - .accountIntro h1 { - font-size: 26px; - margin-bottom: 8px; - } - .accountSymbol, - .accountFeatures { - display: none; - } - .accountIntro > p { - max-width: none; - font-size: 12px; - } - .accountIntro h1 br { - display: none; - } + + + .topbarProtocol { display: none; } @@ -2302,19 +2220,8 @@ .authCard { padding: 24px; } - .accountLayout { - margin-top: 8px; - gap: 24px; - } - .accountIntro h1 { - font-size: 24px; - } - .accountIntro > p { - font-size: 11px; - } - .accountSymbol { - display: none; - } + + .modalBody { padding: 23px; } @@ -2380,14 +2287,3 @@ .primaryButton,.primaryButton:hover{color:var(--primary-foreground);background:var(--primary)} .primaryButton:hover{background:var(--primary-hover)} .primaryButton:disabled{background:var(--disabled-background);color:var(--disabled-foreground)} - -/* Authentication has its own public page, without workspace navigation. */ -.accountPage { min-height: 100vh; background: var(--background); color: var(--foreground); } -.accountHeader { max-width: 1200px; margin: 0 auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.accountHeader a { color: var(--muted-foreground); text-decoration: none; } -.accountHeader > div { display: flex; align-items: center; gap: 20px; font-size: 13px; } -.accountPage > main { max-width: 1120px; margin: 0 auto; padding: 48px 32px 72px; } -@media (max-width: 650px) { - .accountHeader { padding: 20px; } - .accountPage > main { padding: 20px 20px 48px; } -} 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 31dbb6e..99d5a7d 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -27,6 +27,31 @@ const assert=require('node:assert/strict'); } if(checkLegacy&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); await page.goto(base+'/',{waitUntil:'networkidle'});await page.getByRole('heading',{name:/想法不该停在对话里/}).waitFor();await page.screenshot({path:dir+'/landing-desktop.png',fullPage:true}); + // Verify the public account flow in this fresh context, without submitting credentials. + await page.setViewportSize({width:1128,height:959}); + await page.goto(base+'/account?next=/workspace',{waitUntil:'domcontentloaded'}); + await page.locator('button[data-theme-ready="true"]').waitFor(); + await page.getByLabel('邮箱地址',{exact:true}).waitFor(); + assert.equal(await page.locator('aside').count(),0,'Authentication must not include a workspace sidebar'); + assert.equal(await page.getByRole('link',{name:'创建文档',exact:true}).count(),0); + assert.equal(await page.locator('.endoleap-brand').count(),1,'Only one brand belongs on the account page'); + 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}); + 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'); await page.getByRole('button',{name:'添加文字',exact:true}).waitFor({state:'visible'});