Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca08f44137 | |||
| b31a4835ee | |||
| fd96b6d9e9 | |||
| ee72835ea3 | |||
| 6800a1b1c4 | |||
| 1da1114d52 | |||
| 8c48812a11 | |||
| 88d68f3d3c | |||
| 6819db40a8 | |||
| b2381a1cdf | |||
| 150e4b3af8 | |||
| e36b8aea6c | |||
| 517c31ca04 | |||
| fafdae66bb | |||
| a45d43f5f7 | |||
| 5b68c5b84e | |||
| 5c068ace81 | |||
| f4397db869 | |||
| 9e5c5abbdf | |||
| 8b949484c1 | |||
| 9887a5958a | |||
| cc6b376f56 |
@@ -11,6 +11,8 @@ on:
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
- 'scripts/cleanup-review-images.sh'
|
||||
- 'scripts/test-review-image-cleanup.py'
|
||||
concurrency:
|
||||
group: creative-workspace-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
@@ -42,9 +44,11 @@ jobs:
|
||||
- name: Check patch formatting
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
python3 scripts/test-review-image-cleanup.py
|
||||
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
|
||||
- name: Verify Studio types, tests, build and smoke
|
||||
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
|
||||
- name: Build Wiki integration and runtime images
|
||||
@@ -63,3 +67,8 @@ jobs:
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
- name: Remove this run disposable review image tags
|
||||
if: always()
|
||||
env:
|
||||
REVIEW_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/cleanup-review-images.sh
|
||||
|
||||
@@ -20,7 +20,9 @@ const fs=require('node:fs/promises');
|
||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
await page.goto(base+'/infinite_canvas');
|
||||
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
|
||||
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
|
||||
await page.locator('button[data-theme-ready="true"]').waitFor();
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
@@ -99,7 +101,7 @@ const fs=require('node:fs/promises');
|
||||
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
|
||||
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
|
||||
const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
||||
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
||||
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
|
||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
||||
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check;
|
||||
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset'));
|
||||
INSERT INTO schema_migrations(version) VALUES ('017_password_reset');
|
||||
COMMIT;
|
||||
@@ -1,3 +1,4 @@
|
||||
import a from "@/styles/account.module.css";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Brand } from "./Brand";
|
||||
import Head from "next/head";
|
||||
@@ -48,9 +49,11 @@ export const useGuidanceAccount = () => useContext(AccountContext);
|
||||
export default function GuidanceShell({
|
||||
children,
|
||||
title = "探索文档",
|
||||
standalone = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
standalone?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
@@ -187,6 +190,18 @@ export default function GuidanceShell({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (standalone) return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={a.page}>
|
||||
<Head><title>{`${title} · 啡跃`}</title></Head>
|
||||
<a className={s.skipLink} href="#account-content">跳到正文</a>
|
||||
<header className={a.header}>
|
||||
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
|
||||
</header>
|
||||
<main id="account-content">{children}</main>
|
||||
</div>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={s.site}>
|
||||
|
||||
@@ -3,5 +3,5 @@ export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}>◐<span>{dark ? '浅色' : '深色'}</span></button>;
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}><span aria-hidden="true">◐</span></button>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { normalizeEmail,validPassword, trustedOrigin,accountCookie } from './account-crypto';
|
||||
import { rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
|
||||
import { requestPasswordReset,resetPassword,rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
|
||||
export const runtime = 'nodejs';
|
||||
const headers = {'Cache-Control':'private, no-store','Referrer-Policy':'no-referrer'};
|
||||
const json = (body: object,status=200) => NextResponse.json(body,{status,headers});
|
||||
@@ -12,7 +12,7 @@ export async function GET(request: Request,context: Context) {
|
||||
export async function POST(request: Request,context: Context) {
|
||||
if (!trustedOrigin(request)) return json({error:'请求来源不受信任'},403);
|
||||
const {action} = await context.params;
|
||||
if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404);
|
||||
if (!['register','resend','verify','login','logout','forgot','reset'].includes(action)) return json({error:'不存在'},404);
|
||||
try {
|
||||
// Revocation remains available even when login or mail limits are exhausted.
|
||||
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
|
||||
@@ -27,6 +27,19 @@ export async function POST(request: Request,context: Context) {
|
||||
if(typeof body.token!=='string'||! /^[a-f0-9]{64}$/.test(body.token))return json({error:'验证链接无效或已过期'},400);
|
||||
return await verifyAccount(body.token) ? json({ok:true,message:'邮箱已验证,请登录'}) : json({error:'验证链接无效或已过期'},400);
|
||||
}
|
||||
if(action==='reset') {
|
||||
if(typeof body.token!=='string'||!/^[a-f0-9]{64}$/.test(body.token)||!validPassword(body.password))return json({error:'重设链接无效,或密码不符合要求'},400);
|
||||
if(!await rateLimit('reset:'+body.token,5))return json({error:'请求过于频繁,请稍后再试'},429);
|
||||
if(!await resetPassword(body.token,body.password))return json({error:'重设链接无效或已过期,请重新申请'},400);
|
||||
const r=json({ok:true,message:'密码已更新,请使用新密码登录'});r.headers.set('Set-Cookie',accountCookie('',true));return r;
|
||||
}
|
||||
if(action==='forgot') {
|
||||
const email=normalizeEmail(body.email);
|
||||
if(!email)return json({error:'请输入有效邮箱'},400);
|
||||
if(!await rateLimit('forgot:'+email,3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
|
||||
await requestPasswordReset(email);
|
||||
return json({ok:true,message:'若该邮箱对应已验证账户,重设链接将发送至邮箱,有效期 30 分钟。'});
|
||||
}
|
||||
const email=normalizeEmail(body.email);
|
||||
if(!email||!validPassword(body.password))return json({error:'请输入有效邮箱及 12–256 字节密码'},400);
|
||||
if(!await rateLimit(action+':'+email,action==='login'?10:3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { db, transaction } from './db';
|
||||
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword } from './account-crypto';
|
||||
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword, validPassword } from './account-crypto';
|
||||
export async function rateLimit(key: string, limit: number) {
|
||||
const result = await db().query(`INSERT INTO account_limits(key,attempts,window_start) VALUES ($1,1,now())
|
||||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN 1 ELSE account_limits.attempts+1 END,
|
||||
window_start=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN now() ELSE account_limits.window_start END RETURNING attempts`,[tokenDigest(key)]);
|
||||
return result.rows[0].attempts <= limit;
|
||||
}
|
||||
export async function sendVerification(email: string, token: string) {
|
||||
async function sendAccountMail(email: string, token: string, kind: 'verify' | 'reset') {
|
||||
const origin = process.env.ACCOUNT_PUBLIC_ORIGIN;
|
||||
if (!origin || !process.env.SMTP_PASSWORD || !process.env.SMTP_USERNAME) throw Error('SMTP unavailable');
|
||||
const url = new URL('/account',origin); url.searchParams.set('verify',token);
|
||||
const url = new URL('/account',origin); url.searchParams.set(kind,token);
|
||||
const smtp = nodemailer.createTransport({host:process.env.SMTP_HOST || 'smtpdm.aliyun.com',port:Number(process.env.SMTP_PORT || 465),secure:true,
|
||||
auth:{user:process.env.SMTP_USERNAME,pass:process.env.SMTP_PASSWORD},connectionTimeout:10000,socketTimeout:15000});
|
||||
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:'验证你的知识站邮箱',text:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
|
||||
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:kind==='reset'?'重设你的啡跃账户密码':'验证你的知识站邮箱',text:kind==='reset'?`请在 30 分钟内打开链接,由你设置新的密码:\n${url.href}\n如果你没有请求重设密码,请忽略;你的密码不会改变。`:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
|
||||
}
|
||||
export async function sendVerification(email: string,token: string) { await sendAccountMail(email,token,'verify'); }
|
||||
export async function requestPasswordReset(email: string) {
|
||||
const result=await db().query('SELECT id FROM account_users WHERE email=$1 AND verified_at IS NOT NULL',[email]);
|
||||
if(!result.rowCount)return;
|
||||
const token=newToken(),digest=tokenDigest(token);
|
||||
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'reset',now()+interval '30 minutes')",[digest,result.rows[0].id]);
|
||||
try { await sendAccountMail(email,token,'reset'); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[digest]);throw Error('SMTP unavailable'); }
|
||||
}
|
||||
export async function resetPassword(token: string,password: string) {
|
||||
if(!/^[a-f0-9]{64}$/.test(token)||!validPassword(password))return false;
|
||||
const hash=await hashPassword(password),digest=tokenDigest(token);
|
||||
return transaction(async client=>{
|
||||
const lookup=await client.query("SELECT user_id FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now()",[digest]);
|
||||
if(!lookup.rowCount)return false;
|
||||
const id=lookup.rows[0].user_id;
|
||||
// Serialize all reset links for one account, then recheck after acquiring the lock.
|
||||
await client.query('SELECT id FROM account_users WHERE id=$1 FOR UPDATE',[id]);
|
||||
const consumed=await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now() RETURNING user_id",[digest]);
|
||||
if(!consumed.rowCount)return false;
|
||||
await client.query('UPDATE account_users SET password_hash=$1 WHERE id=$2',[hash,id]);
|
||||
await client.query('DELETE FROM account_tokens WHERE user_id=$1',[id]);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
export async function registerAccount(email: string,password: string) {
|
||||
const hash = await hashPassword(password);
|
||||
@@ -42,14 +66,16 @@ export async function verifyAccount(token: string) {
|
||||
});
|
||||
}
|
||||
export async function loginAccount(email: string,password: string) {
|
||||
const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]);
|
||||
return transaction(async client=>{
|
||||
const result = await client.query('SELECT * FROM account_users WHERE email=$1 FOR UPDATE',[email]);
|
||||
const user = result.rows[0];
|
||||
// A fixed dummy hash makes unknown accounts perform the same expensive KDF.
|
||||
const hash = user?.password_hash || 'scrypt:00000000000000000000000000000000:'+'0'.repeat(128);
|
||||
if (!await checkPassword(password,hash) || !user?.verified_at) return null;
|
||||
const token = newToken();
|
||||
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
|
||||
await client.query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
export function requestToken(request: Request) {
|
||||
const value = request.headers.get('cookie')?.split(';').map(s=>s.trim()).find(s=>s.startsWith(ACCOUNT_COOKIE+'='))?.slice(ACCOUNT_COOKIE.length+1);
|
||||
|
||||
@@ -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 (
|
||||
<GuidanceShell title="邮箱账户">
|
||||
<AccountContent />
|
||||
</GuidanceShell>
|
||||
);
|
||||
return <GuidanceShell title="登录与注册" standalone><AccountContent /></GuidanceShell>;
|
||||
}
|
||||
function AccountContent() {
|
||||
const account = useGuidanceAccount(),
|
||||
router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("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<Mode>("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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className={s.accountLayout}>
|
||||
<section className={s.accountIntro}>
|
||||
<span className={s.accountSymbol}>
|
||||
<Brand compact/>
|
||||
</span>
|
||||
<span className={s.eyebrow}>YOUR KNOWLEDGE, CONNECTED</span>
|
||||
<h1>
|
||||
让有用的知识,
|
||||
<br />
|
||||
成为下一次行动的起点。
|
||||
</h1>
|
||||
<p>
|
||||
用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent
|
||||
在需要的时候找到它。
|
||||
</p>
|
||||
<div className={s.accountFeatures}>
|
||||
<div>
|
||||
<BookOpen size={18} />
|
||||
<div>
|
||||
<strong>每篇文档,都能独立使用</strong>
|
||||
<p>从当前任务出发,无需加载整个集合。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GitBranch size={18} />
|
||||
<div>
|
||||
<strong>每次更新,都有固定版本</strong>
|
||||
<p>历史引用保持可读,修改始终有迹可循。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link2 size={18} />
|
||||
<div>
|
||||
<strong>知识之间,按条件连接</strong>
|
||||
<p>需要更多背景时,再沿有用的链接继续阅读。</p>
|
||||
</div>
|
||||
</div>
|
||||
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 <div className={a.layout}>
|
||||
<section className={a.intro} data-theme="home-dark" aria-label="啡跃创作空间">
|
||||
<Link className={a.brand} href="/" aria-label="啡跃首页"><Brand /></Link>
|
||||
<div className={a.story}>
|
||||
<p className={a.eyebrow}>A SPACE FOR YOUR NEXT IDEA</p>
|
||||
<h1>给想法留白,<br />让灵感有形。</h1>
|
||||
<p className={a.description}>收集一闪而过的灵感,把有用的知识连接起来。下一件作品,从这里开始。</p>
|
||||
<div className={a.art} aria-hidden="true">
|
||||
<div className={a.orbit} />
|
||||
<div className={a.idea}><span>01 / THE IDEA</span><strong>留住清晨<br />的一束光。</strong><i>灵感片段 · 随手记下</i></div>
|
||||
<div className={a.work}><span>02 / IN THE MAKING</span><div className={a.artwork} /><strong>光的形状</strong><i>让一个念头,慢慢成形。</i></div>
|
||||
<span className={a.artCaption}>COLLECT. CONNECT. CREATE.</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className={s.authCard} aria-label="邮箱账户">
|
||||
<h2>
|
||||
{account.user
|
||||
? "你的账户"
|
||||
: mode === "register"
|
||||
? "创建你的账户"
|
||||
: mode === "resend"
|
||||
? "重发验证邮件"
|
||||
: "欢迎回来"}
|
||||
</h2>
|
||||
<p className={s.authDescription}>
|
||||
{account.user
|
||||
? "已准备好发布和管理你的独立文档。"
|
||||
: mode === "register"
|
||||
? "验证邮箱后,就可以发布第一篇文档。"
|
||||
: mode === "resend"
|
||||
? "输入注册时的邮箱和密码,重新获取验证链接。"
|
||||
: "使用邮箱与密码登录,继续你的知识工作。"}
|
||||
</p>
|
||||
{token && (
|
||||
<section className={s.verificationCard}>
|
||||
<h3>
|
||||
<MailCheck size={17} />
|
||||
确认验证邮箱
|
||||
</h3>
|
||||
<p>验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。</p>
|
||||
<button
|
||||
type="button"
|
||||
className={s.primaryButton}
|
||||
disabled={busy}
|
||||
onClick={() => void action("verify", { token })}
|
||||
>
|
||||
{busy ? "正在验证…" : "确认验证邮箱"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{feedback && <Notice tone={feedback.tone}>{feedback.text}</Notice>}
|
||||
{account.error && (
|
||||
<Notice tone="error">
|
||||
{account.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={s.textLink}
|
||||
onClick={() => void account.refresh()}
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</Notice>
|
||||
)}
|
||||
{account.loading ? (
|
||||
<div className={s.accountLoading} aria-busy="true">
|
||||
正在检查账户状态…
|
||||
</div>
|
||||
<p className={a.signature}>啡跃 Endoleap <span>为下一次创作,留一个起点。</span></p>
|
||||
</section>
|
||||
<section className={a.form} aria-label="邮箱账户">
|
||||
<div className={a.frame}>
|
||||
<h2 key={title}>{title}</h2><p className={a.formDescription}>{description}</p>
|
||||
{account.loading ? <div className={a.loading} aria-busy="true">正在检查账户状态…</div> : user ? <>
|
||||
<div className={a.accountDetails}><CheckCircle2 size={22} /><strong>{user.email}</strong><span>邮箱已验证</span></div>
|
||||
<Link className={s.primaryButton} href={localNext(router.query.next)}>继续使用啡跃 <ArrowRight size={15} /></Link>
|
||||
<button className={a.secondary} type="button" disabled={busy} onClick={() => void action("logout")}><LogOut size={14} />退出登录</button>
|
||||
</> : <>
|
||||
<div className={a.tabSlot}>
|
||||
{(mode === "login" || mode === "register") && <nav className={a.tabs} aria-label="账户操作" data-active={mode}>
|
||||
<button type="button" aria-pressed={mode === "login"} disabled={busy} onClick={() => switchMode("login")}>登录</button>
|
||||
<button type="button" aria-pressed={mode === "register"} disabled={busy} onClick={() => switchMode("register")}>创建账户</button>
|
||||
</nav>}
|
||||
</div>
|
||||
) : account.user ? (
|
||||
<>
|
||||
<div className={s.loggedUser}>
|
||||
<span className={s.avatar}>
|
||||
{account.user.email.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{account.user.email}</strong>
|
||||
<span className={s.verifiedBadge}>
|
||||
<CheckCircle2 size={13} />
|
||||
邮箱已验证
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.accountInfo}>
|
||||
<p>
|
||||
<strong>你的内容,由你管理。</strong>
|
||||
<br />
|
||||
你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={s.primaryButton}
|
||||
href={localNext(router.query.next)}
|
||||
>
|
||||
继续使用啡跃
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
<div className={s.authSecondary}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void action("logout")}
|
||||
>
|
||||
<LogOut size={12} /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mode !== "resend" && (
|
||||
<nav className={s.authTabs} aria-label="账户操作">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "login"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "register"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
创建账户
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<form onSubmit={submit}>
|
||||
<fieldset className={s.formFieldset} disabled={busy}>
|
||||
<div className={s.formField}>
|
||||
<form onSubmit={submit}>
|
||||
<fieldset className={a.fieldset} disabled={busy}>
|
||||
<div className={a.fields} key={mode}>
|
||||
{mode !== "reset" && mode !== "verify" && <div className={a.field}>
|
||||
<label htmlFor="account-email">邮箱地址</label>
|
||||
<input
|
||||
id="account-email"
|
||||
className={s.input}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxLength={254}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.formField}>
|
||||
<label htmlFor="account-password">密码</label>
|
||||
<input id="account-email" className={s.input} type="email" name="email" autoComplete="email" placeholder="you@example.com" required maxLength={254} value={email} onChange={event => setEmail(event.target.value)} />
|
||||
</div>}
|
||||
{mode !== "forgot" && mode !== "verify" && <div className={a.field}>
|
||||
<div className={a.labelRow}><label htmlFor="account-password">{mode === "reset" ? "新密码" : "密码"}</label>{mode === "login" && <button type="button" onClick={() => switchMode("forgot")}>忘记密码?</button>}</div>
|
||||
<div className={s.passwordField}>
|
||||
<input
|
||||
id="account-password"
|
||||
className={s.input}
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
autoComplete={
|
||||
mode === "register"
|
||||
? "new-password"
|
||||
: "current-password"
|
||||
}
|
||||
placeholder={
|
||||
mode === "register"
|
||||
? "设置至少 12 个字符的密码"
|
||||
: "输入你的密码"
|
||||
}
|
||||
minLength={12}
|
||||
maxLength={256}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<input id="account-password" className={s.input} type={showPassword ? "text" : "password"} name="password" autoComplete={mode === "register" || mode === "reset" ? "new-password" : "current-password"} placeholder={mode === "register" || mode === "reset" ? "设置至少 12 个字符的密码" : "输入你的密码"} minLength={12} maxLength={256} required value={password} onChange={event => setPassword(event.target.value)} />
|
||||
<button type="button" aria-label={showPassword ? "隐藏密码" : "显示密码"} onClick={() => setShowPassword(value => !value)}>{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<small className={s.fieldHint}>
|
||||
建议使用包含字母、数字的长密码。
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<button className={s.primaryButton} type="submit">
|
||||
{busy
|
||||
? "正在处理…"
|
||||
: mode === "login"
|
||||
? "登录"
|
||||
: mode === "register"
|
||||
? "创建账户"
|
||||
: "发送验证邮件"}
|
||||
{!busy && <ArrowRight size={15} />}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div className={s.authSecondary}>
|
||||
{mode === "resend" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("resend")}
|
||||
>
|
||||
没有收到验证邮件?重新发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.authFootnote}>
|
||||
注册后需要先验证邮箱。公开文档始终可以直接阅读。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>}
|
||||
{mode === "reset" && <div className={a.field}><label htmlFor="account-confirm">确认新密码</label><input id="account-confirm" className={s.input} type={showPassword ? "text" : "password"} autoComplete="new-password" required minLength={12} maxLength={256} value={confirm} onChange={event => setConfirm(event.target.value)} /></div>}
|
||||
{mode === "verify" && <div className={a.verifyInfo}><MailCheck size={28} /><p>验证链接有效期为 30 分钟。请确认这是你注册的邮箱。</p></div>}
|
||||
</div>
|
||||
<button className={`${s.primaryButton} ${a.submit}`} type="submit">{busy ? "正在处理…" : ({login: "登录", register: "创建账户", resend: "发送验证邮件", forgot: "发送重设链接", reset: "保存新密码", verify: "确认验证邮箱"})[mode]}{!busy && <ArrowRight size={15} />}</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div className={a.secondarySlot}>{mode === "login" || mode === "register" ? <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("resend")}>没有收到验证邮件?重新发送</button> : <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("login")}>返回登录</button>}</div>
|
||||
<p className={a.footnote}>{mode === "forgot" || mode === "reset" ? "链接仅供本人使用。重设成功后,所有旧会话将退出。" : "首次注册需要验证邮箱;已验证的账户可直接登录。"}</p>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
{toast && <div className={a.toast} data-tone={toast.tone} role={toast.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{toast.tone === "error" ? "!" : "✓"}</span><p>{toast.text}</p><button type="button" aria-label={!feedback && account.error ? "重新连接" : "关闭提示"} onClick={() => {setFeedback(null); if (account.error) void account.refresh();}}>×</button></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
@@ -8,11 +8,11 @@ body{margin:0}
|
||||
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
|
||||
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
|
||||
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:0;background:transparent;color:var(--muted-foreground);border-radius:8px;padding:8px;width:32px;height:32px;font-size:16px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
|
||||
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
::selection{background:var(--selection);color:var(--foreground)}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:inline}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
|
||||
button:disabled{cursor:default}
|
||||
input,textarea,select{accent-color:var(--primary)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../src/lib/db';
|
||||
import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto';
|
||||
import { loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account';
|
||||
export async function verifyEmailAccounts() {
|
||||
import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account';
|
||||
export async function verifyEmailAccounts(base: string) {
|
||||
const id=randomUUID(), email=id+'@example.com', password='a secure test password';
|
||||
const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}});
|
||||
try {
|
||||
@@ -17,6 +17,34 @@ export async function verifyEmailAccounts() {
|
||||
await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]);
|
||||
assert.equal(await currentAccount(request(token)),null,'expired sessions rejected');
|
||||
const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token');
|
||||
const oldSession=await loginAccount(email,password);assert.ok(oldSession);
|
||||
const resets=[newToken(),newToken()],replacement='a new secure test password';
|
||||
for(const value of resets)await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(value),id]);
|
||||
const resetResults=await Promise.all(resets.map(value=>resetPassword(value,replacement)));
|
||||
assert.deepEqual(resetResults.sort(),[false,true],'Only one concurrent reset may succeed for one account');
|
||||
assert.equal(await currentAccount(request(oldSession)),null,'Reset revokes existing sessions');
|
||||
assert.equal(await loginAccount(email,password),null,'Original password is no longer accepted');
|
||||
assert.ok(await loginAccount(email,replacement));
|
||||
assert.equal(await resetPassword(resets[0],password),false,'Reset links are single-use, including sibling links');
|
||||
const expired=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()-interval '1 minute')",[tokenDigest(expired),id]);
|
||||
assert.equal(await resetPassword(expired,password),false);
|
||||
const wrongKind=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(wrongKind),id]);
|
||||
assert.equal(await resetPassword(wrongKind,password),false,'Email verification cannot reset a password');
|
||||
assert.equal(await resetPassword(newToken(),'short'),false);
|
||||
const post=(action:string,body:object,origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base)=>fetch(base+'/api/account/'+action,{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:JSON.stringify(body)});
|
||||
assert.equal((await post('forgot',{email},'https://untrusted.invalid')).status,403,'Cross-origin recovery is rejected');
|
||||
const unknown=await post('forgot',{email:randomUUID()+'@example.com'});
|
||||
assert.equal(unknown.status,200,'Unknown email receives the same non-enumerating response without sending mail');
|
||||
assert.match((await unknown.json()).message,/若该邮箱/);
|
||||
const httpToken=newToken(),httpPassword='http replacement secure password';
|
||||
const priorHttpSession=await loginAccount(email,replacement);assert.ok(priorHttpSession);
|
||||
await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(httpToken),id]);
|
||||
const changed=await post('reset',{token:httpToken,password:httpPassword});
|
||||
assert.equal(changed.status,200);
|
||||
assert.match(changed.headers.get('set-cookie')||'',/Max-Age=0/);
|
||||
assert.equal(await currentAccount(request(priorHttpSession)),null);
|
||||
assert.ok(await loginAccount(email,httpPassword),'HTTP recovery changes the stored password');
|
||||
assert.equal((await post('reset',{token:httpToken,password:replacement})).status,400,'HTTP recovery rejects a consumed token');
|
||||
const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]);
|
||||
await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]);
|
||||
} finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Recover once from an idempotent document GET's transient proxy failure.
|
||||
* This never retries hydration, theme assertions, or mutations, and shares one
|
||||
* 60-second budget across both attempts rather than extending the deadline.
|
||||
* @param {(timeout: number) => Promise<{ok(): boolean, status(): number} | null>} navigate
|
||||
* @param {string} url
|
||||
* @param {() => number} now
|
||||
*/
|
||||
export async function navigateBrandDocument(navigate, url, now = Date.now) {
|
||||
const deadline = now() + 60000;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) throw new Error(`Document navigation budget exhausted: ${url}`);
|
||||
try {
|
||||
const response = await navigate(remaining);
|
||||
if (response?.ok()) return response;
|
||||
const status = response?.status();
|
||||
if (attempt === 1 && [500, 502, 503, 504].includes(status ?? 0)) {
|
||||
console.warn('Brand navigation proxy recovery', JSON.stringify({url, status, attempt}));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Document navigation failed: ${url}, HTTP ${status ?? 'no response'}`);
|
||||
} catch (error) {
|
||||
if (attempt === 1 && /ECONNRESET|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|EMPTY_RESPONSE|ABORTED)/.test(String(error))) {
|
||||
console.warn('Brand navigation connection recovery', JSON.stringify({url, error: String(error), attempt}));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Document navigation failed: ${url}`);
|
||||
}
|
||||
|
||||
/** Load a document fully before relying on server-rendered text or leaving it.
|
||||
* Only document GETs may recover; application assertions remain outside retry.
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @param {string} url
|
||||
* @param {boolean} reload
|
||||
*/
|
||||
export async function visitBrowserDocument(page, url, reload = false) {
|
||||
return navigateBrandDocument(async timeout => {
|
||||
const started = Date.now();
|
||||
const scripts = new Set(), failures = [];
|
||||
const requested = request => { if (request.resourceType() === 'script') scripts.add(request); };
|
||||
const failed = request => { if (scripts.has(request)) failures.push(request.failure()?.errorText || 'unknown script failure'); };
|
||||
page.on('request', requested);
|
||||
page.on('requestfailed', failed);
|
||||
try {
|
||||
const response = await (reload ? page.reload({waitUntil: 'domcontentloaded', timeout}) : page.goto(url, {waitUntil: 'domcontentloaded', timeout}));
|
||||
if (response?.ok()) {
|
||||
const remaining = timeout - (Date.now() - started);
|
||||
if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url);
|
||||
try {
|
||||
// A cancelled script may be a preload duplicate. Hydration is the
|
||||
// authority; recover only when cancellation actually blocks startup.
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor({timeout: failures.length ? Math.min(5000, remaining) : remaining});
|
||||
} catch (error) {
|
||||
if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
page.off('request', requested);
|
||||
page.off('requestfailed', failed);
|
||||
}
|
||||
}, url);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {navigateBrandDocument, visitBrowserDocument} from './brand-navigation.mjs';
|
||||
const response = status => ({ok: () => status === 200, status: () => status});
|
||||
|
||||
test('successful documents are fetched once', async () => {
|
||||
let calls = 0;
|
||||
await navigateBrandDocument(async () => { calls++; return response(200); }, '/canvas');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
test('one proxy failure can recover within the original deadline', async () => {
|
||||
const timeouts = []; let elapsed = 0;
|
||||
await navigateBrandDocument(async timeout => {
|
||||
timeouts.push(timeout); elapsed = 15000;
|
||||
return response(timeouts.length === 1 ? 500 : 200);
|
||||
}, '/canvas', () => elapsed);
|
||||
assert.deepEqual(timeouts, [60000, 45000]);
|
||||
});
|
||||
test('connection reset and interrupted bootstrap scripts can recover once', async () => {
|
||||
for(const failure of ['net::ERR_CONNECTION_RESET','Document bootstrap script request failed: net::ERR_ABORTED']){
|
||||
let calls = 0;
|
||||
await navigateBrandDocument(async () => {
|
||||
if (++calls === 1) throw new Error(failure);
|
||||
return response(200);
|
||||
}, '/canvas');
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
test('persistent server failure is not hidden', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(502); }, '/canvas'), /HTTP 502/);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
test('missing routes, authentication failures and ordinary timeouts are not retried', async () => {
|
||||
for (const status of [401, 403, 404]) {
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(status); }, '/canvas'), /HTTP/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; throw new Error('Timeout 60000ms exceeded'); }, '/canvas'), /Timeout/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
test('recovery cannot extend the total navigation budget', async () => {
|
||||
let calls = 0, elapsed = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => {
|
||||
calls++; elapsed = 60000; return response(503);
|
||||
}, '/canvas', () => elapsed), /budget exhausted/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = false) {
|
||||
const listeners = new Map(), calls = [];
|
||||
const page = {
|
||||
on(event, fn) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(fn); },
|
||||
off(event, fn) { listeners.get(event)?.delete(fn); },
|
||||
async goto(url, options) {
|
||||
calls.push(options.waitUntil);
|
||||
const request = {resourceType: () => 'script', failure: () => ({errorText: 'net::ERR_ABORTED'})};
|
||||
for (const fn of listeners.get('request') || []) fn(request);
|
||||
if (failBootstrap && calls.length === 1) for (const fn of listeners.get('requestfailed') || []) fn(request);
|
||||
return response(200);
|
||||
},
|
||||
locator() { return {first: () => ({waitFor: async () => { calls.push('hydrated'); if (failHydration || (blockedOnce && calls.length === 2)) throw Error('Hydration timeout'); }})}; },
|
||||
};
|
||||
return {page, calls, listeners};
|
||||
}
|
||||
test('document visit completes deferred scripts before checking hydration and removes listeners', async () => {
|
||||
const {page, calls, listeners} = browserFixture();
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
assert.ok([...listeners.values()].every(set => set.size === 0));
|
||||
});
|
||||
test('interrupted bootstrap retries only document loading before hydration', async () => {
|
||||
const {page, calls} = browserFixture(true, false, true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated', 'domcontentloaded', 'hydrated']);
|
||||
});
|
||||
test('hydration failure is surfaced without retrying the application', async () => {
|
||||
const {page, calls} = browserFixture(false, true);
|
||||
await assert.rejects(visitBrowserDocument(page, '/account'), /Hydration timeout/);
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
});
|
||||
|
||||
test('cancelled script does not reject an already hydrated document', async () => {
|
||||
const {page, calls} = browserFixture(true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/** CI-only checks against the disposable stack, never against personal documents. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright-core';
|
||||
import { chromium, type Request } from 'playwright-core';
|
||||
import { visitBrowserDocument } from './brand-navigation.mjs';
|
||||
export async function verifyBrandUI(base: string) {
|
||||
for (const prefix of process.env.VERIFY_CANVAS_UI === '1' ? ['', '/studio'] : ['']) {
|
||||
const icon = await fetch(`${base}${prefix}/favicon.ico`);
|
||||
@@ -18,11 +19,22 @@ export async function verifyBrandUI(base: string) {
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);
|
||||
const failures: string[] = [], scripts: {path:string;status?:number;ms:number;error?:string}[] = [];
|
||||
const scriptStarted = new WeakMap<Request,number>();
|
||||
page.on('request', request => { if(request.resourceType()==='script') scriptStarted.set(request,Date.now()); });
|
||||
page.on('response', response => { if(response.request().resourceType()==='script') { scripts.push({path:new URL(response.url()).pathname,status:response.status(),ms:Date.now()-(scriptStarted.get(response.request())??Date.now())}); if(scripts.length>24)scripts.shift(); } });
|
||||
page.on('pageerror', error => failures.push(error.message));
|
||||
page.on('requestfailed', request => { failures.push(new URL(request.url()).pathname + ': ' + request.failure()?.errorText); if(request.resourceType()==='script') scripts.push({path:new URL(request.url()).pathname,ms:Date.now()-(scriptStarted.get(request)??Date.now()),error:request.failure()?.errorText}); });
|
||||
const visit = async (path: string, theme: string) => {
|
||||
await page.goto(base + path, { waitUntil: 'commit' });
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor();
|
||||
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
|
||||
console.log('Brand UI route ready:', path, theme);
|
||||
const started = Date.now();
|
||||
try {
|
||||
await visitBrowserDocument(page, base + path);
|
||||
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
|
||||
console.log('Brand UI route ready:', path, theme, Date.now() - started);
|
||||
} catch (error) {
|
||||
console.error('Brand UI navigation diagnostics', JSON.stringify({path, url:page.url(), elapsedMs:Date.now()-started, failures:failures.slice(-20), scripts:scripts.slice(-24), controls:await page.locator('[data-theme-ready]').evaluateAll(elements=>elements.map(el=>({ready:el.getAttribute('data-theme-ready'),display:getComputedStyle(el).display}))), body:(await page.locator('body').innerText({timeout:3000}).catch(()=>'unavailable')).slice(0,1200)}));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const background = (selector: string) => page.locator(selector).first().evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
await visit('/', 'home-light');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { visitBrowserDocument } from "./brand-navigation.mjs";
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {chromium,type Request} from 'playwright-core';
|
||||
@@ -22,12 +23,13 @@ export async function verifyCanvasAgentUI(base:string){
|
||||
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
|
||||
try{
|
||||
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);page.on('pageerror',e=>errors.push(e.message));
|
||||
mark('open landing');await page.goto(base+'/',{waitUntil:'commit'});
|
||||
mark('open landing');await visitBrowserDocument(page,base+'/');
|
||||
await page.getByRole('heading',{name:/想法不该停在对话里/,level:1}).waitFor();
|
||||
const accountSession=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/session'&&response.request().method()==='GET');
|
||||
const [,sessionResponse]=await Promise.all([page.getByRole('link',{name:'开始创作',exact:true}).click(),accountSession]);
|
||||
assert.equal(sessionResponse.status(),200);assert.equal((await sessionResponse.json()).user,null);
|
||||
await page.getByRole('heading',{name:'欢迎回来',exact:true,level:2}).waitFor();
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor();
|
||||
mark('login form ready');
|
||||
await page.getByLabel('邮箱地址',{exact:true}).fill(email);
|
||||
await page.getByLabel('密码',{exact:true}).fill(password);
|
||||
@@ -36,10 +38,13 @@ export async function verifyCanvasAgentUI(base:string){
|
||||
await page.waitForURL(url=>url.pathname==='/workspace',{waitUntil:'commit'});
|
||||
mark('workspace URL accepted');
|
||||
await page.getByRole('heading',{name:'今天,让哪个想法成形?',exact:true}).waitFor();
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor();
|
||||
mark('workspace rendered');
|
||||
const session=(await context.cookies()).find(c=>c.name===ACCOUNT_COOKIE);assert.ok(session?.httpOnly,'The actual login response establishes an HTTP-only account session');
|
||||
const ownerHeaders={cookie:ACCOUNT_COOKIE+'='+session.value};
|
||||
await page.getByRole('link',{name:'无限画布',exact:true}).click();
|
||||
const canvasLink=page.getByRole('link',{name:'无限画布',exact:true});
|
||||
assert.equal(await canvasLink.getAttribute('href'),'/infinite_canvas');
|
||||
await visitBrowserDocument(page,base+'/infinite_canvas');
|
||||
await page.getByRole('link',{name:email,exact:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
const shot=page.locator('[data-node-id="shot"] [data-node-content]');
|
||||
@@ -97,7 +102,7 @@ export async function verifyCanvasAgentUI(base:string){
|
||||
assert.equal((await fetch(base+root,{headers:{authorization:second.authorization}})).status,404);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
const bounds=await panel.boundingBox();assert.ok(bounds&&bounds.x>=0&&bounds.x+bounds.width<=390,'Agent panel stays within the mobile viewport');
|
||||
await page.reload({waitUntil:'commit'});
|
||||
await visitBrowserDocument(page,page.url(),true);
|
||||
await page.getByRole('link',{name:email,exact:true,includeHidden:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'Agent',exact:true}).click();
|
||||
const reopened=page.getByRole('region',{name:'Agent 连接',exact:true});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { visitBrowserDocument } from "./brand-navigation.mjs";
|
||||
import assert from 'node:assert/strict';
|
||||
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
|
||||
import {chromium} from 'playwright-core';
|
||||
@@ -46,7 +47,7 @@ export async function verifyGuidancePublishing(base:string){
|
||||
page.on('response',response=>{const path=new URL(response.url()).pathname;if(path.startsWith('/api/')||path.includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${path}: ${body.slice(0,1400)}`)).catch(()=>{});else if(response.status()>=400||response.request().resourceType()==='script')record(`resource ${response.status()} ${path}`);});
|
||||
try{
|
||||
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
|
||||
const navigation=await page.goto(base+'/documents',{waitUntil:'commit'});record('directory HTTP '+navigation?.status());
|
||||
const navigation=await visitBrowserDocument(page,base+'/documents');record('directory HTTP '+navigation?.status());
|
||||
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
|
||||
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
|
||||
const search=page.getByRole('textbox',{name:'搜索文档'});
|
||||
@@ -54,8 +55,10 @@ export async function verifyGuidancePublishing(base:string){
|
||||
await page.waitForURL(url=>url.searchParams.get('q')===httpDoc.title,{waitUntil:'commit'});
|
||||
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
|
||||
assert.equal(await page.getByText('没有匹配文档',{exact:false}).count(),0);
|
||||
await page.reload({waitUntil:'commit'});
|
||||
await visitBrowserDocument(page,page.url(),true);
|
||||
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
|
||||
// The matching count can also describe the unfiltered list before router hydration.
|
||||
await page.waitForFunction(title=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value===title,httpDoc.title);
|
||||
assert.equal(await search.inputValue(),httpDoc.title);
|
||||
await page.getByRole('tab',{name:/事实依据/}).click();
|
||||
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence',{waitUntil:'commit'});
|
||||
@@ -63,13 +66,14 @@ export async function verifyGuidancePublishing(base:string){
|
||||
await page.getByRole('button',{name:'清除所有筛选'}).click();
|
||||
await page.waitForURL(url=>url.search==='',{waitUntil:'commit'});
|
||||
await page.locator(link).waitFor({state:'visible'});
|
||||
await page.waitForFunction(()=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value==='');
|
||||
assert.equal(await search.inputValue(),'');
|
||||
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
|
||||
await page.locator(link).click();
|
||||
await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1',{waitUntil:'commit'});
|
||||
await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();
|
||||
assert.equal(await page.locator('h1').textContent(),httpDoc.title);
|
||||
await page.goto(base+'/write',{waitUntil:'commit'});await page.getByRole('heading',{name:'发布独立文档',exact:true,level:1}).waitFor();assert.equal(await page.locator('h1').textContent(),'发布独立文档');
|
||||
await visitBrowserDocument(page,base+'/write');await page.getByRole('heading',{name:'发布独立文档',exact:true,level:1}).waitFor();assert.equal(await page.locator('h1').textContent(),'发布独立文档');
|
||||
}catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;}
|
||||
await page.close();
|
||||
await verifyGuidanceWorkspaceUI(base,browser);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */
|
||||
import assert from "node:assert/strict";
|
||||
import { visitBrowserDocument } from "./brand-navigation.mjs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import type { Browser } from "playwright-core";
|
||||
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
|
||||
@@ -100,9 +101,14 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
async function navigate(url: string, reload = false) {
|
||||
const previousSessionReplies = sessionReplies;
|
||||
try {
|
||||
const navigation = await (reload ? page.reload({waitUntil:"commit"}) : page.goto(url,{waitUntil:"commit"}));
|
||||
const navigation = await visitBrowserDocument(page, url, reload);
|
||||
assert.ok(navigation?.ok(), "Navigation returns a successful document");
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
if (new URL(url).pathname === "/account") {
|
||||
await page.getByRole("heading", { name: signedIn ? "你的账户" : "欢迎回来", exact: true }).waitFor();
|
||||
} else {
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
}
|
||||
await page.getByText("正在检查账户状态…", {exact: true}).waitFor({state: "hidden"});
|
||||
assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state");
|
||||
} catch (error) {
|
||||
const probeStarted=Date.now();
|
||||
@@ -125,6 +131,7 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
.waitFor();
|
||||
await navigate(page.url(), true);
|
||||
await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor();
|
||||
await page.waitForFunction(() => document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value === "不存在的文档");
|
||||
assert.equal(await search.inputValue(), "不存在的文档");
|
||||
await page
|
||||
.getByRole("button", { name: "清除所有筛选", exact: true })
|
||||
@@ -154,6 +161,29 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/account");
|
||||
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
|
||||
@@ -171,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;
|
||||
|
||||
@@ -42,7 +42,7 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
|
||||
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
|
||||
if(!external)await verifyBrandUI(base);
|
||||
await verifyEmailAccounts();
|
||||
await verifyEmailAccounts(base);
|
||||
await verifyCanvasWorkspace(base);
|
||||
if(process.env.VERIFY_CANVAS_UI==='1')await verifyCanvasAgentUI(base);
|
||||
await verifyGuidancePublishing(base);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
: "${REVIEW_SHA:?The exact review commit SHA is required}"
|
||||
[[ "$REVIEW_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Refusing invalid review SHA' >&2; exit 2; }
|
||||
# Only this workflow's disposable tags; production tags, volumes and other projects stay out of scope.
|
||||
for repository in creative-studio-review creative-wiki-tests creative-wiki-review; do
|
||||
tag="$repository:$REVIEW_SHA"
|
||||
referenced=$(docker ps -aq | xargs -r docker inspect --format '{{.Image}}')
|
||||
if ! image=$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null); then
|
||||
echo "No review image: $tag"
|
||||
continue
|
||||
fi
|
||||
if printf '%s\n' "$referenced" | grep -Fxq "$image"; then
|
||||
echo "Keeping container-referenced review image: $tag"
|
||||
continue
|
||||
fi
|
||||
# Preserve parent layers so unchanged dependency installs stay cached.
|
||||
docker image rm --no-prune "$tag"
|
||||
done
|
||||
@@ -17,7 +17,15 @@ compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${C
|
||||
cleanup(){
|
||||
code=$?
|
||||
trap - EXIT INT TERM
|
||||
if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true; fi
|
||||
if [ "$code" -ne 0 ]; then
|
||||
echo 'Runner pressure at integration failure (before disposable stack cleanup)'
|
||||
uptime || true
|
||||
free -m || true
|
||||
df -h / || true
|
||||
for resource in cpu memory io; do cat "/proc/pressure/$resource" || true; done
|
||||
timeout 10s docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} io={{.BlockIO}} pids={{.PIDs}}' || true
|
||||
compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true
|
||||
fi
|
||||
cleanup_code=0
|
||||
bash scripts/cleanup-integration.sh || cleanup_code=$?
|
||||
rm -f "$TEST_ENV"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check review cleanup scope without Docker or build output."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).with_name('cleanup-review-images.sh')
|
||||
SHA = 'a' * 40
|
||||
FAKE_DOCKER = '''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args=sys.argv[1:]
|
||||
with open(os.environ['DOCKER_CALLS'], 'a') as output:
|
||||
output.write(json.dumps(args)+'\\n')
|
||||
if args[0]=='ps':
|
||||
if os.environ.get('FAIL_INVENTORY'): sys.exit(1)
|
||||
if os.environ.get('REFERENCED'): print('container-id')
|
||||
elif args[0]=='inspect':
|
||||
print('sha256:review-image')
|
||||
elif args[:2]==['image','inspect']:
|
||||
if os.environ.get('MISSING_IMAGE'): sys.exit(1)
|
||||
print('sha256:review-image')
|
||||
'''
|
||||
|
||||
|
||||
class ReviewCleanupTests(unittest.TestCase):
|
||||
def run_cleanup(self, sha=SHA, **extra):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
docker = root / 'docker'
|
||||
docker.write_text(FAKE_DOCKER)
|
||||
docker.chmod(0o700)
|
||||
calls = root / 'calls'
|
||||
result = subprocess.run(['bash', str(SCRIPT)], capture_output=True, env={
|
||||
**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'],
|
||||
'DOCKER_CALLS': str(calls), 'REVIEW_SHA': sha, **extra})
|
||||
return result.returncode, [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
|
||||
|
||||
def test_removes_only_exact_review_tags_without_force_and_keeps_parent_cache(self):
|
||||
code, calls = self.run_cleanup()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual([args for args in calls if args[:2] == ['image', 'rm']], [
|
||||
['image', 'rm', '--no-prune', name + ':' + SHA] for name in
|
||||
['creative-studio-review', 'creative-wiki-tests', 'creative-wiki-review']])
|
||||
self.assertFalse(any('-f' in args or 'prune' in args or 'volume' in args for args in calls))
|
||||
|
||||
def test_invalid_sha_never_contacts_docker(self):
|
||||
for value in ['', 'main', 'a' * 39, 'a' * 41, 'A' * 40, '*' * 40]:
|
||||
with self.subTest(value=value):
|
||||
code, calls = self.run_cleanup(value)
|
||||
self.assertNotEqual(code, 0)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_container_referenced_images_are_kept(self):
|
||||
code, calls = self.run_cleanup(REFERENCED='1')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls))
|
||||
|
||||
def test_missing_tags_are_a_successful_noop(self):
|
||||
code, calls = self.run_cleanup(MISSING_IMAGE='1')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls))
|
||||
|
||||
def test_inventory_failure_prevents_removal(self):
|
||||
code, calls = self.run_cleanup(FAIL_INVENTORY='1')
|
||||
self.assertNotEqual(code, 0)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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'});
|
||||
|
||||
Reference in New Issue
Block a user