Files
Genarrative/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
T
k88936 9e13b83171 refactor(充值弹窗): 摊平套餐卡片里的嵌套三元价格行
- 原年付实付文案在模板字符串里再嵌一层 savingCents 三元,属于嵌套三元
- 把年付价格行提升为 annualPriceLine 常量,JSX 只保留 annual 单层分支,文案不变
2026-10-04 15:52:29 +08:00

1572 lines
49 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,
useRef,
useState,
} from 'react';
import type {
ProfileMembership,
ProfileMembershipCycleKind,
ProfileMembershipPlan,
ProfileMembershipPlanRecord,
ProfileMembershipUpgradeQuote,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../contracts/runtime';
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
import {
formatMudPointResetAt,
resolveProfileMudPointDisplayTotal,
shouldShowProfileDailyFreePool,
} from '../../utils/mudPoints';
import {
resolveMembershipConcurrencyLabel,
resolveMembershipConcurrencyShortLabel,
resolveMembershipModelAccessLabel,
resolveMembershipPlanCopy,
} from './membershipCopy';
import { resolveMembershipProductId } from './membershipProduct';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
/**
* roving tabindex / radiogroup 的方向键索引解析:`Home` / `End` 跳首尾,左右键循环,
* 其余按键返回 `null`(调用方据此忽略事件)。`currentIndex` 允许为 `-1`(未选中)。
*/
function resolveRovingIndex(
key: string,
currentIndex: number,
length: number,
): number | null {
if (length <= 0) {
return null;
}
switch (key) {
case 'Home':
return 0;
case 'End':
return length - 1;
case 'ArrowLeft':
return (currentIndex - 1 + length) % length;
case 'ArrowRight':
return (currentIndex + 1) % length;
default:
return null;
}
}
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 resolveMembershipQuoteErrorState({
isUpgrade,
error,
isLoading,
onRetry,
}: {
isUpgrade: boolean;
error: string | null;
isLoading: boolean;
onRetry: () => void;
}): ReactNode {
if (!isUpgrade) {
return null;
}
if (error) {
return (
<PlatformRechargeStatusMessage className="rounded-2xl font-semibold">
<div>{error}</div>
<PlatformRechargeActionButton className="mt-3" onClick={onRetry}>
重新获取报价
</PlatformRechargeActionButton>
</PlatformRechargeStatusMessage>
);
}
if (isLoading) {
return (
<div className="rounded-xl px-3 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
正在获取升级报价…
</div>
);
}
return null;
}
function PlatformMudPointPoolOverview({
center,
}: {
center: ProfileRechargeCenterResponse | null;
}) {
const balance = center?.mudPointBalance;
const showDailyFree = shouldShowProfileDailyFreePool(balance);
const dailyFreeResetsAt = formatMudPointResetAt(balance?.dailyFreeResetsAt);
const monthlyResetsAt = formatMudPointResetAt(balance?.monthlyResetsAt);
const pools = [
...(showDailyFree
? [
{
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 ${showDailyFree ? 'grid-cols-3' : 'grid-cols-2'} 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>
{showDailyFree && 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: '泥点充值' },
];
// 方向键移动选中项后把焦点一并移到新页签,保持 roving tabindex 的焦点与选中一致。
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
return (
<div
role="tablist"
aria-label="会员与泥点"
className="platform-subpanel flex gap-1 rounded-full p-1"
onKeyDown={(event) => {
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab);
const nextIndex = resolveRovingIndex(
event.key,
currentIndex,
tabs.length,
);
if (nextIndex === null) {
return;
}
event.preventDefault();
const nextTab = tabs[nextIndex];
if (nextTab) {
onChange(nextTab.id);
tabRefs.current[nextTab.id]?.focus();
}
}}
>
{tabs.map((tab) => {
const selected = tab.id === activeTab;
return (
<button
key={tab.id}
ref={(node) => {
tabRefs.current[tab.id] = node;
}}
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;
const annualPriceLine = `年实付 ${formatRechargePrice(plan.yearPriceCents)}${
savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : ''
}`;
// 按钮文案优先级:不可购买时展示业务文案,其次「处理中」,最后默认「选择并确认」。
let actionButtonLabel = '选择并确认';
if (disabled) {
actionButtonLabel = actionLabel;
} else if (submitting) {
actionButtonLabel = '处理中';
}
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
? annualPriceLine
: `每次支付 ${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)}</li>
</ul>
<PlatformRechargeActionButton
ariaLabel={`${plan.title} ${cycleKind === 'yearly' ? '年付' : '月付'} ${actionLabel}`}
className="mt-1 w-full disabled:cursor-not-allowed"
disabled={disabled || submitting}
onClick={onCheckout}
>
{actionButtonLabel}
</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)),
},
];
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;
}) {
// 方向键移动选中项后同步移动焦点,保持 roving tabindex 的焦点与选中一致。
const productRefs = useRef<Record<string, HTMLButtonElement | null>>({});
return (
<div
role="radiogroup"
aria-label="泥点充值档位"
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
onKeyDown={(event) => {
const currentIndex = products.findIndex(
(product) => product.productId === selectedProductId,
);
const nextIndex = resolveRovingIndex(
event.key,
currentIndex,
products.length,
);
if (nextIndex === null) {
return;
}
event.preventDefault();
const nextProduct = products[nextIndex];
if (nextProduct) {
onSelect(nextProduct);
productRefs.current[nextProduct.productId]?.focus();
}
}}
>
{products.map((product) => {
const selected = product.productId === selectedProductId;
const submitting = submittingProductId === product.productId;
return (
<button
key={product.productId}
ref={(node) => {
productRefs.current[product.productId] = node;
}}
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,
showDailyFreePool,
onClose,
onBuy,
onRequestMembershipQuote,
onSubmitMembershipCheckout,
}: {
target: PlatformProfileRechargeCheckoutTarget;
membershipPlans: ProfileMembershipPlanRecord[];
membership: ProfileMembership | null;
membershipQuote: ProfileMembershipUpgradeQuote | null;
isLoadingMembershipQuote: boolean;
membershipQuoteError: string | null;
submittingProductId: string | null;
showDailyFreePool: boolean;
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={
showDailyFreePool
? '永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。'
: '永久有效,不会随会员到期清零。创作时先扣月度泥点,不足部分再扣永久泥点。'
}
/>
);
}
const plan =
membershipPlans.find((item) => item.plan === target.plan) ?? null;
const annual = target.cycleKind === 'yearly';
const cycleLabel = annual ? '年付' : '月付';
const listPriceCents = annual
? (plan?.yearPriceCents ?? 0)
: (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.cycleKind === target.cycleKind
? membershipQuote
: null;
// 升级金额只能来自服务端报价:报价未到 / 失败时不能拿新套餐标准价冒充「本次补付」。
const amountPending = isUpgrade && !quoteForTarget;
const amountCents = quoteForTarget
? quoteForTarget.amountCents
: listPriceCents;
const amountText = amountPending ? '—' : formatRechargePrice(amountCents);
let ctaLabel: string;
if (!isUpgrade) {
ctaLabel = `支付 ${amountText}`;
} else if (amountPending) {
ctaLabel = '补差升级';
} else {
ctaLabel = `补差升级 ${amountText}`;
}
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}
amountPending={amountPending}
amountLabel={isUpgrade ? '本次补付' : '本次支付'}
ctaLabel={ctaLabel}
ctaDisabled={!canSubmit}
onClose={onClose}
onSubmit={() => {
onSubmitMembershipCheckout(target.plan, target.cycleKind);
onClose();
}}
lines={lines}
note={
isUpgrade
? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。'
: `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。`
}
errorState={resolveMembershipQuoteErrorState({
isUpgrade,
error: membershipQuoteError,
isLoading: isLoadingMembershipQuote,
onRetry: () => onRequestMembershipQuote(target.plan),
})}
/>
);
}
function PlatformRechargeCheckoutShell({
title,
amountCents,
amountPending = false,
amountLabel = '本次支付',
ctaLabel,
ctaDisabled = false,
submitting,
lines,
note,
errorState,
onClose,
onSubmit,
}: {
title: string;
amountCents: number;
/** 金额未知(升级报价未到 / 失败)时只显示占位符,避免拿标准价冒充补差金额。 */
amountPending?: boolean;
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)]">
{amountPending ? '—' : 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>
{/* 中文注释:当前只支持微信支付,是纯展示文案,不用 radiogroup / radio 语义(不可交互的
单选项会误导读屏)。 */}
<div className="mt-1.5 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/6 px-3 py-2">
<span 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,
);
const showDailyFreePool = shouldShowProfileDailyFreePool(
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;
}
// 中文注释:默认预选第 3 档(索引 2,不足时退回末档),让首屏落在价格居中的档位;
// 充点商品目前同价(¥1 = 10 泥点),这里只影响默认选中项,不改变计费。
const defaultProductIndex = Math.min(2, pointProducts.length - 1);
const fallbackProduct = pointProducts[defaultProductIndex];
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.recommended}
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)
}
/>
<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}
showDailyFreePool={showDailyFreePool}
onClose={() => setCheckout(null)}
onBuy={onBuy}
onRequestMembershipQuote={onRequestMembershipQuote}
onSubmitMembershipCheckout={onSubmitMembershipCheckout}
/>
) : null}
{nativePayment ? (
<PlatformProfileWechatNativePaymentModal
nativePayment={nativePayment}
nativeQrImageUrl={nativeQrImageUrl}
onClose={onCloseNativePayment}
onConfirm={onConfirmNativePayment}
/>
) : null}
</>
);
}