Files
Genarrative/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
T
k88936 5f31c8c9df fix(会员充值): 套餐卡不再重复触发下单回调
- PlatformProfileRechargeModal/index.tsx:去掉套餐卡外层 div 的 onClick,点击 CTA 时不再因事件冒泡触发两次 openMembershipCheckout

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-03 14:58:18 +08:00

1483 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './index.css';
import QRCode from 'qrcode';
import type { ReactNode } 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 {
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;
amountCents: number;
codeUrl: string;
expiresAt: string;
isConfirming: boolean;
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;
};
function joinClassNames(
...classNames: Array<string | false | null | undefined>
) {
return classNames.filter(Boolean).join(' ');
}
function PlatformProfileRechargeModalShell({
title,
description,
onClose,
closeLabel,
closeDisabled = false,
size = 'sm',
zIndexClassName = 'z-[80]',
panelClassName,
bodyClassName = 'px-5 py-5',
children,
}: {
title: string;
description?: ReactNode;
onClose: () => void;
closeLabel?: string;
closeDisabled?: boolean;
size?: 'sm' | 'md';
zIndexClassName?: string;
panelClassName: string;
bodyClassName?: string;
children: ReactNode;
}) {
const titleId = useId();
const descriptionId = useId();
const sizeClassName = size === 'md' ? 'max-w-xl' : 'max-w-md';
return (
<div
className={joinClassNames(
'platform-profile-recharge-modal-backdrop fixed inset-0 flex items-center justify-center px-4 py-6',
zIndexClassName,
)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(
'platform-profile-recharge-modal flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-[1.75rem]',
sizeClassName,
panelClassName,
)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<div
id={titleId}
className="text-base font-black text-[var(--platform-text-strong)]"
>
{title}
</div>
{description ? (
<div
id={descriptionId}
className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]"
>
{description}
</div>
) : null}
</div>
<button
type="button"
aria-label={closeLabel ?? `关闭${title}`}
title={closeLabel ?? `关闭${title}`}
disabled={closeDisabled}
onClick={onClose}
className="platform-profile-recharge-modal__close flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg font-black disabled:cursor-not-allowed disabled:opacity-45"
>
×
</button>
</div>
<div className={joinClassNames('min-h-0 flex-1', bodyClassName)}>
{children}
</div>
</div>
</div>
);
}
function PlatformRechargeActionButton({
children,
className,
disabled,
ariaLabel,
onClick,
}: {
children: ReactNode;
className?: string;
disabled?: boolean;
ariaLabel?: string;
onClick?: () => void;
}) {
return (
<button
type="button"
aria-label={ariaLabel}
className={joinClassNames(
'platform-profile-recharge-modal__primary-button inline-flex items-center justify-center gap-2 rounded-2xl px-4 py-2 text-xs font-black',
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
function PlatformRechargeAsyncStatePanel({
errorState,
isLoading,
loadingState,
isEmpty,
emptyState,
children,
}: {
errorState?: ReactNode;
isLoading?: boolean;
loadingState?: ReactNode;
isEmpty?: boolean;
emptyState?: ReactNode;
children: ReactNode;
}) {
if (errorState !== undefined && errorState !== null) {
return <>{errorState}</>;
}
if (isLoading) {
return <>{loadingState}</>;
}
if (isEmpty) {
return <>{emptyState}</>;
}
return <>{children}</>;
}
function PlatformRechargeSkeletonList({
count,
containerClassName,
itemClassName,
}: {
count: number;
containerClassName?: string;
itemClassName?: string;
}) {
return (
<div className={containerClassName}>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className={joinClassNames(
'animate-pulse rounded-xl bg-zinc-100',
itemClassName,
)}
/>
))}
</div>
);
}
function PlatformRechargeEmptyState({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={joinClassNames(
'platform-profile-recharge-modal__empty min-w-0 rounded-[1rem] px-4 py-5 text-center text-sm font-semibold',
className,
)}
>
{children}
</div>
);
}
function PlatformRechargeStatusMessage({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={joinClassNames(
'platform-profile-recharge-modal__status-error rounded-xl px-3 py-2 text-xs',
className,
)}
>
{children}
</div>
);
}
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function formatNativePaymentRemaining(remainingMs: number) {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function useNativePaymentRemaining(expiresAt: string) {
const resolveRemainingMs = useCallback(() => {
const expiresAtMs = Date.parse(expiresAt);
if (!Number.isFinite(expiresAtMs)) {
return 0;
}
return Math.max(0, expiresAtMs - Date.now());
}, [expiresAt]);
const [remainingMs, setRemainingMs] = useState(resolveRemainingMs);
useEffect(() => {
setRemainingMs(resolveRemainingMs());
const timer = window.setInterval(() => {
setRemainingMs(resolveRemainingMs());
}, 1000);
return () => window.clearInterval(timer);
}, [resolveRemainingMs]);
return {
isExpired: remainingMs <= 0,
label: formatNativePaymentRemaining(remainingMs),
};
}
type MembershipPlanAction = {
disabled: boolean;
label: string;
mode: 'purchase' | 'upgrade';
};
function resolveMembershipPlanAction(
plan: ProfileMembershipPlanRecord,
membership: ProfileMembership | null,
currentPlan: ProfileMembershipPlanRecord | null,
): MembershipPlanAction {
const isActive =
membership?.status === 'active' && membership.plan !== 'normal';
if (!isActive || !membership || !currentPlan) {
return { disabled: false, label: '开通会员', mode: 'purchase' };
}
if (plan.plan === membership.plan) {
return { disabled: true, label: '当前套餐', mode: 'purchase' };
}
if (plan.rank <= currentPlan.rank) {
return { disabled: true, label: '不支持降档', mode: 'purchase' };
}
return { disabled: false, label: '补差升级', mode: 'upgrade' };
}
function PlatformMudPointPoolOverview({
center,
}: {
center: ProfileRechargeCenterResponse | null;
}) {
const balance = center?.mudPointBalance;
const dailyFreeResetsAt = formatMudPointResetAt(balance?.dailyFreeResetsAt);
const monthlyResetsAt = formatMudPointResetAt(balance?.monthlyResetsAt);
const pools = [
{
key: 'daily',
label: '每日免费',
points: balance?.dailyFreePoints ?? 0,
detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`,
},
{
key: 'monthly',
label: '月度泥点',
points: balance?.monthlyPoints ?? 0,
detail: monthlyResetsAt
? `${monthlyResetsAt} 刷新`
: '会员每期额度,刷新日清零',
},
{
key: 'permanent',
label: '永久泥点',
points: balance?.permanentPoints ?? 0,
detail: '不过期,不随会员到期清零',
},
];
return (
<div className="grid gap-2">
<div className="rounded-[1rem] border border-[rgba(194,145,111,0.28)] bg-white/45 px-4 py-3">
<div className="text-[11px] font-bold text-[var(--platform-text-soft)]">
可用总额
</div>
<div className="mt-0.5 text-xl font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(
resolveProfileMudPointDisplayTotal(balance) ?? 0,
)}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
{pools.map((pool) => (
<div
key={pool.key}
className="min-w-0 rounded-[1rem] border border-[rgba(194,145,111,0.28)] bg-white/35 px-3 py-2"
>
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
{pool.label}
</div>
<div className="mt-0.5 text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(pool.points)}
</div>
<div className="mt-0.5 truncate text-[10px] text-[var(--platform-text-soft)]">
{pool.detail}
</div>
</div>
))}
</div>
{dailyFreeResetsAt ? (
<p className="text-[10px] text-[var(--platform-text-soft)]">
创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '}
重置每日免费额度。
</p>
) : null}
</div>
);
}
function PlatformRechargeTabNav({
activeTab,
onChange,
}: {
activeTab: PlatformProfileRechargeTab;
onChange: (tab: PlatformProfileRechargeTab) => void;
}) {
const tabs: Array<{ id: PlatformProfileRechargeTab; label: string }> = [
{ id: 'subscription', label: '会员订阅' },
{ id: 'recharge', label: '泥点充值' },
];
return (
<div
role="tablist"
aria-label="会员与泥点"
className="platform-subpanel flex gap-1 rounded-full p-1"
onKeyDown={(event) => {
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 (
<button
key={tab.id}
type="button"
role="tab"
id={`platform-recharge-tab-${tab.id}`}
aria-selected={selected}
aria-controls={`platform-recharge-panel-${tab.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(tab.id)}
className={joinClassNames(
'flex-1 rounded-full px-3 py-2 text-xs font-black transition',
selected
? 'platform-profile-recharge-modal__primary-button'
: 'text-[var(--platform-text-soft)] hover:bg-white/55',
)}
>
{tab.label}
</button>
);
})}
</div>
);
}
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 (
<div className="platform-subpanel flex items-center justify-between gap-3 rounded-[1rem] px-4 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-black text-[var(--platform-text-strong)]">
当前 {planTitle} · {cycleLabel}
</div>
<div className="mt-0.5 truncate text-[11px] text-[var(--platform-text-soft)]">
{expiresAt ? `会员至 ${expiresAt}` : '会员有效期读取中'}
{membership.cycleKind === 'yearly'
? ` · 第 ${cycleIndexLabel} 期`
: ''}
</div>
</div>
<div className="shrink-0 text-right text-[10px] font-semibold text-[var(--platform-text-soft)]">
可同周期补差升档
<br />
周期切换需等到期后
</div>
</div>
);
}
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 (
<div className="flex items-center justify-between gap-3">
<div
role="radiogroup"
aria-label="付费周期"
className="platform-subpanel flex gap-1 rounded-full p-1"
>
{options.map((option) => {
const selected = option.id === cycleKind;
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={selected}
disabled={locked && !selected}
onClick={() => onChange(option.id)}
className={joinClassNames(
'rounded-full px-3 py-1.5 text-xs font-black transition disabled:cursor-not-allowed disabled:opacity-45',
selected
? 'platform-profile-recharge-modal__primary-button'
: 'text-[var(--platform-text-soft)] hover:bg-white/55',
)}
>
{option.label}
</button>
);
})}
</div>
{locked ? (
<span className="text-[10px] font-semibold text-[var(--platform-text-soft)]">
有效期内不支持月年互换
</span>
) : null}
</div>
);
}
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 (
<article
className={joinClassNames(
'platform-subpanel platform-profile-recharge-modal__interactive-card relative flex min-w-0 flex-col gap-2 rounded-[1.15rem] px-4 py-4',
isCurrentPlan ? 'ring-2 ring-[var(--platform-warm-border)]' : '',
)}
>
{recommended ? (
<span className="platform-profile-recharge-modal__badge-warning absolute right-3 top-3 rounded-full border px-2 py-0.5 text-[10px] font-black">
推荐之选
</span>
) : null}
<div className="min-w-0">
<h3 className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{plan.title}
</h3>
<p className="mt-0.5 text-[11px] text-[var(--platform-text-soft)]">
{copy.estimateAlt}
</p>
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(perMonthCents)}
</span>
<span className="text-[11px] font-bold text-[var(--platform-text-soft)]">
{annual ? '折合 / 月' : '/ 月'}
</span>
</div>
<div className="text-[10px] text-[var(--platform-text-soft)]">
{annual
? `年实付 ${formatRechargePrice(plan.yearPriceCents)}${
savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : ''
}`
: `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`}
</div>
<div className="rounded-[0.85rem] border border-[rgba(194,145,111,0.28)] bg-white/35 px-3 py-2">
<div className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(plan.periodPoints)} 月度泥点
</div>
<div className="mt-0.5 text-[10px] text-[var(--platform-text-soft)]">
{annual ? '每月刷新 · 全年发放 12 期' : '每月刷新 · 当期有效'}
</div>
</div>
<div className="text-[10px] leading-5 text-[var(--platform-text-soft)]">
每月预计可完成约 {copy.estimateCount} · {copy.estimateKind}
</div>
<ul className="grid gap-0.5 text-[10px] leading-5 text-[var(--platform-text-soft)]">
<li>{resolveMembershipModelAccessLabel(plan.modelAccess)}</li>
<li>{resolveMembershipConcurrencyLabel(plan.concurrentJobLimit)}</li>
</ul>
<PlatformRechargeActionButton
ariaLabel={`${plan.title} ${cycleKind === 'yearly' ? '年付' : '月付'} ${actionLabel}`}
className="mt-1 w-full disabled:cursor-not-allowed"
disabled={disabled || submitting}
onClick={onCheckout}
>
{disabled ? actionLabel : submitting ? '处理中' : '选择并确认'}
</PlatformRechargeActionButton>
</article>
);
}
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 (
<div className="overflow-x-auto">
<table className="w-full min-w-[26rem] border-collapse text-left text-[11px]">
<thead>
<tr>
<th
scope="col"
className="p-2 font-black text-[var(--platform-text-soft)]"
>
套餐
</th>
{plans.map((plan) => (
<th
key={plan.plan}
scope="col"
className="p-2 font-black text-[var(--platform-text-strong)]"
>
{plan.title}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.label}
className="border-t border-[rgba(194,145,111,0.28)]"
>
<th
scope="row"
className="p-2 font-bold text-[var(--platform-text-soft)]"
>
{row.label}
</th>
{row.values.map((value, index) => (
<td
key={`${row.label}-${plans[index]?.plan ?? index}`}
className="p-2 tabular-nums text-[var(--platform-text-strong)]"
>
{value}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function PlatformPointProductGrid({
products,
selectedProductId,
submittingProductId,
onSelect,
}: {
products: ProfileRechargeProduct[];
selectedProductId: string | null;
submittingProductId: string | null;
onSelect: (product: ProfileRechargeProduct) => void;
}) {
return (
<div
role="radiogroup"
aria-label="泥点充值档位"
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
onKeyDown={(event) => {
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 (
<button
key={product.productId}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
disabled={Boolean(submittingProductId)}
onClick={() => onSelect(product)}
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点 ${formatRechargePrice(product.priceCents)}`}
className={joinClassNames(
'platform-subpanel platform-profile-recharge-modal__interactive-card flex min-w-0 flex-col items-start gap-1 rounded-[1rem] px-3 py-3 text-left disabled:cursor-not-allowed disabled:opacity-55',
selected ? 'ring-2 ring-[var(--platform-warm-border)]' : '',
)}
>
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatMudPointCount(product.pointsAmount)}
<small className="ml-1 text-[10px] font-bold text-[var(--platform-text-soft)]">
永久泥点
</small>
</span>
<span className="text-xs font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="text-[10px] text-[var(--platform-text-soft)]">
{submitting ? '处理中' : product.badgeLabel || '永久有效'}
</span>
</button>
);
})}
</div>
);
}
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 (
<PlatformRechargeCheckoutShell
title="确认订单"
submitting={submitting}
amountCents={product.priceCents}
ctaLabel={`充值 ${formatRechargePrice(product.priceCents)}`}
onClose={onClose}
onSubmit={() => {
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 (
<PlatformRechargeCheckoutShell
title={isUpgrade ? '确认套餐变更' : '确认订单'}
submitting={submitting}
amountCents={amountCents}
amountLabel={isUpgrade ? '本次补付' : '本次支付'}
ctaLabel={
isUpgrade
? `补差升级 ${formatRechargePrice(amountCents)}`
: `支付 ${formatRechargePrice(amountCents)}`
}
ctaDisabled={!canSubmit}
onClose={onClose}
onSubmit={() => {
onSubmitMembershipCheckout(target.plan, target.cycleKind);
onClose();
}}
lines={lines}
note={
isUpgrade
? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。'
: `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。`
}
errorState={
isUpgrade && membershipQuoteError ? (
<PlatformRechargeStatusMessage className="rounded-2xl font-semibold">
<div>{membershipQuoteError}</div>
<PlatformRechargeActionButton
className="mt-3"
onClick={() => onRequestMembershipQuote(target.plan)}
>
重新获取报价
</PlatformRechargeActionButton>
</PlatformRechargeStatusMessage>
) : isUpgrade && isLoadingMembershipQuote ? (
<div className="rounded-xl px-3 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
正在获取升级报价…
</div>
) : 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 (
<PlatformProfileRechargeModalShell
title={title}
description="确认后进入微信支付"
onClose={onClose}
closeLabel={`关闭${title}`}
closeDisabled={submitting}
size="sm"
zIndexClassName="z-[86]"
panelClassName="!max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<div className="text-center">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{amountLabel}
</div>
<div className="mt-1 text-3xl font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(amountCents)}
</div>
</div>
{errorState ? <div className="mt-4">{errorState}</div> : null}
<div className="mt-4 grid gap-1.5 rounded-2xl border border-white/10 bg-white/6 px-4 py-3">
{lines.map((line) => (
<div
key={line.label}
className="flex items-start justify-between gap-3 text-xs"
>
<span className="font-bold text-[var(--platform-text-soft)]">
{line.label}
</span>
<strong className="text-right font-black text-[var(--platform-text-strong)]">
{line.value}
</strong>
</div>
))}
</div>
<div className="mt-4 text-[11px] font-bold text-[var(--platform-text-soft)]">
支付方式
</div>
<div
role="radiogroup"
aria-label="支付方式"
className="mt-1.5 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/6 px-3 py-2"
>
<span
role="radio"
aria-checked="true"
className="inline-flex items-center gap-2 text-xs font-black text-[var(--platform-text-strong)]"
>
<span className="platform-profile-recharge-modal__badge-warning inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]">
微
</span>
微信支付
</span>
</div>
<PlatformRechargeActionButton
className="mt-4 w-full disabled:cursor-not-allowed"
disabled={submitting || ctaDisabled}
onClick={onSubmit}
>
{submitting ? '处理中' : ctaLabel}
</PlatformRechargeActionButton>
<p className="mt-3 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{note}
</p>
</PlatformProfileRechargeModalShell>
);
}
function PlatformProfileWechatNativePaymentModal({
nativePayment,
nativeQrImageUrl,
onClose,
onConfirm,
}: {
nativePayment: PlatformProfileRechargeNativePaymentState;
nativeQrImageUrl: string | null;
onClose: () => void;
onConfirm: () => void;
}) {
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
return (
<PlatformProfileRechargeModalShell
title="微信扫码支付"
description="请使用微信扫描二维码完成付款"
onClose={onClose}
closeLabel="关闭微信扫码支付"
closeDisabled={nativePayment.isConfirming}
size="sm"
zIndexClassName="z-[85]"
panelClassName="!max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<div className="text-center">
<div className="mb-4 rounded-2xl border border-white/10 bg-white/6 px-4 py-3 text-left">
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
支付项目
</div>
<div className="mt-1 text-sm font-black text-[var(--platform-text-strong)]">
{nativePayment.productTitle}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
支付金额
</div>
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
{formatRechargePrice(nativePayment.amountCents)}
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span>剩余时间</span>
<span className="tabular-nums">
{remaining.isExpired ? '已过期' : remaining.label}
</span>
</div>
</div>
<div className="mx-auto flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">生成中</span>
)}
</div>
<PlatformRechargeActionButton
className="mt-4 disabled:cursor-not-allowed"
onClick={onConfirm}
disabled={nativePayment.isConfirming || remaining.isExpired}
>
{nativePayment.isConfirming
? '确认中'
: remaining.isExpired
? '已过期'
: '我已支付'}
</PlatformRechargeActionButton>
{nativePayment.confirmMessage ? (
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
{nativePayment.confirmMessage}
</div>
) : null}
</div>
</PlatformProfileRechargeModalShell>
);
}
export function PlatformProfileRechargeModal({
center,
isLoading,
error,
submittingProductId,
nativePayment,
membershipQuote,
isLoadingMembershipQuote,
membershipQuoteError,
onClose,
onRetry,
onBuy,
onRequestMembershipQuote,
onSubmitMembershipCheckout,
onConfirmNativePayment,
onCloseNativePayment,
}: PlatformProfileRechargeModalProps) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const membership = center?.membership ?? null;
const isActiveMembership =
membership?.status === 'active' && membership.plan !== 'normal';
const currentBalance = resolveProfileMudPointDisplayTotal(
center?.mudPointBalance,
);
// 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。
const membershipPlans = useMemo(
() =>
(center?.membershipPlans ?? []).filter(
(plan) => plan.enabled && plan.plan !== 'normal',
),
[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<PlatformProfileRechargeTab>('subscription');
const [cycleKind, setCycleKind] =
useState<ProfileMembershipCycleKind>('monthly');
const [selectedPointProductId, setSelectedPointProductId] = useState<
string | null
>(null);
const [checkout, setCheckout] =
useState<PlatformProfileRechargeCheckoutTarget | null>(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 (
<>
<PlatformProfileRechargeModalShell
title="会员与泥点"
description={
currentBalance === undefined
? '可用总额读取中'
: `可用总额 ${formatMudPointCount(currentBalance)} 泥点`
}
onClose={onClose}
closeLabel="关闭会员与泥点"
size="md"
panelClassName="!max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
>
<PlatformRechargeAsyncStatePanel
errorState={
error ? (
<PlatformRechargeStatusMessage className="mt-4 rounded-2xl font-semibold">
<div>{error}</div>
<PlatformRechargeActionButton
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformRechargeActionButton>
</PlatformRechargeStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformRechargeSkeletonList
count={4}
containerClassName="grid gap-2.5"
itemClassName="h-[4.5rem] rounded-lg bg-white/10"
/>
}
isEmpty={membershipPlans.length === 0 && pointProducts.length === 0}
emptyState={
<PlatformRechargeEmptyState className="mt-1">
暂无可购买套餐
</PlatformRechargeEmptyState>
}
>
<div className="grid gap-4">
<PlatformRechargeTabNav
activeTab={activeTab}
onChange={setActiveTab}
/>
<PlatformMudPointPoolOverview center={center} />
<div
role="tabpanel"
id="platform-recharge-panel-subscription"
aria-labelledby="platform-recharge-tab-subscription"
hidden={activeTab !== 'subscription'}
className="grid gap-3"
>
{isActiveMembership && membership ? (
<PlatformMembershipStatusCard
membership={membership}
currentPlan={currentPlanRecord}
/>
) : null}
<PlatformMembershipCycleSwitch
cycleKind={cycleKind}
locked={isActiveMembership}
onChange={setCycleKind}
/>
<div className="grid gap-2.5 sm:grid-cols-2">
{membershipPlans.map((plan) => {
const action = resolveMembershipPlanAction(
plan,
membership,
currentPlanRecord,
);
const planSubmitting =
submittingProductId ===
resolveMembershipProductId(plan.plan, cycleKind);
return (
<div key={plan.plan}>
<PlatformMembershipPlanCard
plan={plan}
cycleKind={cycleKind}
isCurrentPlan={plan.plan === membership?.plan}
recommended={plan.plan === RECOMMENDED_MEMBERSHIP_PLAN}
actionLabel={action.label}
disabled={action.disabled}
submitting={planSubmitting}
onCheckout={() => openMembershipCheckout(plan)}
/>
</div>
);
})}
</div>
{membershipPlans.length > 0 ? (
<details className="platform-subpanel rounded-[1rem] px-4 py-3">
<summary className="cursor-pointer text-xs font-black text-[var(--platform-text-strong)]">
套餐对比
</summary>
<div className="mt-3">
<PlatformMembershipComparisonTable
plans={membershipPlans}
/>
</div>
</details>
) : (
<PlatformRechargeEmptyState>
暂无上架的会员套餐
</PlatformRechargeEmptyState>
)}
</div>
<div
role="tabpanel"
id="platform-recharge-panel-recharge"
aria-labelledby="platform-recharge-tab-recharge"
hidden={activeTab !== 'recharge'}
className="grid gap-3"
>
{pointProducts.length > 0 ? (
<>
<PlatformPointProductGrid
products={pointProducts}
selectedProductId={selectedPointProductId}
submittingProductId={submittingProductId}
onSelect={(product) =>
setSelectedPointProductId(product.productId)
}
/>
<p className="text-[10px] text-[var(--platform-text-soft)]">
常规充值 ¥1 = 10 永久泥点,会员到期也可继续使用。
</p>
<PlatformRechargeActionButton
className="w-full disabled:cursor-not-allowed"
disabled={!selectedPointProduct}
onClick={() => {
if (selectedPointProduct) {
setCheckout({
kind: 'points',
product: selectedPointProduct,
});
}
}}
>
{selectedPointProduct
? `充值 ${formatRechargePrice(selectedPointProduct.priceCents)}`
: '请选择充值档位'}
</PlatformRechargeActionButton>
</>
) : (
<PlatformRechargeEmptyState>
暂无可购买套餐
</PlatformRechargeEmptyState>
)}
</div>
</div>
</PlatformRechargeAsyncStatePanel>
</PlatformProfileRechargeModalShell>
{checkout ? (
<PlatformRechargeCheckoutDialog
target={checkout}
membershipPlans={membershipPlans}
membership={membership}
membershipQuote={membershipQuote}
isLoadingMembershipQuote={isLoadingMembershipQuote}
membershipQuoteError={membershipQuoteError}
submittingProductId={submittingProductId}
onClose={() => setCheckout(null)}
onBuy={onBuy}
onRequestMembershipQuote={onRequestMembershipQuote}
onSubmitMembershipCheckout={onSubmitMembershipCheckout}
/>
) : null}
{nativePayment ? (
<PlatformProfileWechatNativePaymentModal
nativePayment={nativePayment}
nativeQrImageUrl={nativeQrImageUrl}
onClose={onCloseNativePayment}
onConfirm={onConfirmNativePayment}
/>
) : null}
</>
);
}