Merge branch 'hermes/wechat'
# Conflicts: # .hermes/shared-memory/decision-log.md # docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md # docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md # server-rs/crates/module-runtime/src/errors.rs # src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||
@@ -57,6 +58,7 @@ import type {
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
WechatNativePayment,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
@@ -73,6 +75,14 @@ import {
|
||||
updateAuthProfile,
|
||||
} from '../../services/authService';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
} from '../../services/payment/paymentPlatform';
|
||||
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
|
||||
import {
|
||||
claimRpgProfileTaskReward,
|
||||
confirmWechatRpgProfileRechargeOrder,
|
||||
@@ -217,9 +227,9 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
@@ -235,6 +245,10 @@ type RechargePaymentResult = {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
type NativeWechatPaymentState = WechatNativePayment & {
|
||||
orderId: string;
|
||||
isConfirming: boolean;
|
||||
};
|
||||
type DiscoverChannel =
|
||||
| 'recommend'
|
||||
| 'today'
|
||||
@@ -2527,18 +2541,6 @@ function formatRechargePrice(priceCents: number) {
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function isWechatMiniProgramWebView() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program'
|
||||
);
|
||||
}
|
||||
|
||||
function clearWechatPayResultHash() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -2685,6 +2687,36 @@ async function confirmWechatRechargeOrderUntilSettled(
|
||||
return latestResponse;
|
||||
}
|
||||
|
||||
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 RechargeProductCard({
|
||||
product,
|
||||
submittingProductId,
|
||||
@@ -2737,22 +2769,29 @@ function ProfileRechargeModal({
|
||||
isLoading,
|
||||
error,
|
||||
submittingProductId,
|
||||
nativePayment,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onRetry,
|
||||
onBuy,
|
||||
onConfirmNativePayment,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
submittingProductId: string | null;
|
||||
nativePayment: NativeWechatPaymentState | null;
|
||||
activeTab: RechargeTab;
|
||||
onTabChange: (tab: RechargeTab) => void;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
onConfirmNativePayment: () => void;
|
||||
}) {
|
||||
const nativeQrImageUrl = useWechatNativeQrCode(
|
||||
nativePayment?.codeUrl ?? null,
|
||||
);
|
||||
const products =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
@@ -2841,6 +2880,33 @@ function ProfileRechargeModal({
|
||||
暂无可购买套餐
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nativePayment ? (
|
||||
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-4 text-center">
|
||||
<div className="text-sm font-black">微信扫码支付</div>
|
||||
<div className="mx-auto mt-3 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirmNativePayment}
|
||||
disabled={nativePayment.isConfirming}
|
||||
className="platform-primary-button mt-4 rounded-2xl px-4 py-2 text-xs font-black disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{nativePayment.isConfirming ? '确认中' : '我已支付'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3594,6 +3660,7 @@ export function RpgEntryHomeView({
|
||||
hasUnreadDraftUpdate = false,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const showRechargeEntry = shouldShowRechargeEntry();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
|
||||
@@ -3611,6 +3678,8 @@ export function RpgEntryHomeView({
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [rechargePaymentResult, setRechargePaymentResult] =
|
||||
useState<RechargePaymentResult | null>(null);
|
||||
const [nativeWechatPayment, setNativeWechatPayment] =
|
||||
useState<NativeWechatPaymentState | null>(null);
|
||||
const [activeRechargeTab, setActiveRechargeTab] =
|
||||
useState<RechargeTab>('points');
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
@@ -4149,6 +4218,7 @@ export function RpgEntryHomeView({
|
||||
loadRechargeCenter();
|
||||
setSubmittingRechargeProductId(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setNativeWechatPayment(null);
|
||||
}, [loadRechargeCenter]);
|
||||
const handleWechatPayResult = useCallback(() => {
|
||||
const payResult = readWechatPayResultFromHash();
|
||||
@@ -4232,16 +4302,24 @@ export function RpgEntryHomeView({
|
||||
setIsRechargeOpen(true);
|
||||
loadRechargeCenter();
|
||||
};
|
||||
const openRechargeOrRewardCodeModal = () => {
|
||||
if (showRechargeEntry) {
|
||||
openRechargeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
openRewardCodeModal();
|
||||
};
|
||||
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentChannel = isWechatMiniProgramWebView()
|
||||
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||
: 'mock';
|
||||
const paymentChannel = resolveProfileRechargePaymentChannel();
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
setRechargePaymentResult(null);
|
||||
setNativeWechatPayment(null);
|
||||
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||
.then(async (response) => {
|
||||
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
||||
@@ -4252,24 +4330,105 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
setRechargeCenter(response.center);
|
||||
return;
|
||||
} else {
|
||||
}
|
||||
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
|
||||
const h5Url = response.wechatH5Payment?.h5Url?.trim();
|
||||
if (!h5Url) {
|
||||
throw new Error('微信 H5 支付链接生成失败');
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
setRechargeCenter(response.center);
|
||||
setRechargePaymentResult({
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
kind: 'pending',
|
||||
title: '正在打开微信支付',
|
||||
message: '完成支付后返回页面确认到账状态。',
|
||||
});
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setSubmittingRechargeProductId(null);
|
||||
redirectToPaymentUrl(h5Url);
|
||||
return;
|
||||
}
|
||||
void onRechargeSuccess?.();
|
||||
if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) {
|
||||
const codeUrl = response.wechatNativePayment?.codeUrl?.trim();
|
||||
if (!codeUrl) {
|
||||
throw new Error('微信 Native 支付二维码生成失败');
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
setRechargeCenter(response.center);
|
||||
setNativeWechatPayment({
|
||||
orderId: response.order.orderId,
|
||||
codeUrl,
|
||||
isConfirming: false,
|
||||
});
|
||||
setSubmittingRechargeProductId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('充值支付渠道无效');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
setNativeWechatPayment(null);
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
setSubmittingRechargeProductId(null);
|
||||
});
|
||||
};
|
||||
const confirmNativeWechatPayment = useCallback(() => {
|
||||
if (!nativeWechatPayment || nativeWechatPayment.isConfirming) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNativeWechatPayment((current) =>
|
||||
current && current.orderId === nativeWechatPayment.orderId
|
||||
? { ...current, isConfirming: true }
|
||||
: current,
|
||||
);
|
||||
setRechargePaymentResult({
|
||||
kind: 'pending',
|
||||
title: '正在确认支付',
|
||||
message: '正在查询微信支付到账状态。',
|
||||
});
|
||||
void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId)
|
||||
.then((response) => {
|
||||
const isPaid = response.order.status === 'paid';
|
||||
setRechargeCenter(response.center);
|
||||
setRechargePaymentResult(
|
||||
isPaid
|
||||
? {
|
||||
kind: 'success',
|
||||
title: '支付成功',
|
||||
message: '已到账,账户状态已刷新。',
|
||||
}
|
||||
: {
|
||||
kind: 'pending',
|
||||
title: '等待微信确认',
|
||||
message: '暂时没能确认到账状态,请稍后再试。',
|
||||
},
|
||||
);
|
||||
if (isPaid) {
|
||||
setNativeWechatPayment(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
void onRechargeSuccess?.();
|
||||
} else {
|
||||
setNativeWechatPayment((current) =>
|
||||
current && current.orderId === nativeWechatPayment.orderId
|
||||
? { ...current, isConfirming: false }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setRechargePaymentResult({
|
||||
kind: 'pending',
|
||||
title: '等待微信确认',
|
||||
message: '暂时没能确认到账状态,请稍后再试。',
|
||||
});
|
||||
setNativeWechatPayment((current) =>
|
||||
current && current.orderId === nativeWechatPayment.orderId
|
||||
? { ...current, isConfirming: false }
|
||||
: current,
|
||||
);
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
}, [nativeWechatPayment, onRechargeSuccess]);
|
||||
useEffect(() => {
|
||||
const handleResume = () => {
|
||||
handleWechatPayResult();
|
||||
@@ -5569,13 +5728,21 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargeModal}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Coins className="h-4 w-4" />
|
||||
{showRechargeEntry ? (
|
||||
<Coins className="h-4 w-4" />
|
||||
) : (
|
||||
<Ticket className="h-4 w-4" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-bold">充值</div>
|
||||
<div className="text-[10px] opacity-80">泥点/会员</div>
|
||||
<div className="text-xs font-bold">
|
||||
{showRechargeEntry ? '充值' : '兑换码'}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -5659,11 +5826,19 @@ export function RpgEntryHomeView({
|
||||
onClick={openTaskCenterPanel}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
icon={Ticket}
|
||||
onClick={openRewardCodeModal}
|
||||
label={showRechargeEntry ? '充值' : '兑换码'}
|
||||
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
|
||||
icon={showRechargeEntry ? Coins : Ticket}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
/>
|
||||
{showRechargeEntry ? (
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
icon={Ticket}
|
||||
onClick={openRewardCodeModal}
|
||||
/>
|
||||
) : null}
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel={
|
||||
@@ -6149,11 +6324,13 @@ export function RpgEntryHomeView({
|
||||
isLoading={isLoadingRechargeCenter}
|
||||
error={rechargeError}
|
||||
submittingProductId={submittingRechargeProductId}
|
||||
nativePayment={nativeWechatPayment}
|
||||
activeTab={activeRechargeTab}
|
||||
onTabChange={setActiveRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
onConfirmNativePayment={confirmNativeWechatPayment}
|
||||
/>
|
||||
) : null;
|
||||
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
|
||||
|
||||
Reference in New Issue
Block a user