From 9ba3aec02604c46cdd69582d6a630c1708029728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 12:12:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E5=91=98=E4=B8=8E?= =?UTF-8?q?=E6=B3=A5=E7=82=B9=E5=85=B1=E4=BA=AB=E5=BC=B9=E5=B1=82=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E7=89=88=E9=92=B1=E5=8C=85=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlatformProfileRechargeModal 重写为「会员订阅 / 泥点充值」双 Tab,含三池概览、套餐卡、周期切换、会员状态、套餐对比表与确认订单页 - 新增 membershipCopy 收敛数量估算、推荐标签、模型权限与并发展示文案,价格与权益仍读后端目录 - 新增 membershipProduct 提供由档位与周期拼出的会员 productId,避免与组件混导出 - PlatformMudPointWalletEntry 入口改为「可用总额 + 行内月度/永久 + 会员与泥点」,使用详情与兑换码保留为次级入口 - 同步改写共享弹层测试,覆盖双 Tab、升级报价与确认页 Co-authored-by: Junie --- .../PlatformMudPointWalletEntry/index.tsx | 30 +- .../index.test.tsx | 287 ++++- .../PlatformProfileRechargeModal/index.tsx | 1106 +++++++++++++++-- .../membershipCopy.ts | 89 ++ .../membershipProduct.ts | 15 + 5 files changed, 1390 insertions(+), 137 deletions(-) create mode 100644 packages/shared/src/components/PlatformProfileRechargeModal/membershipCopy.ts create mode 100644 packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 82b0d51e7..5d8dd7e16 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -88,6 +88,14 @@ export function PlatformMudPointWalletEntry({ displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`; const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt); + const monthlyInlineLabel = formatMudPointCount( + breakdown?.monthlyPoints ?? 0, + true, + ); + const permanentInlineLabel = formatMudPointCount( + breakdown?.permanentPoints ?? 0, + true, + ); const cancelPendingClose = useCallback(() => { if (closeTimerRef.current !== null) { @@ -192,7 +200,9 @@ export function PlatformMudPointWalletEntry({ - 充值 + 会员与泥点 {onRedeemCode ? ( <> @@ -258,13 +276,13 @@ export function PlatformMudPointWalletEntry({ {isOpen ? (
- 泥点 {exactBalanceLabel} + 可用总额 {exactBalanceLabel}
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 1654e6221..1e14639b5 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -25,6 +25,26 @@ function buildNormalMembership() { }; } +function buildActiveMembership( + plan: 'plus' | 'max', + cycleKind: 'monthly' | 'yearly', +) { + return { + status: 'active' as const, + plan, + startedAt: '2026-01-01T00:00:00+08:00', + expiresAt: '2027-01-01T00:00:00+08:00', + updatedAt: '2026-04-01T00:00:00+08:00', + cycleStartedAt: '2026-03-20T09:00:00+08:00', + cycleResetsAt: '2026-04-20T09:00:00+08:00', + cycleGrantedPoints: 1_150, + cycleRemainingPoints: 615, + cycleIndex: 4, + cycleCount: 12, + cycleKind, + }; +} + /** 三池余额 fixture:每日免费 / 月度 / 永久。 */ function buildMudPointBalance(totalPoints: number) { return { @@ -54,7 +74,7 @@ function buildPointProduct( }; } -/// 六档充点商品:¥1 = 10 泥点,没有首充赠送。 +// 六档充点商品:¥1 = 10 泥点,没有首充赠送。 function buildPointProducts(): ProfileRechargeProduct[] { return [60, 180, 300, 680, 1_280, 3_280].map((pointsAmount) => buildPointProduct({ @@ -67,20 +87,44 @@ function buildPointProducts(): ProfileRechargeProduct[] { ); } -function buildMembershipPlan() { - return { - plan: 'starter' as const, - title: 'Starter', - rank: 1, - monthPriceCents: 3_900, - yearPriceCents: 39_000, - periodPoints: 400, - modelAccess: 'basic' as const, - concurrentJobLimit: 1, - sortOrder: 10, - enabled: true, - }; -} +const STARTER_PLAN = { + plan: 'starter' as const, + title: 'Starter', + rank: 1, + monthPriceCents: 3_900, + yearPriceCents: 39_000, + periodPoints: 400, + modelAccess: 'basic' as const, + concurrentJobLimit: 1, + sortOrder: 10, + enabled: true, +}; + +const PLUS_PLAN = { + ...STARTER_PLAN, + plan: 'plus' as const, + title: 'Plus', + rank: 2, + monthPriceCents: 9_900, + yearPriceCents: 99_000, + periodPoints: 1_150, + modelAccess: 'full' as const, + concurrentJobLimit: 3, + sortOrder: 20, +}; + +const MAX_PLAN = { + ...STARTER_PLAN, + plan: 'max' as const, + title: 'Max', + rank: 4, + monthPriceCents: 69_900, + yearPriceCents: 699_000, + periodPoints: 8_650, + modelAccess: 'full' as const, + concurrentJobLimit: 128, + sortOrder: 40, +}; vi.mock('qrcode', () => ({ default: { @@ -89,7 +133,134 @@ vi.mock('qrcode', () => ({ })); describe('PlatformProfileRechargeModal', () => { - test('renders the six point products and forwards the selected buy action', async () => { + test('renders the membership plans and forwards a first-time purchase', async () => { + const user = userEvent.setup(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '会员与泥点' })).toBeTruthy(); + expect(screen.getByText('可用总额 29 泥点')).toBeTruthy(); + const tablist = screen.getByRole('tablist', { name: '会员与泥点' }); + expect( + within(tablist) + .getByRole('tab', { name: '会员订阅' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); + expect(screen.getAllByText('Plus').length).toBeGreaterThan(0); + + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('¥39')).toBeTruthy(); + expect(within(checkout).getByText('400(首期)')).toBeTruthy(); + expect(within(checkout).getByText('微信支付')).toBeTruthy(); + expect(within(checkout).queryByText(/报价已失效/)).toBeNull(); + expect(within(checkout).queryByText(/倒计时/)).toBeNull(); + + await user.click( + within(checkout).getByRole('button', { name: '支付 ¥39' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith( + 'starter', + 'monthly', + ); + }); + + test('requests the server quote and shows the upgrade amount', async () => { + const user = userEvent.setup(); + const onRequestMembershipQuote = vi.fn(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Plus 年付 当前套餐' }), + ).toHaveProperty('disabled', true); + expect(screen.getByText('当前 Plus · 年付')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + + expect(onRequestMembershipQuote).toHaveBeenCalledWith('max'); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + expect(within(checkout).getByText('¥100')).toBeTruthy(); + expect(within(checkout).getByText('本期补发月度泥点')).toBeTruthy(); + + await user.click( + within(checkout).getByRole('button', { name: '补差升级 ¥100' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith('max', 'yearly'); + }); + + test('recharge tab picks a point product and forwards the buy action', async () => { const user = userEvent.setup(); const onBuy = vi.fn(); @@ -99,7 +270,7 @@ describe('PlatformProfileRechargeModal', () => { mudPointBalance: buildMudPointBalance(29), membership: buildNormalMembership(), pointProducts: buildPointProducts(), - membershipPlans: [buildMembershipPlan()], + membershipPlans: [STARTER_PLAN], latestOrder: null, hasPointsRecharged: false, }} @@ -107,58 +278,50 @@ describe('PlatformProfileRechargeModal', () => { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={onBuy} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); - const dialog = screen.getByRole('dialog', { name: '购买更多泥点' }); - expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '300泥点 ¥30 购买', - }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '680泥点 ¥68 购买', - }), - ).toBeTruthy(); - expect(within(dialog).queryAllByText('首充加赠')).toHaveLength(0); - expect( - within(dialog).getByRole('button', { name: '1,280泥点 ¥128 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '3,280泥点 ¥328 购买' }), - ).toBeTruthy(); - expect(within(dialog).queryByRole('tablist')).toBeNull(); - expect(within(dialog).queryByText('Starter')).toBeNull(); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + const products = screen.getByRole('radiogroup', { name: '泥点充值档位' }); await user.click( - within(dialog).getByRole('button', { name: '180泥点 ¥18 购买' }), - ); - expect(onBuy).toHaveBeenCalledWith( - expect.objectContaining({ productId: 'points_180' }), + within(products).getByRole('radio', { name: '60泥点 ¥6' }), ); + + await user.click(screen.getByRole('button', { name: '充值 ¥6' })); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('60 永久泥点')).toBeTruthy(); + + await user.click(within(checkout).getByRole('button', { name: '充值 ¥6' })); + + expect(onBuy).toHaveBeenCalledTimes(1); + expect(onBuy.mock.calls[0][0]).toMatchObject({ + productId: 'points_60', + priceCents: 600, + }); }); - test('shows the point-product empty state without exposing membership purchase', () => { + test('shows the empty state when the recharge tab has no point product', async () => { + const user = userEvent.setup(); + render( { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); - expect(screen.queryByText('Starter')).toBeNull(); - expect(screen.queryByRole('tablist')).toBeNull(); }); test('opens native payment QR code in a separate dialog', async () => { @@ -197,6 +366,9 @@ describe('PlatformProfileRechargeModal', () => { isLoading={false} error={null} submittingProductId={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} nativePayment={{ orderId: 'rcg_native_001', productTitle: '100泥点', @@ -209,13 +381,15 @@ describe('PlatformProfileRechargeModal', () => { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={onCloseNativePayment} />, ); const rechargeDialog = screen.getByRole('dialog', { - name: '购买更多泥点', + name: '会员与泥点', }); const paymentDialog = screen.getByRole('dialog', { name: '微信扫码支付', @@ -264,6 +438,9 @@ describe('PlatformProfileRechargeModal', () => { isLoading={false} error={null} submittingProductId={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} nativePayment={{ orderId: 'rcg_native_expired', productTitle: '100泥点', @@ -275,6 +452,8 @@ describe('PlatformProfileRechargeModal', () => { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={vi.fn()} />, diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 24c6c8f7b..d337dbdf1 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -2,17 +2,35 @@ import './index.css'; import QRCode from 'qrcode'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { + ProfileMembership, + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipPlanRecord, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeProduct, } from '../../contracts/runtime'; import { formatMudPointCount, formatRechargePrice } from '../../utils/format'; -import { resolveProfileMudPointDisplayTotal } from '../../utils/mudPoints'; +import { + formatMudPointResetAt, + resolveProfileMudPointDisplayTotal, +} from '../../utils/mudPoints'; +import { + RECOMMENDED_MEMBERSHIP_PLAN, + resolveMembershipConcurrencyLabel, + resolveMembershipConcurrencyShortLabel, + resolveMembershipModelAccessLabel, + resolveMembershipPlanCopy, +} from './membershipCopy'; +import { resolveMembershipProductId } from './membershipProduct'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; +type PlatformProfileRechargeTab = 'subscription' | 'recharge'; + export type PlatformProfileRechargeNativePaymentState = { orderId: string; productTitle: string; @@ -23,15 +41,40 @@ export type PlatformProfileRechargeNativePaymentState = { confirmMessage?: string; }; +/** 弹层内的结算目标:泥点商品直接带商品行,会员只有「档位 + 周期」。 */ +export type PlatformProfileRechargeCheckoutTarget = + | { + kind: 'points'; + product: ProfileRechargeProduct; + } + | { + kind: 'membership'; + plan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + mode: 'purchase' | 'upgrade'; + }; + export type PlatformProfileRechargeModalProps = { center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; submittingProductId: string | null; nativePayment: PlatformProfileRechargeNativePaymentState | null; + /** 升级报价;仅升级确认页会用到,首次购买不走报价接口。 */ + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; onClose: () => void; onRetry: () => void; + /** 泥点商品下单(确认页「充值」触发)。 */ onBuy: (product: ProfileRechargeProduct) => void; + /** 升级前取服务端报价;只有需要补差的档位才会调用。 */ + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + /** 会员下单;productId 由档位与周期拼出,复用泥点下单接口。 */ + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; onConfirmNativePayment: () => void; onCloseNativePayment: () => void; }; @@ -128,16 +171,19 @@ function PlatformRechargeActionButton({ children, className, disabled, + ariaLabel, onClick, }: { children: ReactNode; className?: string; disabled?: boolean; + ariaLabel?: string; onClick?: () => void; }) { return ( +
+ {pools.map((pool) => ( +
+
+ {pool.label} +
+
+ {formatMudPointCount(pool.points)} +
+
+ {pool.detail} +
+
+ ))} +
+ {dailyFreeResetsAt ? ( +

+ 创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '} + 重置每日免费额度。 +

+ ) : null} +
+ ); +} + +function PlatformRechargeTabNav({ + activeTab, + onChange, +}: { + activeTab: PlatformProfileRechargeTab; + onChange: (tab: PlatformProfileRechargeTab) => void; +}) { + const tabs: Array<{ id: PlatformProfileRechargeTab; label: string }> = [ + { id: 'subscription', label: '会员订阅' }, + { id: 'recharge', label: '泥点充值' }, + ]; + + return ( +
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + return; + } + event.preventDefault(); + const currentIndex = tabs.findIndex((tab) => tab.id === activeTab); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? tabs.length - 1 + : (currentIndex + + (event.key === 'ArrowRight' ? 1 : -1) + + tabs.length) % + tabs.length; + const nextTab = tabs[nextIndex]; + if (nextTab) { + onChange(nextTab.id); + } + }} + > + {tabs.map((tab) => { + const selected = tab.id === activeTab; + return ( + + ); + })} +
+ ); +} + +function PlatformMembershipStatusCard({ + membership, + currentPlan, +}: { + membership: ProfileMembership; + currentPlan: ProfileMembershipPlanRecord | null; +}) { + const planTitle = currentPlan?.title ?? '会员'; + const cycleLabel = membership.cycleKind === 'yearly' ? '年付' : '月付'; + const expiresAt = formatMudPointResetAt(membership.expiresAt); + const cycleIndexLabel = `${Math.max(1, membership.cycleIndex)}/${Math.max( + 1, + membership.cycleCount, + )}`; + + return ( +
+
+
+ 当前 {planTitle} · {cycleLabel} +
+
+ {expiresAt ? `会员至 ${expiresAt}` : '会员有效期读取中'} + {membership.cycleKind === 'yearly' + ? ` · 第 ${cycleIndexLabel} 期` + : ''} +
+
+
+ 可同周期补差升档 +
+ 周期切换需等到期后 +
+
+ ); +} + +function PlatformMembershipCycleSwitch({ + cycleKind, + locked, + onChange, +}: { + cycleKind: ProfileMembershipCycleKind; + locked: boolean; + onChange: (cycleKind: ProfileMembershipCycleKind) => void; +}) { + const options: Array<{ id: ProfileMembershipCycleKind; label: string }> = [ + { id: 'monthly', label: '月付' }, + { id: 'yearly', label: '年付' }, + ]; + + return ( +
+
+ {options.map((option) => { + const selected = option.id === cycleKind; + return ( + + ); + })} +
+ {locked ? ( + + 有效期内不支持月年互换 + + ) : null} +
+ ); +} + +function PlatformMembershipPlanCard({ + plan, + cycleKind, + isCurrentPlan, + recommended, + actionLabel, + disabled, + submitting, + onCheckout, +}: { + plan: ProfileMembershipPlanRecord; + cycleKind: ProfileMembershipCycleKind; + isCurrentPlan: boolean; + recommended: boolean; + actionLabel: string; + disabled: boolean; + submitting: boolean; + onCheckout: () => void; +}) { + const copy = resolveMembershipPlanCopy(plan.plan); + const annual = cycleKind === 'yearly'; + const listPriceCents = annual ? plan.yearPriceCents : plan.monthPriceCents; + const perMonthCents = annual + ? Math.round(plan.yearPriceCents / 12) + : plan.monthPriceCents; + const savingCents = annual + ? Math.max(0, plan.monthPriceCents * 12 - plan.yearPriceCents) + : 0; + + return ( +
+ {recommended ? ( + + 推荐之选 + + ) : null} +
+

+ {plan.title} +

+

+ {copy.estimateAlt} +

+
+
+ + {formatRechargePrice(perMonthCents)} + + + {annual ? '折合 / 月' : '/ 月'} + +
+
+ {annual + ? `年实付 ${formatRechargePrice(plan.yearPriceCents)}${ + savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : '' + }` + : `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`} +
+
+
+ {formatMudPointCount(plan.periodPoints)} 月度泥点 +
+
+ {annual ? '每月刷新 · 全年发放 12 期' : '每月刷新 · 当期有效'} +
+
+
+ 每月预计可完成约 {copy.estimateCount} · {copy.estimateKind} +
+
    +
  • {resolveMembershipModelAccessLabel(plan.modelAccess)}
  • +
  • {resolveMembershipConcurrencyLabel(plan.concurrentJobLimit)}
  • +
+ + {disabled ? actionLabel : submitting ? '处理中' : '选择并确认'} + +
+ ); +} + +function PlatformMembershipComparisonTable({ + plans, +}: { + plans: ProfileMembershipPlanRecord[]; +}) { + const rows: Array<{ + label: string; + values: string[]; + }> = [ + { + label: '月付价格', + values: plans.map((plan) => formatRechargePrice(plan.monthPriceCents)), + }, + { + label: '年付价格', + values: plans.map((plan) => formatRechargePrice(plan.yearPriceCents)), + }, + { + label: '每期泥点', + values: plans.map((plan) => formatMudPointCount(plan.periodPoints)), + }, + { + label: '模型权限', + values: plans.map((plan) => + resolveMembershipModelAccessLabel(plan.modelAccess), + ), + }, + { + label: '独立任务并发', + values: plans.map((plan) => + resolveMembershipConcurrencyShortLabel(plan.concurrentJobLimit), + ), + }, + ]; + + return ( +
+ + + + + {plans.map((plan) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.values.map((value, index) => ( + + ))} + + ))} + +
+ 套餐 + + {plan.title} +
+ {row.label} + + {value} +
+
+ ); +} + +function PlatformPointProductGrid({ + products, + selectedProductId, + submittingProductId, + onSelect, +}: { + products: ProfileRechargeProduct[]; + selectedProductId: string | null; + submittingProductId: string | null; + onSelect: (product: ProfileRechargeProduct) => void; +}) { + return ( +
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + return; + } + event.preventDefault(); + const currentIndex = products.findIndex( + (product) => product.productId === selectedProductId, + ); + const nextIndex = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? products.length - 1 + : (currentIndex + + (event.key === 'ArrowRight' ? 1 : -1) + + products.length) % + products.length; + const nextProduct = products[nextIndex]; + if (nextProduct) { + onSelect(nextProduct); + } + }} + > + {products.map((product) => { + const selected = product.productId === selectedProductId; + const submitting = submittingProductId === product.productId; + return ( + + ); + })} +
+ ); +} + +function PlatformRechargeCheckoutDialog({ + target, + membershipPlans, + membership, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, + submittingProductId, + onClose, + onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, +}: { + target: PlatformProfileRechargeCheckoutTarget; + membershipPlans: ProfileMembershipPlanRecord[]; + membership: ProfileMembership | null; + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; + submittingProductId: string | null; + onClose: () => void; + onBuy: (product: ProfileRechargeProduct) => void; + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; +}) { + if (target.kind === 'points') { + const product = target.product; + const submitting = submittingProductId === product.productId; + return ( + { + onBuy(product); + onClose(); + }} + lines={[ + { label: '充值项目', value: product.title }, + { + label: '本次到账', + value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`, + }, + ]} + note="永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。" + /> + ); + } + + const plan = + membershipPlans.find((item) => item.plan === target.plan) ?? null; + const annual = target.cycleKind === 'yearly'; + const cycleLabel = annual ? '年付' : '月付'; + const listPriceCents = plan + ? annual + ? plan.yearPriceCents + : plan.monthPriceCents + : 0; + const isUpgrade = target.mode === 'upgrade'; + const currentPlan = + membershipPlans.find((item) => item.plan === membership?.plan) ?? null; + const productId = resolveMembershipProductId(target.plan, target.cycleKind); + const submitting = submittingProductId === productId; + const quoteForTarget = + isUpgrade && membershipQuote && membershipQuote.targetPlan === target.plan + ? membershipQuote + : null; + const amountCents = quoteForTarget + ? quoteForTarget.amountCents + : listPriceCents; + const canSubmit = + Boolean(plan) && + !submitting && + (!isUpgrade || Boolean(quoteForTarget)) && + amountCents > 0; + + const lines: Array<{ label: string; value: string }> = []; + if (isUpgrade) { + if (currentPlan) { + lines.push({ + label: `原套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice( + annual ? currentPlan.yearPriceCents : currentPlan.monthPriceCents, + ), + }); + } + lines.push({ + label: `新套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice(listPriceCents), + }); + if (quoteForTarget) { + lines.push({ + label: '本期补发月度泥点', + value: `+${formatMudPointCount(quoteForTarget.grantedPointsDelta)}`, + }); + lines.push({ + label: '补点后月度余额', + value: formatMudPointCount(quoteForTarget.monthlyBalanceAfter), + }); + const expiresAt = formatMudPointResetAt(quoteForTarget.expiresAt); + const cycleResetsAt = formatMudPointResetAt(quoteForTarget.cycleResetsAt); + if (expiresAt) { + lines.push({ label: '会员到期日(不变)', value: expiresAt }); + } + if (cycleResetsAt) { + lines.push({ label: '本期刷新日(不变)', value: cycleResetsAt }); + } + } + } else { + lines.push({ + label: '到账月度泥点', + value: plan ? `${formatMudPointCount(plan.periodPoints)}(首期)` : '—', + }); + lines.push({ + label: '计费周期', + value: annual + ? '年付 · 共 12 期,本次只到账首期' + : '月付 · 本次到账当期额度', + }); + } + + return ( + { + onSubmitMembershipCheckout(target.plan, target.cycleKind); + onClose(); + }} + lines={lines} + note={ + isUpgrade + ? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。' + : `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。` + } + errorState={ + isUpgrade && membershipQuoteError ? ( + +
{membershipQuoteError}
+ onRequestMembershipQuote(target.plan)} + > + 重新获取报价 + +
+ ) : isUpgrade && isLoadingMembershipQuote ? ( +
+ 正在获取升级报价… +
+ ) : null + } + /> + ); +} + +function PlatformRechargeCheckoutShell({ + title, + amountCents, + amountLabel = '本次支付', + ctaLabel, + ctaDisabled = false, + submitting, + lines, + note, + errorState, + onClose, + onSubmit, +}: { + title: string; + amountCents: number; + amountLabel?: string; + ctaLabel: string; + ctaDisabled?: boolean; + submitting: boolean; + lines: Array<{ label: string; value: string }>; + note: string; + errorState?: ReactNode; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + +
+
+ {amountLabel} +
+
+ {formatRechargePrice(amountCents)} +
+
+ {errorState ?
{errorState}
: null} +
+ {lines.map((line) => ( +
+ + {line.label} + + + {line.value} + +
+ ))} +
+
+ 支付方式 +
+
+ + + 微 + + 微信支付 + +
+ + {submitting ? '处理中' : ctaLabel} + +

+ {note} +

+
); } @@ -454,31 +1196,116 @@ export function PlatformProfileRechargeModal({ error, submittingProductId, nativePayment, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, onClose, onRetry, onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, onConfirmNativePayment, onCloseNativePayment, }: PlatformProfileRechargeModalProps) { const nativeQrImageUrl = useWechatNativeQrCode( nativePayment?.codeUrl ?? null, ); - const products = center?.pointProducts ?? []; + const membership = center?.membership ?? null; + const isActiveMembership = + membership?.status === 'active' && membership.plan !== 'normal'; const currentBalance = resolveProfileMudPointDisplayTotal( center?.mudPointBalance, ); + const membershipPlans = useMemo( + () => + (center?.membershipPlans ?? []) + .filter((plan) => plan.enabled && plan.plan !== 'normal') + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder), + [center?.membershipPlans], + ); + const pointProducts = useMemo( + () => center?.pointProducts ?? [], + [center?.pointProducts], + ); + const currentPlanRecord = useMemo( + () => + membershipPlans.find((plan) => plan.plan === membership?.plan) ?? null, + [membership?.plan, membershipPlans], + ); + const [activeTab, setActiveTab] = + useState('subscription'); + const [cycleKind, setCycleKind] = + useState('monthly'); + const [selectedPointProductId, setSelectedPointProductId] = useState< + string | null + >(null); + const [checkout, setCheckout] = + useState(null); + + // 有效会员期内不允许月付 / 年付互换,切换器锁定在当前周期。 + useEffect(() => { + if (isActiveMembership && membership?.cycleKind) { + setCycleKind(membership.cycleKind); + } + }, [isActiveMembership, membership?.cycleKind]); + + useEffect(() => { + setSelectedPointProductId((current) => { + if ( + current && + pointProducts.some((product) => product.productId === current) + ) { + return current; + } + if (pointProducts.length === 0) { + return null; + } + const fallbackProduct = + pointProducts[Math.min(2, pointProducts.length - 1)]; + return fallbackProduct ? fallbackProduct.productId : null; + }); + }, [pointProducts]); + + const selectedPointProduct = + pointProducts.find( + (product) => product.productId === selectedPointProductId, + ) ?? null; + + const openMembershipCheckout = useCallback( + (plan: ProfileMembershipPlanRecord) => { + const action = resolveMembershipPlanAction( + plan, + membership, + currentPlanRecord, + ); + if (action.disabled) { + return; + } + setCheckout({ + kind: 'membership', + plan: plan.plan, + cycleKind, + mode: action.mode, + }); + if (action.mode === 'upgrade') { + onRequestMembershipQuote(plan.plan); + } + }, + [currentPlanRecord, cycleKind, membership, onRequestMembershipQuote], + ); return ( <> } - isEmpty={products.length === 0} + isEmpty={membershipPlans.length === 0 && pointProducts.length === 0} emptyState={ 暂无可购买套餐 } > -
- {products.map((product) => ( - + + + + + +
+ {checkout ? ( + setCheckout(null)} + onBuy={onBuy} + onRequestMembershipQuote={onRequestMembershipQuote} + onSubmitMembershipCheckout={onSubmitMembershipCheckout} + /> + ) : null} {nativePayment ? ( = 128` 视为不设上限。 */ +export const UNLIMITED_CONCURRENT_JOB_LIMIT = 128; + +type MembershipPlanCopy = { + /** 每月预计可完成的数量估算。 */ + estimateCount: string; + /** 数量估算对应的项目形态。 */ + estimateKind: string; + /** 数量估算的补充说明。 */ + estimateAlt: string; +}; + +const MEMBERSHIP_PLAN_COPY: Partial< + Record +> = { + starter: { + estimateCount: '1–2 个', + estimateKind: '简单小游戏原型', + estimateAlt: '先验证核心玩法与基础交互', + }, + plus: { + estimateCount: '2–4 个', + estimateKind: '轻量微抖小游戏', + estimateAlt: '按可玩版本估算', + }, + pro: { + estimateCount: '1–2 个', + estimateKind: '中等项目 Demo', + estimateAlt: '或约 7–14 个轻量小游戏', + }, + max: { + estimateCount: '1–2 个', + estimateKind: '独立游戏可玩章节', + estimateAlt: '或约 2–5 个中等项目 Demo', + }, +}; + +const FALLBACK_PLAN_COPY: MembershipPlanCopy = { + estimateCount: '按实际创作量', + estimateKind: '以作品规模为准', + estimateAlt: '具体消耗取决于创作复杂度', +}; + +export function resolveMembershipPlanCopy( + plan: ProfileMembershipPlan, +): MembershipPlanCopy { + return MEMBERSHIP_PLAN_COPY[plan] ?? FALLBACK_PLAN_COPY; +} + +export function resolveMembershipModelAccessLabel( + modelAccess: ProfileMembershipModelAccess, +): string { + return modelAccess === 'full' ? '基础及高性能模型' : '基础模型'; +} + +/** 套餐卡里的并发文案。 */ +export function resolveMembershipConcurrencyLabel( + concurrentJobLimit: number, +): string { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '独立任务并发不设套餐上限' + : `同时运行 ${concurrentJobLimit} 个独立任务`; +} + +/** 对比表里的并发短文案。 */ +export function resolveMembershipConcurrencyShortLabel( + concurrentJobLimit: number, +): string { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT + ? '不设套餐上限' + : `${concurrentJobLimit} 个`; +} + +export function isUnlimitedConcurrency(concurrentJobLimit: number): boolean { + return concurrentJobLimit >= UNLIMITED_CONCURRENT_JOB_LIMIT; +} diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts new file mode 100644 index 000000000..509047d40 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts @@ -0,0 +1,15 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, +} from '../../contracts/runtime'; + +/** + * 会员商品没有配置行,`product_id` 由「档位 + 周期」拼出,与后端 + * `parse_runtime_profile_membership_product_id` 双向对应。 + */ +export function resolveMembershipProductId( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, +) { + return `membership-${plan}-${cycleKind}`; +}