feat(core): Wave 1 part1 — 浮层进场动画/ESC + Toast 归正 + 账户表横线

S4 浮层: overlays.tsx 新增共享 useOverlayTransition(挂载→下一帧上 .show 触发
  scale(.96→1)/translateX 进场过渡;关闭先撤 .show 播退场再卸载)+ 统一 ESC 关闭,
  应用 TeamModal/ConfirmModal/SuccessModal/Drawer;EmptyPanel 补 .ic-empty 灰 icon
S3 Toast: ToastLike 改用设计系统 .toast(右下/单橙 ic-t/shadow-floating/滑入),
  不再随 success/error 变绿红边;auth login-toast → .toast
S9 表格: account-page.css 删 tbody td border-bottom(对照 V1 account.html 确认应无行线)

验证: tsc 0 · 交互走查 toast出现+ESC关弹窗 · 14 路由回归 boot 0 报错 · 登录 643ms

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 03:51:30 +08:00
co-authored by Claude Opus 4.8
parent 8a0ae6a19d
commit 74e3c818c5
6 changed files with 144 additions and 21 deletions
+1 -3
View File
@@ -129,9 +129,7 @@
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
.billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 13px; border-bottom: 0; }
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
/* 行间淡分隔线,长流水更易扫读;最后一行不画 */
.billing-table tbody td { border-bottom: 1px solid var(--border-faint); }
.billing-table tbody tr:last-child td { border-bottom: 0; }
/* 对齐 V1:tbody 不画行间横线(违反 inside-border 铁律),只靠 thead 分隔 + 行 hover 底色区分 */
.billing-table tbody tr:hover { background: var(--background-lighter); }
.billing-table .ts { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
+11 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { AlertCircle, Check, Info } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import type { Product, Project, Team, User } from "../types";
@@ -261,9 +261,17 @@ export function CornerMarks() {
}
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
// 右下角浮层:统一用设计系统 .toast(单橙 icon-box + --shadow-floating),不再随
// success/error 变绿/红边(单一 accent 铁律)。挂载后下一帧上 .show 触发滑入进场。
const [show, setShow] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}, []);
const Icon = notice.type === "error" ? AlertCircle : notice.type === "info" ? Info : Check;
return (
<div className={`inline-toast ${notice.type}`}>
<div className="ic-t"><Check size={12} /></div>
<div className={`toast${show ? " show" : ""}`} role="status" aria-live="polite">
<div className="ic-t"><Icon size={13} /></div>
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
</div>
);
+46 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { CheckCircle2, Music, Shield, Trash2, X } from "lucide-react";
import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react";
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
// 多浮层叠开用计数,最后一个关闭才解锁。
@@ -17,6 +17,36 @@ export function useBodyScrollLock(active: boolean) {
}, [active]);
}
// 浮层进出场过渡 + ESC 统一接管。
// 进场:挂载后「下一帧」再上 .show,让 CSS 的 scale(.96→1)/translateX 过渡真正跑起来
// (此前直接挂 .show,首帧即终态 → 没有进场动画)。
// 退场:先撤 .show 播退场过渡,等过渡时长后再卸载。
// ESC:open 时监听 Escape → onClose(对齐 MediaLightbox 既有行为)。
export function useOverlayTransition(open: boolean, onClose?: () => void, durationMs = 260) {
const [mounted, setMounted] = useState(open);
const [show, setShow] = useState(false);
useEffect(() => {
let raf1 = 0;
let raf2 = 0;
let timer = 0;
if (open) {
setMounted(true);
raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setShow(true)); });
} else {
setShow(false);
timer = window.setTimeout(() => setMounted(false), durationMs);
}
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); window.clearTimeout(timer); };
}, [open, durationMs]);
useEffect(() => {
if (!open || !onClose) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
return { mounted, show };
}
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
@@ -80,10 +110,11 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
footer?: ReactNode;
}) {
useBodyScrollLock(open);
if (!open) return null;
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={close}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
@@ -106,10 +137,11 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "//
onConfirm: () => void | Promise<unknown>;
}) {
useBodyScrollLock(open);
if (!open) return null;
const { mounted, show } = useOverlayTransition(open, onCancel);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<div className="modal-bg show" onClick={onCancel}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={onCancel}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}<span>{subtitle}</span></div></div>
@@ -130,9 +162,10 @@ export function SuccessModal({ open, title, detail, actions, close }: {
close: () => void;
}) {
useBodyScrollLock(open);
if (!open) return null;
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
return createPortal(
<div className="modal-bg show" onClick={close}>
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}<span>// SUCCESS</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
@@ -146,12 +179,13 @@ export function SuccessModal({ open, title, detail, actions, close }: {
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
useBodyScrollLock(open);
if (!open) return null;
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<>
<div className="drawer-bg show" onClick={close} />
<aside className="drawer show">
<div className={`drawer-bg${show ? " show" : ""}`} onClick={close} />
<aside className={`drawer${show ? " show" : ""}`}>
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="drawer-b">{children}</div>
</aside>
@@ -161,5 +195,5 @@ export function Drawer({ title, open, close, children }: { title: string; open:
}
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
return <div className="empty-state show"><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
}
+7 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Eye, EyeOff, LockKeyhole, UserRound } from "lucide-react";
import { Eye, EyeOff, Info, LockKeyhole, UserRound } from "lucide-react";
import { api, getRemember, setRemember as persistRemember } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
@@ -173,7 +173,12 @@ export function AuthScreen({
</button>
</div>
</div>
{toast && <div className="login-toast"><div>{toast.title}</div><span>// {toast.sub}</span></div>}
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
</div>
)}
</main>
);
}
+61
View File
@@ -0,0 +1,61 @@
// Wave 1 part-1 interaction smoke (scratch): S3 toast归正 + S4 modal ESC/进场动画.
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5173";
const TOKEN = process.env.TOKEN || "";
const OUT = path.resolve(process.env.OUT || "../../../_qa_shots/wave1");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const result = {};
// 1) S3 — 登录页「忘记密码」应弹设计系统 .toast(右下),不再是旧 .login-toast
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto(BASE + "/login", { waitUntil: "load" });
await p.waitForTimeout(400);
await p.locator("a", { hasText: "忘记密码" }).first().click();
await p.waitForTimeout(500);
result.authToast = {
toastShown: await p.locator(".toast.show").count(),
toastText: await p.locator(".toast .txt").innerText().catch(() => ""),
legacyLoginToast: await p.locator(".login-toast").count(),
};
await p.screenshot({ path: path.join(OUT, "auth-toast.png") });
await ctx.close();
}
// 2) S4 — 团队页「创建账户」弹窗:打开有 .show / ESC 能关
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const seed = await ctx.newPage();
await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" });
await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN);
await seed.close();
const p = await ctx.newPage();
await p.goto(BASE + "/team", { waitUntil: "load" });
await p.waitForTimeout(1200);
if (await p.locator("#open-invite").count()) {
await p.click("#open-invite");
await p.waitForTimeout(450); // 让进场动画跑完
const shownOnOpen = await p.locator(".modal-bg.show").count();
const modalTransform = await p.locator(".modal-bg.show .modal").first()
.evaluate((el) => getComputedStyle(el).transform).catch(() => "n/a");
await p.screenshot({ path: path.join(OUT, "team-modal-open.png") });
await p.keyboard.press("Escape");
await p.waitForTimeout(500); // 退场动画 + 卸载
const modalGone = (await p.locator(".modal-bg").count()) === 0;
await p.screenshot({ path: path.join(OUT, "team-modal-after-esc.png") });
result.teamModal = { opened: true, shownOnOpen, modalTransform, closedByEsc: modalGone };
} else {
result.teamModal = { opened: false, note: "#open-invite not found" };
}
await ctx.close();
}
await browser.close();
console.log(JSON.stringify(result, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(result, null, 2));