diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 826c4db62..5c48bc9a8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 门禁,无法给运营、审核等人员分配独立账号和页面范围。 diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.css b/packages/shared/src/components/PlatformProfileRechargeModal/index.css new file mode 100644 index 000000000..5aae1750b --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.css @@ -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); +} diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx similarity index 98% rename from src/components/platform-entry/PlatformProfileRechargeModal.test.tsx rename to packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index c7bed4948..aa69e4d46 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -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 { diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx similarity index 53% rename from src/components/platform-entry/PlatformProfileRechargeModal.tsx rename to packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index f5acb0023..ecec85c75 100644 --- a/src/components/platform-entry/PlatformProfileRechargeModal.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -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 +) { + 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 ( +
+
event.stopPropagation()} + > +
+
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ +
+
+ {children} +
+
+
+ ); +} + +function PlatformRechargeActionButton({ + children, + className, + disabled, + onClick, +}: { + children: ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void; +}) { + return ( + + ); +} + +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 ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+ ); +} + +function PlatformRechargeEmptyState({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function PlatformRechargeStatusMessage({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function PlatformRechargePillBadge({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + function useWechatNativeQrCode(codeUrl: string | null) { const [qrImageUrl, setQrImageUrl] = useState(null); @@ -96,7 +318,6 @@ function useNativePaymentRemaining(expiresAt: string) { }; } -/** 充值套餐行,复用后端下发的商品与逐档首充资格。 */ function RechargeProductCard({ product, submittingProductId, @@ -114,27 +335,18 @@ function RechargeProductCard({ : null; return ( - 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" >
{badgeLabel ? ( - + {badgeLabel} - + ) : null}
@@ -150,10 +362,10 @@ function RechargeProductCard({ {formatRechargePrice(product.priceCents)} - + {submitting ? '处理中' : '购买'} - + ); } @@ -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 ( -
@@ -214,9 +426,7 @@ function PlatformProfileWechatNativePaymentModal({ 生成中 )}
- + {nativePayment.confirmMessage ? (
{nativePayment.confirmMessage}
) : null}
- + ); } -/** 主站与编辑器共用的泥点购买弹窗。 */ export function PlatformProfileRechargeModal({ center, isLoading, @@ -259,7 +468,7 @@ export function PlatformProfileRechargeModal({ return ( <> - - +
{error}
- 重新加载 - - + +
) : null } isLoading={isLoading} loadingState={ - + 暂无可购买套餐 - + } >
@@ -322,8 +520,8 @@ export function PlatformProfileRechargeModal({ /> ))}
-
-
+ + {nativePayment ? (