From c3bcddb652a86221eb80d4f64983590df2994480 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 06:35:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=3Fplan=3D=20=E6=B7=B1?= =?UTF-8?q?=E9=93=BE=E7=94=A8=E4=B8=80=E5=8F=A5=E6=96=87=E6=A1=88=E6=8C=87?= =?UTF-8?q?=E5=90=91=E7=9B=AE=E6=A0=87=E5=8D=A1=EF=BC=8C=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E7=AC=AC=E4=B8=89=E5=B1=82=E6=8F=8F=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D13 把 URL 高亮改成纯滚动定位后留下一个空档:桌面上三张套餐卡本来就都在 视口内,滚动等于什么都没指。付费转化路径上,用户从「开通年度会员」的链接 进来,看不到任何东西指向那张卡。 产品在「加一行文案 / 维持现状 / 把描边加回来」里选了第一个。 用卡片自己的套餐名,不用序数——手机端 [data-plan-alias="monthly"] 带 order:-1,序数会指错。新增断言锁住这两点(必须有 note、不得出现序数写法)。 测试 3354 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static; 干净构建 CSS gzip 39,724→39,733。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- frontend/DESIGN.md | 1 + frontend/src/app/globals.css | 6 ++++++ frontend/src/components/billing-panel.tsx | 12 ++++++++++++ frontend/tests/billing-panel.test.ts | 7 +++++++ 4 files changed, 26 insertions(+) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 35541a43..9a3c9b56 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -488,6 +488,7 @@ page. Three parts now, in reading order: - **Structure:** summary line (balance · membership / expiry), then tabs 会员套餐 / 点数包 / 兑换码 / 订单记录. - **Plan card states:** exactly two, and they are mutually exclusive. `.is-current` (当前套餐) draws the `--color-action` border; `.is-recommended` (推荐, the monthly plan) draws the `--color-border-strong` border on the plain canvas. A card that is both takes 当前套餐 — its own action is 续费, not 立即购买 — so borders and fills can never stack. The badge in the card header says the same word. Mobile ordering (月卡 first below 640px) keys off `[data-plan-alias="monthly"]`, i.e. the plan's identity, not its state, so it survives the card becoming the current plan. +- **`?plan=` deep link:** names its target in copy, not in a third border layer. A `.membership-plan-note` line above the grid reads 「你从「年度会员」的链接过来的,就在下面。」, and the card takes `ref` / `tabIndex` / `aria-current` for the scroll and focus target only. It names the plan by the card's own title rather than a position: below 640px the monthly card takes `order: -1`, so an ordinal would point at the wrong card. Scrolling alone is not wayfinding here — at desktop widths all three cards are already in view, so a link that only scrolled pointed at nothing. - **Actions:** recommended-plan purchase is `Button` default. Other plan and credit buys use `Button` outline. Cashier still opens with `window.open` in a new tab. Paying copy is 跳转收银台…; pending copy is 等待支付结果. Redeem is an inline form on its tab, not a nested dialog. - **Deep links:** `/?settings=billing&source=…[&plan=…][&tab=…]` open this pane on `/` and are then stripped. `/membership` and `/membership/orders` redirect here. `&plan=` does **not** repaint the card (that used to be a third border plus an outline on top of the two states); it scrolls the card into view and focuses it, and nothing else. - **Waiting:** the summary renders from the in-memory account. Package cards stay empty until `/api/payment/packages` arrives. No spinner, skeleton, or 「正在加载」. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4276f480..fa7d4b58 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4308,6 +4308,12 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } borders and backgrounds can never stack the way the three old BEM modifiers (recommended / current / URL highlight) did. Plan order on mobile is keyed off the plan identity below, not off a state, so it survives the card becoming 当前套餐. */ +.membership-plan-note { + margin: 0 0 var(--space-3); + color: var(--color-ink-secondary); + font-size: var(--type-caption); + line-height: 1.55; +} .membership-plan-card.is-recommended { border-color: var(--color-border-strong); background: var(--color-canvas); } .membership-plan-card.is-current { border-color: var(--color-action); } .membership-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); } diff --git a/frontend/src/components/billing-panel.tsx b/frontend/src/components/billing-panel.tsx index a46a1e38..6c6741db 100644 --- a/frontend/src/components/billing-panel.tsx +++ b/frontend/src/components/billing-panel.tsx @@ -62,6 +62,11 @@ export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab setRedeemMessage, } = billing; const plans = selectMembershipPlans(paymentPackages); + /* Named by the card's own title, not by position: on phones the monthly card + takes `order: -1`, so an ordinal would point at the wrong card. */ + const highlightedName = highlightedPlan + ? plans.find((product) => planAlias(product) === highlightedPlan)?.name ?? null + : null; const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack"); const credits = account?.credits ?? seedAccount.credits; @@ -119,6 +124,13 @@ export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab {packagesSettled && !packagesError && !paymentEnabled &&

在线支付暂未开放。

} + {/* A ?plan= deep link no longer tints or outlines the card it points at + (D13: one card, one state). On desktop all three cards are visible + anyway, so scrolling alone pointed at nothing — this line is the + wayfinding, in copy instead of a third border layer. */} + {highlightedName && ( +

你从「{highlightedName}」的链接过来的,就在下面。

+ )} {plans.length > 0 && (
{plans.map((product) => { diff --git a/frontend/tests/billing-panel.test.ts b/frontend/tests/billing-panel.test.ts index 8bb2fc6d..8e7717dd 100644 --- a/frontend/tests/billing-panel.test.ts +++ b/frontend/tests/billing-panel.test.ts @@ -216,6 +216,13 @@ test("plan param forces the membership segment and highlights the matching card" assert.match(panelSource, /ref=\{highlighted \? highlightedCardRef : undefined\}/); assert.match(panelSource, /aria-current=\{highlighted \? "true" : undefined\}/); assert.match(panelSource, /highlightedCardRef/); + // 滚动定位本身在桌面上指不到任何东西——三张卡本来就都在视口里。所以深链必须 + // 另有一句文字说明它指向哪张卡,且必须用卡片自己的名字而不是序数:手机端 + // `[data-plan-alias="monthly"]` 带 order:-1,序数会指错。 + assert.match(panelSource, /const highlightedName = highlightedPlan/); + assert.match(panelSource, /plans\.find\(\(product\) => planAlias\(product\) === highlightedPlan\)\?\.name/); + assert.match(panelSource, /className="membership-plan-note"/); + assert.doesNotMatch(panelSource, /第 \{[\s\S]{0,120}\} 张/); assert.match(hookSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/); assert.match(hookSource, /card\.focus\(\{ preventScroll: true \}\)/); assert.match(hookSource, /initialTab: input\.highlightedPlan \? "membership" : input\.initialTab/);