抽取充值模态框 #87

Closed
k88936 wants to merge 1 commits from extract-recharge-model into editor-agent-refactored
11 changed files with 379 additions and 91 deletions
@@ -16,6 +16,13 @@
---
## 2026-07-16 充值弹窗迁入 shared 并组件自带样式
- 背景:未来 Tauri 客户端需要复用主站和图片画布已有的泥点充值弹窗,但原实现位于 `src/components/platform-entry` 并依赖 `src/index.css` 中的全局样式。
- 决策:充值弹窗的正式实现迁入 `packages/shared/src/components/PlatformProfileRechargeModal/`,采用 `index.tsx` + `index.css` 目录结构;组件自带可复用样式,主站和图片画布改为直接引用 shared 组件.支付渠道选择、建单、确认、登录和宿主桥接继续留在业务侧 controller,不下沉到 shared。当前版本仍遵循 2026-07-12 决策,不展示会员商品或会员购买入口。
- 影响范围:`packages/shared` 组件导出、主站和图片画布充值弹窗引用、未来 Tauri Web 客户端复用方式。
- 验证方式:充值弹窗定向测试、资金 ViewModel 定向测试、`npm run typecheck``npm run check:encoding``git diff --check`
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
@@ -0,0 +1,92 @@
.platform-profile-recharge-modal-backdrop {
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
backdrop-filter: blur(12px);
}
.platform-profile-recharge-modal,
.platform-recharge-modal {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 24px 80px rgba(0, 0, 0, 0.18);
}
.platform-profile-recharge-modal__close {
border: 1px solid rgba(255, 255, 255, 0.32);
background: var(--platform-profile-chip-fill);
color: var(--platform-text-soft);
box-shadow: 0 10px 24px rgba(112, 57, 30, 0.08);
}
.platform-profile-recharge-modal__close:hover:not(:disabled) {
background: var(--platform-profile-chip-hover-fill);
color: var(--platform-text-strong);
}
.platform-profile-recharge-modal__subpanel {
border: 1px solid var(--platform-subpanel-border);
background: var(--platform-subpanel-fill);
}
.platform-profile-recharge-modal__interactive-card {
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.platform-profile-recharge-modal__interactive-card:hover:not(:disabled) {
border-color: var(--platform-surface-hover-border);
background: var(--platform-subpanel-fill);
box-shadow: 0 12px 26px rgba(112, 57, 30, 0.08);
transform: translateY(-1px);
}
.platform-profile-recharge-modal__interactive-card:active:not(:disabled) {
transform: translateY(0);
}
.platform-profile-recharge-modal__primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: var(--platform-profile-action-shadow);
transition:
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.platform-profile-recharge-modal__primary-button:hover:not(:disabled) {
box-shadow: 0 16px 34px rgba(182, 98, 63, 0.28);
transform: translateY(-1px);
}
.platform-profile-recharge-modal__primary-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.platform-profile-recharge-modal__status-error {
border: 1px solid var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
.platform-profile-recharge-modal__empty {
border: 1px solid var(--platform-subpanel-border);
background: var(--platform-subpanel-fill);
color: var(--platform-text-soft);
}
.platform-profile-recharge-modal__badge-warning {
border: 1px solid var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-warm-text);
}
@@ -4,8 +4,9 @@ import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import type { ProfileRechargeProduct } from '../../../packages/shared/src/contracts/runtime';
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
import type { ProfileRechargeProduct } from '@/packages/shared/src';
import { PlatformProfileRechargeModal } from '../../index';
function buildNormalMembership() {
return {
@@ -1,30 +1,33 @@
import QRCode from 'qrcode';
import { useCallback, useEffect, useState } from 'react';
import './index.css';
import { formatMudPointCount } from '@/packages/shared/src/utils/format.ts';
import QRCode from 'qrcode';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useId, useState } from 'react';
import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
} from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { formatRechargePrice } from '../rpg-entry/rpgEntryProfileFundsViewModel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import type { NativeWechatPaymentState } from './usePlatformProfileCenterController';
} from '../../contracts/runtime';
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
export type PlatformProfileRechargeNativePaymentState = {
orderId: string;
productTitle: string;
amountCents: number;
codeUrl: string;
expiresAt: string;
isConfirming: boolean;
confirmMessage?: string;
};
export type PlatformProfileRechargeModalProps = {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
nativePayment: PlatformProfileRechargeNativePaymentState | null;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
@@ -32,9 +35,228 @@ export type PlatformProfileRechargeModalProps = {
onCloseNativePayment: () => void;
};
/**
* Native
*/
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,
onClick,
}: {
children: ReactNode;
className?: string;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
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 PlatformRechargePillBadge({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<span
className={joinClassNames(
'platform-profile-recharge-modal__badge-warning inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-black',
className,
)}
>
{children}
</span>
);
}
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
@@ -96,7 +318,6 @@ function useNativePaymentRemaining(expiresAt: string) {
};
}
/** 充值套餐行,复用后端下发的商品与逐档首充资格。 */
function RechargeProductCard({
product,
submittingProductId,
@@ -114,27 +335,18 @@ function RechargeProductCard({
: null;
return (
<PlatformSubpanel
as="button"
<button
type="button"
surface="platform"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
interactive
radius="sm"
padding="none"
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatRechargePrice(product.priceCents)} 购买`}
className="platform-recharge-product-row platform-interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 px-3.5 py-3 text-left"
className="platform-recharge-product-row platform-subpanel platform-interactive-card platform-profile-recharge-modal__subpanel platform-profile-recharge-modal__interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 rounded-[1rem] px-3.5 py-3 text-left transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] disabled:cursor-not-allowed disabled:opacity-55"
>
<div className="min-w-0">
{badgeLabel ? (
<PlatformPillBadge
tone="warning"
size="xxs"
className="mb-1.5 max-w-[7rem] truncate px-2 py-0.5"
>
<PlatformRechargePillBadge className="mb-1.5 max-w-[7rem] truncate px-2 py-0.5">
{badgeLabel}
</PlatformPillBadge>
</PlatformRechargePillBadge>
) : null}
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-sm font-black text-[var(--platform-text-strong)]">
@@ -150,10 +362,10 @@ function RechargeProductCard({
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
<span className="platform-profile-recharge-modal__primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
</span>
</PlatformSubpanel>
</button>
);
}
@@ -163,7 +375,7 @@ function PlatformProfileWechatNativePaymentModal({
onClose,
onConfirm,
}: {
nativePayment: NativeWechatPaymentState;
nativePayment: PlatformProfileRechargeNativePaymentState;
nativeQrImageUrl: string | null;
onClose: () => void;
onConfirm: () => void;
@@ -171,7 +383,7 @@ function PlatformProfileWechatNativePaymentModal({
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
return (
<PlatformProfileModalShell
<PlatformProfileRechargeModalShell
title="微信扫码支付"
description="请使用微信扫描二维码完成付款"
onClose={onClose}
@@ -179,7 +391,7 @@ function PlatformProfileWechatNativePaymentModal({
closeDisabled={nativePayment.isConfirming}
size="sm"
zIndexClassName="z-[85]"
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
panelClassName="!max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<div className="text-center">
@@ -214,9 +426,7 @@ function PlatformProfileWechatNativePaymentModal({
<span className="text-xs font-semibold text-slate-500"></span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
<PlatformRechargeActionButton
className="mt-4 disabled:cursor-not-allowed"
onClick={onConfirm}
disabled={nativePayment.isConfirming || remaining.isExpired}
@@ -226,18 +436,17 @@ function PlatformProfileWechatNativePaymentModal({
: remaining.isExpired
? '已过期'
: '我已支付'}
</PlatformActionButton>
</PlatformRechargeActionButton>
{nativePayment.confirmMessage ? (
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
{nativePayment.confirmMessage}
</div>
) : null}
</div>
</PlatformProfileModalShell>
</PlatformProfileRechargeModalShell>
);
}
/** 主站与编辑器共用的泥点购买弹窗。 */
export function PlatformProfileRechargeModal({
center,
isLoading,
@@ -259,7 +468,7 @@ export function PlatformProfileRechargeModal({
return (
<>
<PlatformProfileModalShell
<PlatformProfileRechargeModalShell
title="购买更多泥点"
description={
currentBalance === undefined
@@ -269,33 +478,26 @@ export function PlatformProfileRechargeModal({
onClose={onClose}
closeLabel="关闭购买更多泥点"
size="md"
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
panelClassName="!max-w-[34rem] rounded-[1.4rem]"
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
>
<PlatformAsyncStatePanel
<PlatformRechargeAsyncStatePanel
errorState={
error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<PlatformRechargeStatusMessage className="mt-4 rounded-2xl font-semibold">
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
<PlatformRechargeActionButton
className="mt-3"
onClick={onRetry}
>
</PlatformActionButton>
</PlatformStatusMessage>
</PlatformRechargeActionButton>
</PlatformRechargeStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
<PlatformRechargeSkeletonList
count={4}
containerClassName="grid gap-2.5"
itemClassName="h-[4.5rem] rounded-lg bg-white/10"
@@ -303,13 +505,9 @@ export function PlatformProfileRechargeModal({
}
isEmpty={products.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-1"
>
<PlatformRechargeEmptyState className="mt-1">
</PlatformEmptyState>
</PlatformRechargeEmptyState>
}
>
<div className="grid gap-2.5">
@@ -322,8 +520,8 @@ export function PlatformProfileRechargeModal({
/>
))}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileModalShell>
</PlatformRechargeAsyncStatePanel>
</PlatformProfileRechargeModalShell>
{nativePayment ? (
<PlatformProfileWechatNativePaymentModal
nativePayment={nativePayment}
+2
View File
@@ -1,3 +1,5 @@
export * from './components/PlatformMudPointWalletEntry';
export * from './components/PlatformProfileRechargeModal';
export * from './contracts/auth';
export type * from './contracts/bigFish';
export * from './contracts/common';
+5
View File
@@ -8,3 +8,8 @@ export function formatMudPointCount(value: number, compact = false) {
}
return normalizedValue.toLocaleString('zh-CN');
}
export function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
@@ -24,7 +24,7 @@ import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
@@ -9,8 +9,8 @@ import {
type ProfileTaskCenterResponse,
type ProfileWalletLedgerResponse,
type RedeemProfileRewardCodeResponse,
type WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import {
clearStoredAccessToken,
refreshStoredAccessToken,
@@ -85,13 +85,8 @@ export type WechatRechargeOrderConfirmationState = {
orderId: string;
};
export type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
productTitle: string;
amountCents: number;
isConfirming: boolean;
confirmMessage?: string;
};
export type NativeWechatPaymentState =
PlatformProfileRechargeNativePaymentState;
function isWechatJsapiMissingIdentityError(error: unknown) {
return (
@@ -132,7 +132,7 @@ import {
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
@@ -4,6 +4,7 @@ import type {
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
export { formatRechargePrice } from '../../../packages/shared/src/utils/format';
const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
new_user_registration_reward: '注册赠送',
@@ -84,11 +85,6 @@ export function buildWalletLedgerPresentation(
};
}
export function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function formatMembershipPeriodPrefix(periodDays: number) {
if (periodDays === 30) {
return '每月';
-8
View File
@@ -9398,14 +9398,6 @@ button.image-canvas-editor__reference-chip:disabled {
backdrop-filter: blur(12px);
}
.platform-recharge-modal {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 24px 80px rgba(0, 0, 0, 0.18);
}
.platform-overlay {
background: var(--platform-overlay-fill);
}