From d72683ebbe7454d9061a638746bfc62fe9ed1c0c Mon Sep 17 00:00:00 2001 From: huyan Date: Sat, 19 Sep 2026 23:54:38 +0800 Subject: [PATCH 1/2] feat(account): add subscription purchase page Signed-in users need a direct place to compare plans and buy credits. Add the protected subscription route, account entry points, aligned plan cards, and credits purchase controls. Keep payment and account subscription APIs out of this frontend-only delivery and explain the unavailable payment flow in a dialog. --- frontend/src/app/app.tsx | 2 + frontend/src/app/layout/app-header.tsx | 15 + frontend/src/pages/account/index.tsx | 8 +- frontend/src/pages/subscription/index.tsx | 223 +++++++++ .../src/pages/subscription/subscription.css | 431 ++++++++++++++++++ 5 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/subscription/index.tsx create mode 100644 frontend/src/pages/subscription/subscription.css diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index ff565095..8c0976bf 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { AssetLibraryPage } from '@/pages/asset-library' +import { SubscriptionPage } from '@/pages/subscription' import { AccountPage } from '@/pages/account' import { AdminRedemptionCodesPage } from '@/pages/admin-redemption-codes' import { CharacterDetailPage } from '@/pages/character-detail' @@ -73,6 +74,7 @@ export function AppRoutes() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 0e178c56..e7e3d5aa 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -455,6 +455,21 @@ export function AppHeader({ accountMenu.state, )}`} > +
+
+

Free

+

+ 当前订阅 · 标准创作 +

+
+ + 升级 + +
))} + + 订阅与充值 ↗ + + +
+ + {recharge ? ( +
+
+

积分充值

+

+ 无需开通会员。每 ¥1 获得 12.5 积分,不足一积分向上补足。 +

+
+ {amounts.map((item) => ( + + ))} +
+ + {!valid && ( +

+ 请输入不低于 5 元的整数金额。 +

+ )} +
+
+

本次将获得

+ + {valid ? credits.toLocaleString('zh-CN') : '—'} + 积分 + +
+
+ 充值金额 + {valid ? `¥${value}` : '—'} +
+
+ 当前方案 + Free +
+ + 充值只增加积分,不改变会员资格。 +
+
+ ) : ( +
+ {plans.map((plan, index) => ( +
setSelectedPlan(plan.name)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setSelectedPlan(plan.name) + } + }} + role="button" + tabIndex={0} + aria-pressed={selectedPlan === plan.name} + > +
+ {index === 1 && 最受欢迎} +
+

{plan.name}

+
+ ¥ + {plan.price} + / 月 +
+
+ {plan.credits.toLocaleString('zh-CN')} + 积分 + 省 {plan.saving} +
+ +
+

+ + 完整标准制作流程 +

+

+ + 保留已有资产与下载能力 +

+

+ + 会员专属能力筹备中 +

+
+

约 ¥{plan.unit} / 积分

+
+ ))} +
+ )} +
+
+

+ 当前订阅方案:Free +

+

Free 包含的功能

+

Free 同样支持标准制作、局部重做与透明素材导出;生成任务按实际报价消耗积分。

+
+
+

暂未开放的会员权益

+

+ 更好的模型、高质量处理、三渲二暂未开放。会员期限、积分有效期与续购规则将在开售前说明。 +

+
+
+ { + if (event.target === event.currentTarget) dialog.current?.close() + }} + aria-labelledby="purchase-title" + > + +

{selection?.name}

+
+ ¥{selection?.price} + {selection?.credits.toLocaleString('zh-CN')} 积分 +
+

支付还没有接入,请联系管理员。当前不会扣款,也不会变更积分或会员资格。

+ +
+ + ) +} diff --git a/frontend/src/pages/subscription/subscription.css b/frontend/src/pages/subscription/subscription.css new file mode 100644 index 00000000..bfc3cd65 --- /dev/null +++ b/frontend/src/pages/subscription/subscription.css @@ -0,0 +1,431 @@ +.subscription-page { + max-width: 1180px; + margin: auto; + padding: 68px 36px 70px; + color: var(--color-app-ink); +} +.subscription-toolbar, +.subscription-creditline, +.subscription-plan-head, +.subscription-button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.subscription-plan-head { + min-height: 1.125rem; +} +.subscription-heading { + display: grid; + place-items: center; + padding: 23px 0 18px; +} +.subscription-heading h1 { + font-family: var(--font-serif); + font-size: clamp(27px, 3vw, 38px); + font-weight: 500; + letter-spacing: -2px; + margin: 0; +} +.subscription-heading h1 > span { + color: var(--color-app-faint); +} +.subscription-intro { + font-size: 13px; + line-height: 1.9; + color: var(--color-app-muted); +} +.subscription-toolbar { + justify-content: center; + margin: 17px 0 14px; +} +.subscription-toolbar > span { + font-size: 11px; + color: var(--color-app-faint); +} +.subscription-tabs { + display: flex; + padding: 4px; + background: var(--color-app-surface-muted); + border: 1px solid var(--color-app-line); + border-radius: 10px; + gap: 4px; +} +.subscription-tabs button { + padding: 9px 20px; + font-size: 13px; + border-radius: 7px; + color: var(--color-app-muted); +} +.subscription-tabs button[aria-pressed='true'] { + background: var(--color-app-surface-raised); + color: var(--color-app-ink); + box-shadow: 0 1px 4px #0000000a; +} +.subscription-plans { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + align-items: stretch; +} +.subscription-plan { + border: 1px solid var(--color-app-line); + background: var(--color-app-surface); + border-radius: 14px; + padding: 25px 26px 20px; + position: relative; + cursor: pointer; + display: flex; + min-height: 500px; + flex-direction: column; + transition: + background-color 0.15s, + box-shadow 0.15s, + transform 0.15s; +} +.subscription-plan:hover { + transform: translateY(-1px); +} +.subscription-plan.selected { + background: var(--color-app-surface-raised); + box-shadow: 0 0 0 2px var(--color-app-ink); +} +.subscription-plan:focus-visible { + outline: 2px solid var(--color-app-ink); + outline-offset: 4px; +} +.subscription-recommended { + position: absolute; + top: 24px; + right: 26px; + font-size: 10px; + background: var(--color-app-ink); + color: var(--color-app-canvas); + padding: 4px 9px; + border-radius: 4px; + margin-left: auto; +} +.subscription-plan h2 { + font-family: var(--font-serif); + font-size: 30px; + letter-spacing: -1px; + margin: 17px 0 8px; +} +.subscription-price { + display: flex; + align-items: baseline; + font-size: 49px; + letter-spacing: -2px; + font-weight: 550; + margin: 25px 0 9px; + gap: 3px; +} +.subscription-price > span { + font-size: 23px; + margin-right: 3px; +} +.subscription-price small { + font-size: 12px; + letter-spacing: 0; + color: var(--color-app-faint); + font-weight: 400; + margin-left: 7px; +} +.subscription-creditline { + justify-content: flex-start; + font-size: 12px; + gap: 7px; +} +.subscription-creditline strong { + font-size: 17px; +} +.subscription-creditline em { + font-style: normal; + margin-left: auto; + font-size: 10px; + border: 1px solid var(--color-app-line); + border-radius: 4px; + padding: 3px 5px; + color: var(--color-app-muted); +} +.subscription-button { + border: 1px solid var(--color-app-line-strong); + border-radius: 8px; + width: 100%; + padding: 12px 14px; + font-size: 13px; + font-weight: 550; + margin: 24px 0; + transition: + transform 0.15s, + opacity 0.15s; + background: transparent; +} +.subscription-button.primary { + background: var(--color-app-ink); + border-color: var(--color-app-ink); + color: var(--color-app-canvas); +} +.subscription-button:hover { + transform: translateY(-1px); + opacity: 0.85; +} +.subscription-button:disabled { + opacity: 0.35; + cursor: not-allowed; + transform: none; +} +.subscription-plan-details { + border-top: 1px solid var(--color-app-line); + padding-top: 9px; + min-height: 108px; +} +.subscription-plan-details p { + display: flex; + align-items: center; + gap: 9px; + font-size: 12px; + margin-top: 13px; + color: var(--color-app-muted); +} +.subscription-plan-details p > span { + font-size: 9px; + margin-left: auto; + color: var(--color-app-faint); +} +.subscription-unit { + margin-top: auto; + padding-top: 23px; + font-size: 10px; + color: var(--color-app-faint); + font-family: monospace; +} +.subscription-footnote { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 65px; + margin-top: 35px; + padding-top: 26px; + border-top: 1px solid var(--color-app-line); +} +.subscription-footnote h3 { + font-family: var(--font-serif); + font-size: 19px; + margin: 10px 0; +} +.subscription-footnote h4 { + font-size: 12px; + margin: 5px 0 12px; +} +.subscription-footnote p { + font-size: 11px; + line-height: 1.9; + color: var(--color-app-muted); +} +.subscription-current-plan { + margin: 0 0 10px; + font-size: 14px; + color: var(--color-app-muted); +} +.subscription-current-plan strong { + color: var(--color-app-ink); + font-size: 18px; + font-weight: 650; +} +.recharge-panel { + display: grid; + grid-template-columns: 1.6fr 1fr; + border: 1px solid var(--color-app-line); + border-radius: 14px; + overflow: hidden; + background: var(--color-app-surface); +} +.recharge-options { + padding: 30px; +} +.recharge-options h2 { + font-family: var(--font-serif); + font-size: 24px; + margin: 13px 0; +} +.recharge-amounts { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin: 26px 0; +} +.recharge-amounts button { + border: 1px solid var(--color-app-line); + border-radius: 8px; + padding: 14px; + background: var(--color-app-surface-raised); +} +.recharge-amounts button[aria-pressed='true'] { + border-color: var(--color-app-ink); + box-shadow: inset 0 0 0 1px var(--color-app-ink); +} +.recharge-amounts strong { + display: block; + font-size: 22px; + font-weight: 500; +} +.recharge-amounts span { + font-size: 11px; + color: var(--color-app-muted); +} +.recharge-custom { + font-size: 12px; +} +.recharge-custom > span { + margin-left: 10px; + font-size: 10px; + color: var(--color-app-faint); +} +.recharge-custom > div { + display: flex; + align-items: center; + border: 1px solid var(--color-app-line); + border-radius: 8px; + padding: 12px; + margin-top: 10px; + gap: 10px; +} +.recharge-custom input { + width: 100%; + outline: none; + background: transparent; +} +.recharge-custom > div:focus-within { + outline: 2px solid var(--color-app-ink); + outline-offset: 2px; +} +.recharge-summary { + background: var(--color-app-surface-muted); + padding: 34px; + display: flex; + flex-direction: column; + justify-content: center; +} +.recharge-summary > p { + font-size: 12px; + margin: 18px 0 8px; + color: var(--color-app-muted); +} +.recharge-summary > strong { + font-size: 46px; + font-weight: 500; + letter-spacing: -1px; +} +.recharge-summary > strong span { + font-size: 12px; + letter-spacing: 0; + color: var(--color-app-muted); +} +.recharge-summary hr { + border-color: var(--color-app-line); + margin: 22px 0; +} +.recharge-summary > div { + display: flex; + justify-content: space-between; + font-size: 12px; + margin: 8px 0; +} +.recharge-summary small { + font-size: 10px; + color: var(--color-app-muted); + text-align: center; +} +.subscription-error { + font-size: 12px; + color: var(--color-app-danger); + margin-top: 8px; +} +.subscription-dialog { + margin: auto; + width: min(440px, calc(100vw - 32px)); + padding: 36px; + background: var(--color-app-surface-raised); + color: var(--color-app-ink); + border: 1px solid var(--color-app-line); + border-radius: 16px; +} +.subscription-dialog::backdrop { + background: #0006; + backdrop-filter: blur(5px); +} +.subscription-dialog-close { + position: absolute; + right: 16px; + top: 16px; + padding: 5px; +} +.subscription-dialog h2 { + font-size: 30px; + font-family: var(--font-serif); + margin: 20px 0; +} +.subscription-dialog-total { + font-size: 40px; + margin-bottom: 22px; + display: flex; + justify-content: space-between; + align-items: center; +} +.subscription-dialog-total span { + font-size: 14px; +} +.subscription-dialog > p:not(.subscription-eyebrow) { + font-size: 13px; + line-height: 1.8; + color: var(--color-app-muted); +} +.subscription-dialog .subscription-button { + margin-bottom: 0; + justify-content: center; +} +.subscription-page button { + cursor: pointer; +} +.subscription-page button:focus-visible, +.subscription-page a:focus-visible { + outline: 2px solid var(--color-app-ink); + outline-offset: 4px; +} +@media (max-width: 760px) { + .subscription-page { + padding: 64px 20px 45px; + } + .subscription-heading { + padding: 20px 0 16px; + } + .subscription-plans { + grid-template-columns: 1fr; + } + .subscription-plan { + padding: 24px; + } + .subscription-toolbar > span { + display: none; + } + .subscription-footnote { + grid-template-columns: 1fr; + gap: 20px; + } + .recharge-panel { + grid-template-columns: 1fr; + } + .recharge-options, + .recharge-summary { + padding: 24px; + } +} +@media (prefers-reduced-motion: reduce) { + .subscription-button { + transition: none; + } + .subscription-button:hover { + transform: none; + } +} From 6d2660f15d858896c0cfba1b3975bf5ab97cb05a Mon Sep 17 00:00:00 2001 From: huyan Date: Sat, 19 Sep 2026 23:55:29 +0800 Subject: [PATCH 2/2] test(account): cover subscription purchase flow Subscription selection and credit validation need regression coverage. Cover rounded credit calculations, invalid custom amounts, plan selection, and the payment-unavailable dialog. Keep the browser behavior explicit while no payment request is made. --- .../src/pages/subscription/index.test.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 frontend/src/pages/subscription/index.test.tsx diff --git a/frontend/src/pages/subscription/index.test.tsx b/frontend/src/pages/subscription/index.test.tsx new file mode 100644 index 00000000..53148899 --- /dev/null +++ b/frontend/src/pages/subscription/index.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' +import { SubscriptionPage } from './index' + +afterEach(cleanup) + +it('rounds custom credits up and rejects fractional or insufficient payments', () => { + render( + + + , + ) + const input = screen.getByLabelText('自定义充值金额') + fireEvent.change(input, { target: { value: '7' } }) + expect(screen.getByText('88')).toBeTruthy() + const buy = screen.getByRole('button', { name: '充值积分' }) as HTMLButtonElement + expect(buy.disabled).toBe(false) + for (const value of ['4', '5.5', '', 'abc']) { + fireEvent.change(input, { target: { value } }) + expect(buy.disabled).toBe(true) + expect(screen.getByRole('alert')).toBeTruthy() + } +}) + +it('highlights a selected plan and explains that payment is not connected', () => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', '') + } + render( + + + , + ) + fireEvent.click(screen.getByText('Pro')) + expect(screen.getByText('Pro').closest('article')?.className).toContain('selected') + + fireEvent.click(screen.getByRole('button', { name: '已选择' })) + const dialog = screen.getByRole('dialog') + expect(dialog.textContent).toContain('¥99') + expect(dialog.textContent).toContain('1,500 积分') + expect(dialog.textContent).toContain('支付还没有接入,请联系管理员') + expect(dialog.textContent).toContain('不会扣款') + expect(screen.getByText('当前订阅方案:')).toBeTruthy() +})