From 74e3c818c56efe32b1cd5601167c1abf0925c4ed Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 03:51:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20Wave=201=20part1=20=E2=80=94=20?= =?UTF-8?q?=E6=B5=AE=E5=B1=82=E8=BF=9B=E5=9C=BA=E5=8A=A8=E7=94=BB/ESC=20+?= =?UTF-8?q?=20Toast=20=E5=BD=92=E6=AD=A3=20+=20=E8=B4=A6=E6=88=B7=E8=A1=A8?= =?UTF-8?q?=E6=A8=AA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/frontend/src/account-page.css | 4 +- core/frontend/src/components/app-shell.tsx | 14 +++-- core/frontend/src/components/overlays.tsx | 58 +++++++++++++++----- core/frontend/src/routes/auth-screen.tsx | 9 +++- core/qa/visual-parity/_wave1-smoke.mjs | 61 ++++++++++++++++++++++ docs/todo/还原性能落地-进度.md | 19 ++++++- 6 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 core/qa/visual-parity/_wave1-smoke.mjs diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index 0a3a1c2..b0f6b01 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -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; } diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 5c16bd8..429368c 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -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 }) { + // 右下角浮层:统一用设计系统 .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 ( -
-
+
+
{notice.text}[ {notice.type.toUpperCase()} ]
); diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index d208a65..ccff651 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -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( -
+
event.stopPropagation()}> ++
{icon}
{title}{subtitle}
@@ -106,10 +137,11 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// onConfirm: () => void | Promise; }) { useBodyScrollLock(open); - if (!open) return null; + const { mounted, show } = useOverlayTransition(open, onCancel); + if (!mounted) return null; // 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏 return createPortal( -
+
event.stopPropagation()}> ++
{icon ?? }
{title}{subtitle}
@@ -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( -
+
event.stopPropagation()}> ++
{title}// SUCCESS
@@ -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( <> -
-
- {toast &&
{toast.title}
// {toast.sub}
} + {toast && ( +
+
+
{toast.title}// {toast.sub}
+
+ )} ); } diff --git a/core/qa/visual-parity/_wave1-smoke.mjs b/core/qa/visual-parity/_wave1-smoke.mjs new file mode 100644 index 0000000..c6021dd --- /dev/null +++ b/core/qa/visual-parity/_wave1-smoke.mjs @@ -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)); diff --git a/docs/todo/还原性能落地-进度.md b/docs/todo/还原性能落地-进度.md index db86268..11c207c 100644 --- a/docs/todo/还原性能落地-进度.md +++ b/docs/todo/还原性能落地-进度.md @@ -31,4 +31,21 @@ ## Wave 1 · 系统性 CSS/组件 (S1-S9) — ⏳ 进行中 -(待补) +> 为护住共享 CSS,拆两段做:**part 1 = 浮层/反馈(S3/S4/S9)**,part 2 = token/布局(S1/S2/S6/S7/S8)。 + +### part 1 — ✅ 完成 (2026-06-19) + +| # | 项 | 状态 | 改动 / 证据 | +|---|---|---|---| +| S4 | 弹窗进场动画 + ESC | ✅ | `overlays.tsx` 新增共享 `useOverlayTransition(open, close)`:挂载→下一帧上 `.show`(触发 `.modal scale(.96→1)` / `.drawer translateX` CSS 过渡)→关闭先撤 `.show` 播退场再卸载;统一接管 ESC。应用到 TeamModal/ConfirmModal/SuccessModal/Drawer。EmptyPanel 补 `.ic-empty` 灰 icon(Inbox)。 | +| S3 | Toast 体系归正 | ✅ | `ToastLike`(app-shell)改渲染设计系统 `.toast`(右下、单橙 `.ic-t`、`--shadow-floating`、挂载滑入),**不再随 success/error 变绿/红边**(单一 accent 铁律);icon 按类型 Check/AlertCircle/Info,盒仍橙。`auth-screen` 旧 `.login-toast` → `.toast`。旧 `inline-toast`/`login-toast` 渲染处全部切走(CSS 残留留到 part 2 的 styles.css 治理一并清)。 | +| S9 | 表格横线 | ✅ | `account-page.css` 删 `.billing-table tbody td { border-bottom }` + last-child 规则。**逐页对照 V1 account.html 确认**:V1 该表 `td border-bottom:0`、只有 thead 分隔 + 行 hover 底色,React 原先多画的行线是偏离,删后对齐 V1。 | + +**part 1 验证:** `tsc --noEmit` 0;交互走查(playwright)`auth-toast`:`.toast.show` 出现、文案对、旧 `.login-toast` 计数 0;`team-modal`:`#open-invite` 打开 `.modal-bg.show`(scale 落定=进场动画跑过)、**ESC 成功关闭并卸载**;14 路由回归 boot 全过 0 报错、登录 643ms。截图:`_qa_shots/wave1/*.png`。 + +### part 2 — ⏳ 待做(下一拍) +- **S1** styles.css 旧暖米 token 孤儿类迁入 design-restraint.css(改前做孤儿分析,护住 shell);顺手清 part1 留下的 `inline-toast`/`login-toast` 死 CSS。 +- **S2** 抽 `.mono-10/11/12` 工具类(逐页应用并入 Wave 4 P2 扫)。 +- **S6** 把 `.bulk-bar`/`.card-check` 从 products-page.css 提到 design-restraint.css(变共享,projects/library 的接线在 Wave 3)。 +- **S7** 扩 `pager.tsx` 富分页器(条数循环 12/24/48/96 + 跳页 + 吸底)+ CSS;**Wave 0 暂缓的 products/projects 服务端分页在此合并**。 +- **S8** hero 大数字字重 —— ⚠️ 计划写 600→700,但 design.md §2.5/§2.6 铁律「字重仅 400/500/600,700 仅 Ctrl-K 徽标」。part 2 逐页核 V1 实际字重,**上限封 600**,不引入 700(铁律优先于报告 S8)。